diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b2e07a2a35..b1f02469a7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.114", + "version": "1.0.118", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -11,7 +11,7 @@ { "name": "genesis-tools", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", - "version": "1.0.114", + "version": "1.0.118", "author": { "name": "GenesisTools" }, @@ -26,8 +26,8 @@ }, { "name": "genesis-tools-mods", - "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Install with: tools agents hooks mods install --write.", - "version": "1.5.2", + "description": "Claude Code mods for GenesisTools, each switched by mods. in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows and beside its type in the agents panel (mods.agentRow, through Claude Code's subagentStatusLine), idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable --write. Install with: tools agents hooks mods install --write.", + "version": "1.16.0", "author": { "name": "GenesisTools" }, diff --git a/.claude/rules/agent-transcripts.md b/.claude/rules/agent-transcripts.md new file mode 100644 index 0000000000..004152bb52 --- /dev/null +++ b/.claude/rules/agent-transcripts.md @@ -0,0 +1,59 @@ +--- +paths: + - "src/agents/**" + - "src/question/**" + - "src/hub/**" + - "src/claude/**" + - "src/codex/**" + - "src/grok/**" + - "src/ai-spend/**" + - "src/genesis-tools-mcp/**" + - "src/utils/agent-sessions/**" + - "src/utils/ai/transcripts/**" + - "src/utils/session-transcript/**" + - "src/utils/cmux/**" + - "src/utils/claude/**" + - "plugins/**" +--- + +# Reading a coding agent's transcript (Claude, Codex, Grok) + +Loaded by Claude Code when you read, write or edit a file in a folder that reads agent sessions. Martin's rule +(2026-10-25): every fact read from a session transcript is a member of the provider interface, implemented by every +provider, so a new provider is a list of members to fill in and no feature parses a transcript format itself. + +- **The interface is `NativeSessionReader`** (`src/utils/agent-sessions/types.ts`), reached through + `ProviderPlugin.codingAgent` and `resolveHistoryProvider(kind)` (`src/utils/agent-sessions/provider.ts`). + Claude, Codex and Grok implement it in `src/utils/agent-sessions/readers/.ts`. Never add another + provider registry or a `switch (harness)` over transcript formats in a tool. +- **A new transcript capability is a REQUIRED member**, implemented by all three readers in the same change. The + compiler then names every provider (and test fake) that lacks it. When a provider cannot answer, its member says + so in code and in a comment (Grok's chat history carries no timestamps, so its `readBackward` is bounded by + content and bytes, never by time). An optional member lets the next provider forget it; do not add one for a + capability features rely on. +- **Members today:** `discover`, `read`, `readMetadata`, `scan`, `readRecords`, `readStatistics`, and + `readBackward` (the newest entries, from the end back to the entry a caller's `until` accepts; added 2026-10-25 + for the decision Stop hook's chat answers). A hook or tool reaches it with `readSessionBackward({ kind, + transcriptPath, until })` (`src/utils/agent-sessions/session-backward.ts`). +- **What a feature gets is normalized**: `NativeHistoryEntry` (role, text, timestamp, tool, paths). Logic over it + (decision answers in `src/question/lib/decisions/chat-answers.ts`, a reply's decision blocks) is provider-neutral + and never names a JSONL field such as `"type":"assistant"`, `response_item`, `continued-in` or `chat_history`. +- **Legacy parsers still exist** (about 20 folders read transcript rows directly: the decision hook's `finalReply`, + `src/claude/lib/usage/session-rows.ts` for `continued-in`, `src/utils/ai/transcripts/`). Do not copy them. When a + change touches one, move the fact it reads into a reader member first. +- **Lint enforces it**: `no-transcript-format-outside-readers` in `scripts/ci/lint-rules.ts` fails a transcript record + type or field literal (`"continued-in"`, `"turn_context"`, `'"type":"user"'`, …) outside the readers. The files that + still do it are listed in `scripts/ci/transcript-format-ratchet.ts`; a file only ever leaves that list. +- **Four more lints guard the same seam** (2026-10-25), each with a list in `scripts/ci/` that only shrinks: + - `no-hand-built-claude-home`, `no-hand-built-codex-grok-home` (`native-home-ratchet.ts`): a path into + `~/.claude`, `~/.codex`, `~/.grok` or their env overrides goes through `nativeHome(kind)` or + `nativeSessionRoots(kind)` in `src/utils/providers/session-paths.ts`. + - `no-harness-name-branch` (`harness-ratchet.ts`): `x.harness === "codex"` or a `case "grok":` in a tool is a + provider member instead. + - `no-hook-payload-read`: a hook payload field (`transcript_path`, `tool_input`, …) is read in + `src/agents/lib/hooks/payload.ts` only; add a typed `HookPayload` field there. + - `no-agent-name-in-command-match`: finding a running agent by its command line goes through + `src/utils/process/classify.ts`. +- **A reader that caps or skips records says so in the log.** A byte cap, a timeout or a skipped row that hides + newer records logs the path, the cap and what was missed, so a frozen age or a missing prompt is found from + `~/.genesis-tools/logs/` and not by a user. diff --git a/.claude/rules/ai.md b/.claude/rules/ai.md index 88fd92850d..86464df68b 100644 --- a/.claude/rules/ai.md +++ b/.claude/rules/ai.md @@ -28,7 +28,7 @@ Everything AI lives under `src/utils/ai/` + `src/utils/security/`, in layers tha **Codex account ownership:** `src/utils/ai/openai/account-binding.ts` is the shared subscription binding for native terminals, workers, usage and SDK requests. Keep immutable account/workspace identity across refresh and renames. Vault grants have one refresh owner; native auth-file references are read-only. Diagnostics use `AiConfigStore.readOnly()` for metadata too, because ordinary `load()` may run migrations. Native app-server authentication uses external tokens with ephemeral credential storage; never swap the shared home's auth file. -**Native CLI history:** extend `ProviderPlugin.codingAgent` and the readers/index under `src/utils/agent-sessions/`; do not add another provider registry. This derived history index is separate from app-owned `src/utils/ai/session/` conversations. Session identity includes provider, native ID and canonical source home. Import through the provider's native API with source-preserving provenance; never manufacture native SQLite rows or infer historical account ownership from today's login. +**Native CLI history:** extend `ProviderPlugin.codingAgent` and the readers/index under `src/utils/agent-sessions/`; do not add another provider registry. Every fact a feature reads from a transcript is a required `NativeSessionReader` member that all three providers implement: `.claude/rules/agent-transcripts.md`. This derived history index is separate from app-owned `src/utils/ai/session/` conversations. Session identity includes provider, native ID and canonical source home. Import through the provider's native API with source-preserving provenance; never manufacture native SQLite rows or infer historical account ownership from today's login. **Cost math is `src/utils/ai/llm-cost.ts` only**: `calculateCallCostUsd` (prices cache reads and writes at their own rates) and `estimateLlmCallCostUsd`. No rate table belongs there, or anywhere new. diff --git a/.claude/rules/genesis-links.md b/.claude/rules/genesis-links.md new file mode 100644 index 0000000000..2729e08338 --- /dev/null +++ b/.claude/rules/genesis-links.md @@ -0,0 +1,42 @@ +--- +paths: + - "src/**/*.ts" + - "src/**/*.tsx" + - "plugins/**" +--- + +# Links to GenesisTools apps, and code that needs GenesisTools + +Loaded by Claude Code when you read, write or edit TypeScript under `src/` or any file under `plugins/`. + +A `https:///…` link (genesis.tools here) opens something only when all of these hold on the Mac that +clicks it: GenesisTools.app is installed, macOS gives https links to it, the router config has a route for that +path, and the app or command the route names is there (Genesis Markdown for `/md/`, the `tools` CLI and cmux for +`/claude/find/`). Miss one and the click goes to the browser, onto a page that does not exist. A `genesis-md://` or +`genesis-tools://` link needs its app. Never write such a link without the check below. + +- **`tools genesis-tools probe`** answers it for this Mac: the CLI, the app, the router, Genesis Markdown, cmux, and + for each link kind (`md`, `finder`, `hub`, `claude-find`) whether a click opens it, by the real router config. + `probe md finder` exits 1 when one of them is missing and prints the fix command; `--url ` checks one link; + `--json` is for code. It reads only. +- **In `src/`**: build a link with `routerLink()` (`src/utils/browser-router/router-link.ts`), which gives the + fallback with a coded reason when the link would be dead. The lint rule `no-raw-genesis-link` fails a literal + anywhere else. Code that needs the whole picture calls `probeGenesisTools()` or `linkState(url, facts)` from + `src/utils/genesis-tools/probe.ts`. +- **In a plugin**: the marketplace installs a plugin without the GenesisTools checkout, so `tools` itself may be + missing, and a plugin file cannot import `src/`. The mods plugin asks `plugins/genesis-tools-mods/hooks/genesis.ts`: + it runs `tools genesis-tools probe --json` once per 30 minutes (a failed spawn means no CLI, so no links and no + `tools` commands), builds every link from the probe's link host, and returns undefined for a kind that does not + open. A feature that runs `tools …` checks `(await genesisProbe(host)).tools` first and shows no control that + would run it. Something that needs no router does it directly: a Finder reveal is `open -R `. +- **The fallback is part of the feature.** No md link: a note gets `cursor://file:` or its plain path. + No finder link: a folder stays text (a plain folder path opens nothing useful). No hub or decision link: the SHA + or the `❓ DECISION n` stays text. +- **A new link kind** is an entry in `LinkTargets` (`src/utils/app-links/targets.ts`) and its routing in + `router-link.ts`, a router preset that writes its route (`src/utils/browser-router/presets.ts`), and a kind in + `GENESIS_LINK_KINDS` (`src/utils/genesis-tools/probe.ts`) plus the plugin's `LINK_KINDS`. `finder` (2026-10-23) + was only a hand-made route on one Mac until it got its preset. +- **Ask Launch Services, not Spotlight, whether an app is installed.** `appPathForBundleId()` + (`src/utils/macos/launch-services.ts`) is the lookup `open -b` and a scheme click use. On 2026-10-23 `mdfind + kMDItemCFBundleIdentifier == …` found no app on a Mac with indexing on, so the genesis-md preset read "needs app" + while Genesis Markdown opened every link. diff --git a/.claude/rules/markdown-viewer.md b/.claude/rules/markdown-viewer.md index 0877a58e1b..cf1fcb22ca 100644 --- a/.claude/rules/markdown-viewer.md +++ b/.claude/rules/markdown-viewer.md @@ -18,7 +18,7 @@ day of review (PRs #499, #501). `open -g -n ~/Applications/GenesisTools.app --args --markdown --no-focus`. - Genesis Markdown.app (GenesisPlayground) builds against this checkout's GenesisKit: `GENESIS_KIT_PATH=/src/macos/GenesisKit bun scripts/install.ts` in `GenesisPlayground/Genesis/apps/Genesis`. - It relaunches the viewer only when it was open. Its `swift build` needs `--disable-build-manifest-caching` too. + It starts the viewer after every install, in the background (`open -g`, with its saved tabs), open before or not. Its `swift build` needs `--disable-build-manifest-caching` too. - The diff page (`src/macos/GenesisTools/web/diff-viewer`) is bundled by `buildDiffViewer` in `src/macos/lib/permissions/app.ts`; both installs call it. @@ -58,7 +58,8 @@ day of review (PRs #499, #501). the window before, `NSWindowController.init(window:)` drops it. ## Martin's UX calls for this viewer (2026-10-13) -- Lists of changes fold untracked files by folder, page, and stay lazy; counts are meaningful. +- Lists of changes fold untracked files by folder and build only the rows on screen (an AppKit table; no pages and + no "Show more" since 2026-10-25); counts are meaningful. - Diffs: left strips for whole added or removed blocks, word marks inside changed lines, no row backgrounds; `+` green, `−` red. Commit choosers are popovers with details, built when opened. Tabs: `GenSegmentedTabs`, animated. - A pane never goes blank between two contents: the old one stays until the new one has rendered. diff --git a/.claude/rules/models.md b/.claude/rules/models.md index 760683edce..65a70a988a 100644 --- a/.claude/rules/models.md +++ b/.claude/rules/models.md @@ -36,6 +36,13 @@ These read the catalog and need no edit of their own: - **The OpenRouter snapshot**: `src/utils/ai/catalog/data/openrouter-snapshot.json`, refreshed with `bun scripts/ai/update-openrouter-snapshot.ts`. A diff there is a price change; read it before you commit. - **The handoff-to skill rankings**: `plugins/genesis-tools/skills/handoff-to/SKILL.md` and `references/codex.md`. Changing them bumps the plugin version (`.claude/rules/plugins.md`). +## Lint + +Three lint rules keep model facts in the owners above, each with a list in `scripts/ci/model-facts-ratchet.ts` that +only shrinks (2026-10-25): `no-model-id-literal-outside-catalog` (a whole literal that is a model id), `no-model-price-table` +(`opus: { input: … }`) and `no-effort-vocabulary-list` (an array or union of effort words). A list of efforts for one +provider is `effortRange(from, to)` or a filter of `REASONING_EFFORTS` (`src/utils/ai/catalog/efforts.ts`). + ## Checklist for a new model or a price change 1. Read the vendor's own page, not a blog: the model page (`platform.claude.com/docs/en/models/`, `developers.openai.com/api/docs/models/`, `docs.x.ai/developers/`) and the vendor pricing page. Note the date you read it in the code comment. diff --git a/.claude/rules/opinionated-settings.md b/.claude/rules/opinionated-settings.md new file mode 100644 index 0000000000..79863b6f40 --- /dev/null +++ b/.claude/rules/opinionated-settings.md @@ -0,0 +1,45 @@ +--- +paths: + - "src/macos/**/*.swift" + - "**/*.tsx" + - "src/*/ui/**" + - "src/dashboard/**" + - "src/dev-dashboard/**" + - "src/hub/lib/widget/**" + - "src/notify/**" +--- + +# Opinionated behavior gets a setting + +Loaded by Claude Code when you read, write or edit native or web UI code, the widget's wire schema, or notifications. + +Martin's rule (2026-10-23): when a UI change picks one way where a reasonable user could want another, the choice +is a setting with that way as its default. You decide the default and ship the setting in the same change; do not +ask first, and do not ship the behavior hard-coded. + +## Opinionated means + +- **Interruptions**: a sound, which sound per kind of event, a popup, a panel that opens or expands by itself, a + notification, a focus steal. +- **Attention cues**: a flash, a pulse, a badge, their length and their colour; "flash the thing that caused it" is + the expected default, and its duration is the setting. +- **Placement and order**: where a composer or toolbar sits (top or bottom), list order, which filter a list opens + with, what a list shows (only what needs you, or everything active). +- **Hover behavior**: a tooltip or a preview on hover, hover delays, whether hover opens anything at all. +- **Timings**: auto-collapse delays, how long a popup stays. + +Not opinionated: a bug fix, a layout that was wrong, a missing hover effect, accessibility labels, performance. +Those are fixed, not made optional. + +## How + +- One preference per choice, stored where its surface already stores preferences (the widget: the zod schema in + `src/hub/lib/widget/types.ts` plus `WidgetPreferences` in GenesisKit `WidgetWireModels.swift`; a native window: + its settings store). An enum over a boolean when a third way is plausible ("auto" | "always" | "never"). +- The control goes on the settings page of that surface, in the card the behavior belongs to, with a one-line detail + that says what it does. A sound picker has a play button. A list that has a filter in place gets a settings + shortcut that opens the page at its card (`#`, `.nativeSettingsAnchor`), which flashes the card's border. +- The default is your best judgment of what Martin asked for; the option you did not pick stays reachable. +- A shared component's look change keeps the old look as a named option (the `AttentionPulse` presets: the border + flash is the default, the filled flash stays as `.filledFlash`). +- Say in the reply which settings you added and their defaults. diff --git a/.claude/rules/plugins.md b/.claude/rules/plugins.md index a3b2d39728..e2fd2dcb71 100644 --- a/.claude/rules/plugins.md +++ b/.claude/rules/plugins.md @@ -10,6 +10,19 @@ Loaded by Claude Code when you read, write or edit a file under `plugins/` or `. - **A runtime plugin file is standalone.** The harness copies `plugins/**` out of this checkout, so no runtime plugin file imports `@genesiscz/*` or `@app/*`; inline what it needs or import a sibling file. `bun scripts/ci/check-plugin-standalone.ts` fails CI on a violation. Plain `JSON` is allowed in `plugins/**`. - **A change users should get bumps the version** in `plugins/genesis-tools/.claude-plugin/plugin.json` and in `.claude-plugin/marketplace.json`, which holds it twice (top level and the plugin entry). +- **The mods plugin (`plugins/genesis-tools-mods/`) hot-reloads in every running session** that has its folder in + `CLAUDE_CODE_PLUGIN_DIRS` (the shell, or the `env` block of `~/.claude/settings.json`): Claude Code 2.1.296 watches + that folder in each interactive session (`CLAUDE_CODE_PLUGIN_DIR_WATCH`, on by default in a terminal) and reloads + the mods when a file is saved, so do not ask the user to run `/reload-plugins` after an edit. Proof that a save + reached the sessions: new `"why":"start"` lines in `~/.genesis-tools/agents/mods-log/git.jsonl` within seconds, + one per session (2026-10-23, 18:44). Each save reloads every session, a half-finished edit too: on 2026-10-23 a + session drew four git rows for a moment, because `git-band.tsx` already read a setting that `settings.ts` did not + have yet. Finish the files of one change before you save the one that depends on them. +- **Never start slow work from a mods `ui.render` hook.** The engine aborts a render's in-flight `$` calls when a newer + render of the same view replaces it (`$.process.run(...) aborted`), and the band redraws every second while a live + time shows. On 2026-10-23 the first `tools genesis-tools probe` ran from a render, was aborted, and the plugin kept + "no link opens" for 30 minutes in every session. Start such work from `session.start` or a timer + (`primeGenesisProbe`), let a render only read the result, and never remember a result that came from an abort. - **Skills load live from this working tree** when the session's folder is the marketplace folder (this repo). Any other folder gets the version-keyed cache: `claude plugin update genesis-tools@genesis-tools` refreshes it, and `/reload-plugins` re-reads it in the middle of a session. - **Codex runs this plugin too, hooks included**, from its own version-keyed cache (`~/.codex/plugins/cache/genesis-tools/genesis-tools//`, one per Codex home). An edit reaches Codex only after `codex plugin add genesis-tools@genesis-tools` in each home (`CODEX_HOME= codex plugin add …`). A hook decides the harness from its payload, never from the environment (`hooks/harness.ts`). - **A hook runs on every matching tool call.** Keep it to one `bun` start (about 35 ms) and exit early when it has nothing to say. A PostToolUse payload from a subagent carries `agent_id`; key per-agent state on it. diff --git a/.claude/rules/tools.md b/.claude/rules/tools.md index 29c6cd3787..f6b9fef1d5 100644 --- a/.claude/rules/tools.md +++ b/.claude/rules/tools.md @@ -17,7 +17,7 @@ Loaded by Claude Code when you read, write or edit TypeScript under `src/` or `s ## Utility Convention -When creating a new tool and writing helper functions, check if the utility is **general-purpose** (usable by other tools). If so, place it in `src/utils/` instead of inside the tool directory: +Whenever you write a helper, in a new tool or in a change to an existing one, ask whether another tool could use it. If so, it goes in `src/utils/`, not in `src//`. The tells: it parses a format another tool also reads (a transcript, a git output, a config file), it wraps a CLI or an OS service, or a second tool already has a private copy (`tools ts refactors --include shadowed` lists those). A transcript fact is a provider reader member: `.claude/rules/agent-transcripts.md`. **🛑 `src/utils/**` is the `@genesiscz/utils` package and can NEVER import `@app/*`.** That includes every subfolder (`src/utils/claude/`, `src/utils/ai/`, …); there is no exception for a folder named after a tool. If code you are writing under `src/utils/` needs an `@app//...` module, that module belongs in `src/utils/**` too: move it there (with its tests) and import it via `@genesiscz/utils/...`. Never solve it by moving YOUR file out to the tool directory, and never by a relative `../..//` path. `scripts/ci/check-package-boundaries.ts` fails CI on any `@app/*` import under `src/utils/**`; run it before you commit a move. @@ -39,6 +39,7 @@ When creating a new tool and writing helper functions, check if the utility is * - `src/utils/repo-context/` - Repository context gatherers (agent instruction files, owning project + package manager + test runner, test commands) over a caller-supplied reader; `gatherRepoContext()`, `defaultGatherers()` - `src/utils/ai/evaluation/spend.ts` - All Jev spend from the usage ledger: `jevSpend()`, `jevCalls()` (by feature label, model, day) - `src/utils/browser-router/router-link.ts` - Every link to a GenesisTools app: `routerLink({ preset, action, data, via?, when?, render?, fallback })` returns `{ result, link, via, error? }`, the link only when it opens on this Mac (router installed, the https handler, preset routed; or the app installed for its scheme) and the fallback with a coded reason otherwise; `schemeLink()` for a link the code opens itself. A new kind of link is a new entry in its `LinkTargets` table, never a URL at a call site: lint-rules `no-raw-genesis-link` fails a `genesis.tools/`, `genesis-md://` or `genesis-tools://` literal anywhere else +- `src/utils/genesis-tools/probe.ts` - What of GenesisTools this Mac has and which link kinds (`md`, `finder`, `hub`, `claude-find`) a click opens: `probeGenesisTools()`, `linkState(url, facts)`; `tools genesis-tools probe` prints it. `src/utils/macos/launch-services.ts` `appPathForBundleId()` is the app lookup (`open -b`'s, not Spotlight's). Rules: `.claude/rules/genesis-links.md` Tool-specific logic stays in the tool directory (e.g., `src/har-analyzer/core/`). diff --git a/.claude/skills/babysit/SKILL.md b/.claude/skills/babysit/SKILL.md index de250c4449..951782304a 100644 --- a/.claude/skills/babysit/SKILL.md +++ b/.claude/skills/babysit/SKILL.md @@ -53,7 +53,8 @@ Write it to `/tmp/cc///babysit/run.json` at the start; the scr | Field | Default | Notes | |---|---|---| | `prs` | asked | PR numbers. Small PRs first; never a PR another session owns (step 3.1). | -| `worker` per PR | `grok` | `grok` (model `grok-4.7-build-fast`, `--auth subscription`) or `codex` (model `gpt-6.1-sol`, effort `medium`, `--write ask`). Mix freely; one worker per PR. | +| `worker` per PR | `grok` | `grok` (model `grok-4.7-build-fast`, `--auth subscription`) or `codex` (model `gpt-6.1-sol`, `--write ask`). Mix freely; one worker per PR. | +| `effort` per PR | from the work | Codex `--effort`: `low`, `medium`, `high`, `xhigh` or `max`. A fix round: `medium`, `high` for a Critical or a cross-file finding. An independent review (step 7a): no effort; `--mode review` picks high or xhigh from the diff size and gates the depth (handoff-to, "Effort is the review's depth"). | | `mode` | from `scripts/mode.ts` | `worktree` or `unified`, step 2. | | `maxParallel` | 3 | Workers at once. The master is the bottleneck, not the workers. | | `post` | ask | Post the round summary and replies after the push (review-loop step 4), or hold them in the vault. | @@ -174,7 +175,7 @@ summary go to `.claude/work/babysit/` inside that cwd (ignored by git; Grok's ja - Grok: `tools grok spawn --name bs--grok --cwd -m grok-4.7-build-fast --auth subscription --label "babysit pr" --prompt-file ` with Bash `run_in_background` (it blocks for the whole turn). -- Codex: `tools codex spawn --name bs--sol --model gpt-6.1-sol --effort medium --write ask --cwd --session +- Codex: `tools codex spawn --name bs--sol --model gpt-6.1-sol --effort --write ask --cwd --session --label "babysit pr" --prompt-file `. The `--label` tags every cost-ledger row of the session, so a run's spend is one query later (step 9). @@ -318,9 +319,15 @@ step 4 only when `post` is true. A round counts only when it fixed or answered real findings. When the reviewer bot has nothing left on a PR that still needs rounds, or a PR merged with fewer than three, run an independent read-only review: `bun -.claude/skills/babysit/scripts/review-brief.ts --run --pr --wt [--post-merge] [--swift]`, then -`steer.ts` with the printed file to a worker that did NOT fix this PR (Sol; Astra only for a risky cross-system PR, -by the model rule in step 1). Judge its findings like a bot's. Before a merge they become the next fix round (`fill-brief --judge --pr --wt [--post-merge] [--swift]`. It +prints the brief path and then the spawn line: `tools codex spawn --mode review --base origin/` (after a +merge `--base ^ --paths `) with the brief as `--prompt-file`, for a worker that did NOT +fix this PR (Sol; Astra only for a risky cross-system PR, by the model rule in step 1). Leave out `--effort`: the +harness picks high or xhigh from the diff size, gives the worker the manifest and the tier's contract, and gates the +depth after the turn (one steer with the unread files, then one rerun a tier higher as `bs--review-`, then +`shallow`). Read the result with `tools codex review-depth bs--review`; a `shallow` verdict is not a finished +review. Every run of the round is one command away: `tools codex reviews --pr ` (or `--label babysit`). Judge its +findings like a bot's. Before a merge they become the next fix round (`fill-brief --judge --dedicated-review`); after a merge they go to a follow-up PR from master (2026-10-14: #518 carried #509's after-merge findings). Never count a reply-only round, or a label, as a fix round: on 2026-10-14 the report said "#500 merged after 3 rounds" when GitHub showed two round summaries. diff --git a/.claude/skills/babysit/scripts/integrate.ts b/.claude/skills/babysit/scripts/integrate.ts index 612ca604ad..06c5e3751c 100644 --- a/.claude/skills/babysit/scripts/integrate.ts +++ b/.claude/skills/babysit/scripts/integrate.ts @@ -126,6 +126,12 @@ function gateSnapshot(options: { wt: string; sha: string; runDir: string }): str continue; } + // A dangling link (a `.bin` entry whose package is gone) has nothing to pin, and realpath throws on it. + if (!existsSync(input)) { + console.log(`gates: skipped the dangling dependency link ${relative(workerRoot, input)}`); + continue; + } + const target = realpathSync(input); const local = relative(workerRoot, target); const workspace = diff --git a/.claude/skills/babysit/scripts/review-brief.ts b/.claude/skills/babysit/scripts/review-brief.ts index f0469ac96a..4eab3e0988 100644 --- a/.claude/skills/babysit/scripts/review-brief.ts +++ b/.claude/skills/babysit/scripts/review-brief.ts @@ -2,10 +2,15 @@ /** * The brief for an independent read-only review round: when the reviewer bot has nothing left on a PR that still * needs rounds, or a PR merged with fewer than three. Its findings become the next fix round (`fill-brief --judge`) - * or, after a merge, a follow-up PR. Writes `/steer-review-.md` and prints the path for steer.ts. + * or, after a merge, a follow-up PR. Writes `/steer-review-.md`, prints its path, then the + * `tools codex spawn --mode review` line that starts the reviewer with it. * * bun review-brief.ts --run --pr 517 --wt [--base master] [--post-merge] [--swift] [--fresh] * + * The depth rules (read every file, probe each finding, the coverage line) are not in this brief: `--mode review` + * gives the worker its tier's contract and the diff manifest, and gates the depth after the turn + * (`tools codex reviews --pr ` lists every run). + * * `--fresh`: a PR opened minutes ago, reviewed next to the bots (the first round of a newly cut PR). * * Hand-edited copies of this text sent a reviewer to another PR's description (2026-10-14: a sed pass left @@ -14,6 +19,7 @@ import { join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; +import { shellWord } from "@genesiscz/utils/shell/quote"; import { flag, has, loadRun, reviewScratch } from "./run"; export interface ReviewBriefInput { @@ -46,13 +52,13 @@ export function reviewBrief({ pr, title, wt, base, scratch, postMerge, swift, fr fresh ? `- The PR has no answered threads yet. Do not wait for the bots and do not read their threads: review the code on your own.` : `- Earlier review threads were already answered; read them with \`tools github review ${pr} --llm\` so you do not report a finding that was fixed or deliberately declined (a declined one is fair game only with new evidence).`, - `- Check every claim: run the relevant tests (\`timeout 600 bun scripts/test.ts \`) or a small read-only probe under \`${scratch}\`, and paste the real output. A finding without evidence is a guess; say so.`, + `- Tests in this repository run as \`timeout 600 bun scripts/test.ts \`; put any read-only probe under \`${scratch}\`.`, ...(swift ? [ "- Swift: do not run `swift build` or `swift test` (the lead runs them); a Swift claim needs a code quote as evidence.", ] : []), - `- Write \`${join(wt, ".claude/work/babysit", `${pr}-review.md`)}\` (create the folder): one section per finding with severity (Critical, High, Medium, Low), file:line, the defect, the evidence, why it matters, and the smallest fix. End with a one-line verdict.`, + `- Write \`${join(wt, ".claude/work/babysit", `${pr}-review.md`)}\` (create the folder): the same numbered findings, \`Ran\`, \`Files reviewed\` and \`Not reviewed\` lines as your final message, with the evidence under each finding, then a one-line verdict.`, "- Change NO source file, commit nothing, post nothing to GitHub. Do NOT run `git checkout`, `git switch`, `bun install` or the hook installer. No app builds, installs, launches or relaunches. Another reviewer may read this same folder, so write only your own review file.", "", "Then stop with a report: the finding count by severity and the review file path, then RESULT / AT / CHANGED / VERIFY / OPEN.", @@ -60,6 +66,41 @@ export function reviewBrief({ pr, title, wt, base, scratch, postMerge, swift, fr return `${lines.join("\n")}\n`; } +export interface ReviewSpawnInput { + pr: number; + wt: string; + /** The PR's base branch, without `origin/`. */ + base: string; + briefPath: string; + /** After a merge the worktree sits at master: the manifest is the PR's files since its merge commit's parent. */ + postMerge?: { mergeCommit: string; files: string[] }; +} + +/** + * The reviewer's spawn line: `--mode review` with the base, so the harness computes the manifest and gates the depth. + * Every value is one shell word: a file name, branch or path from the repository may hold a space or `$(...)`, and + * the lead runs this line as printed. + */ +export function reviewSpawnCommand({ pr, wt, base, briefPath, postMerge }: ReviewSpawnInput): string { + const target = postMerge + ? ["--base", `${postMerge.mergeCommit}^`, "--paths", ...postMerge.files] + : ["--base", `origin/${base}`]; + const argv = [ + ...["tools", "codex", "spawn", "--mode", "review", ...target], + ...["--name", `bs-${pr}-review`, "--model", "gpt-6.1-sol", "--write", "ask", "--cwd", wt], + ...["--label", `babysit pr${pr} review`, "--prompt-file", briefPath], + ]; + + return argv.map(shellWord).join(" "); +} + +interface PrView { + title: string; + baseRefName?: string; + mergeCommit?: { oid?: string } | null; + files?: Array<{ path: string }>; +} + if (import.meta.main) { const run = loadRun(); const pr = Number(flag("--pr")); @@ -72,17 +113,21 @@ if (import.meta.main) { process.exit(2); } - const view = Bun.spawnSync(["gh", "pr", "view", String(pr), "--repo", run.repo, "--json", "title"], { - stdout: "pipe", - stderr: "pipe", - }); + const view = Bun.spawnSync( + ["gh", "pr", "view", String(pr), "--repo", run.repo, "--json", "title,baseRefName,mergeCommit,files"], + { stdout: "pipe", stderr: "pipe" } + ); if (view.exitCode !== 0) { console.error(`✖ gh pr view ${pr}: ${view.stderr.toString().trim()}`); process.exit(1); } - const { title } = SafeJSON.parse(view.stdout.toString(), { strict: true }) as { title: string }; + const { title, baseRefName, mergeCommit, files } = SafeJSON.parse(view.stdout.toString(), { + strict: true, + }) as PrView; + const base = flag("--base") ?? baseRefName ?? "master"; + const postMerge = has("--post-merge"); const out = join(run.dir, `steer-review-${pr}.md`); await Bun.write( out, @@ -90,12 +135,29 @@ if (import.meta.main) { pr, title, wt, - base: flag("--base") ?? "master", + base, scratch: reviewScratch(run.dir, pr), - postMerge: has("--post-merge"), + postMerge, swift: has("--swift"), fresh: has("--fresh"), }) ); console.log(out); + + if (postMerge && !mergeCommit?.oid) { + console.error(`✖ PR #${pr} has no merge commit, so a post-merge review has no base.`); + process.exit(1); + } + + console.log( + reviewSpawnCommand({ + pr, + wt, + base, + briefPath: out, + ...(postMerge && mergeCommit?.oid + ? { postMerge: { mergeCommit: mergeCommit.oid, files: (files ?? []).map((file) => file.path) } } + : {}), + }) + ); } diff --git a/.claude/skills/babysit/scripts/scripts.test.ts b/.claude/skills/babysit/scripts/scripts.test.ts index 5d40f8780c..00d2adcb5a 100644 --- a/.claude/skills/babysit/scripts/scripts.test.ts +++ b/.claude/skills/babysit/scripts/scripts.test.ts @@ -26,7 +26,7 @@ import { appendEvent, readEvents, renderMarkdown } from "./ledger"; import { continueVersionPick } from "./pick-unified"; import { summarize, testEvidence } from "./plant"; import { hitsInAddedLines, probeAddedLines } from "./probe"; -import { reviewBrief } from "./review-brief"; +import { reviewBrief, reviewSpawnCommand } from "./review-brief"; import { loadRun, runPaths } from "./run"; import { trySwiftSlot } from "./swift-slot"; import { parseSwiftTargets, typecheckPackage } from "./swift-typecheck"; @@ -656,6 +656,59 @@ describe("independent review brief", () => { expect(fresh).not.toContain("already answered"); expect(reviewBrief(input)).toContain("found nothing more"); }); + + test("the reviewer starts in --mode review against the PR base, and the brief leaves depth to its contract", () => { + const input = { + pr: 498, + title: "t", + wt: "/w", + base: "feat/parent", + scratch: "/s", + postMerge: false, + swift: false, + }; + expect(reviewBrief(input)).not.toContain("A finding without evidence is a guess"); + expect(reviewBrief(input)).toContain("`Files reviewed` and `Not reviewed` lines"); + + expect( + reviewSpawnCommand({ pr: 498, wt: "/w", base: "feat/parent", briefPath: "/run/steer-review-498.md" }) + ).toBe( + "tools codex spawn --mode review --base origin/feat/parent --name bs-498-review --model gpt-6.1-sol --write ask --cwd /w --label 'babysit pr498 review' --prompt-file /run/steer-review-498.md" + ); + expect( + reviewSpawnCommand({ + pr: 498, + wt: "/w", + base: "master", + briefPath: "/b.md", + postMerge: { mergeCommit: "abc123", files: ["src/a.ts", "src/b.ts"] }, + }) + ).toContain("--mode review --base 'abc123^' --paths src/a.ts src/b.ts --name bs-498-review"); + }); + + test("every value of the spawn line stays one literal shell word: spaces and $(...) in names do not split or run", () => { + const command = reviewSpawnCommand({ + pr: 498, + wt: "/tmp/my wt", + base: "feat/$(id)", + briefPath: "/run/brief file.md", + postMerge: { mergeCommit: "abc123", files: ["src/my file.ts", "$(touch /tmp/review-probe).ts"] }, + }); + expect(command).toContain("--paths 'src/my file.ts' '$(touch /tmp/review-probe).ts' --name"); + expect(command).toContain("--cwd '/tmp/my wt'"); + expect(command).toContain("--prompt-file '/run/brief file.md'"); + + // The shell itself reads it back as the intended argv, without running the substitution. + const parsed = Bun.spawnSync(["sh", "-c", `printf '%s\\n' ${command}`], { env: process.env, stdout: "pipe" }); + const argv = parsed.stdout.toString().trimEnd().split("\n"); + expect(argv).toContain("src/my file.ts"); + expect(argv).toContain("$(touch /tmp/review-probe).ts"); + expect(argv).toContain("/tmp/my wt"); + expect(argv[argv.indexOf("--label") + 1]).toBe("babysit pr498 review"); + expect(reviewSpawnCommand({ pr: 498, wt: "/w", base: "feat/$(id)", briefPath: "/b.md" })).toContain( + "--base 'origin/feat/$(id)'" + ); + }); }); describe("planted-regression summary", () => { test("reads bun counts and the XCTest total, and says so when there is none", () => { @@ -1509,7 +1562,7 @@ test("generated worker briefs follow the round and dedicated-review policy", () ); expect(result.exitCode).toBe(0); const brief = fs.readFileSync(target, "utf8"); - expect(brief).toContain(all ? "fix EVERY valid finding" : "fix Critical and High"); + expect(brief).toContain(all ? "fix EVERY valid finding" : "fix Critical, Major and High findings"); expect(brief).toContain("A valid security finding is always fixed"); expect(brief).toContain("Change every file the fix needs in this PR"); if (!all) { @@ -3116,6 +3169,9 @@ describe("round 15 review regressions", () => { fs.mkdirSync(installed); fs.writeFileSync(join(installed, "fixture.txt"), "trusted dependency"); fs.symlinkSync(installed, join(modules, "fixture-dependency")); + // A `.bin` link whose package is gone must not stop the gate snapshot (it did: ENOENT from realpath). + fs.mkdirSync(join(modules, ".bin")); + fs.symlinkSync("../gone/bin/gone.mjs", join(modules, ".bin/gone")); fs.writeFileSync(join(dir, "run.json"), SafeJSON.stringify({ repo: "genesiscz/GenesisTools", main: wt })); const preload = join(dir, "preload.ts"); const gateCwd = join(dir, "gate-cwd.txt"); diff --git a/.claude/skills/performance/SKILL.md b/.claude/skills/performance/SKILL.md index dcc794fc96..46c505e9f1 100644 --- a/.claude/skills/performance/SKILL.md +++ b/.claude/skills/performance/SKILL.md @@ -1,6 +1,6 @@ --- name: performance -description: Measuring and improving CPU, I/O and latency in the GenesisTools Bun/TypeScript code — CLI tools, the usage daemon, the hub server, session discovery and listing, file walking and reading, JSON parsing, bun:ffi and native code. Use it whenever a tool, command, daemon tick or server door is slow or burns CPU, before and after any change meant to make something faster, when you profile, benchmark, A/B or compare two ways of doing something, when you consider bun:ffi, TinyCC, a C helper or a cache, when code spawns ps, lsof or pgrep, when a test starts an executable, when a CI run is red, slow or hangs, and when you write a performance number into a commit, note or ledger — even when the user only says "this is slow", "why does it take 1 s", "can we make it faster", "is FFI faster", "measure it", or asks whether a cache would show stale data. For SwiftUI/AppKit app performance use the swift skill; this one covers the Bun side it calls. +description: Measuring and improving CPU, I/O and latency in the GenesisTools Bun/TypeScript code — CLI tools, the usage daemon, the hub server, session discovery and listing, file walking and reading, JSON parsing, bun:ffi and native code. Use it whenever a tool, command, daemon tick or server door is slow or burns CPU, before and after any change meant to make something faster, when you profile, benchmark, A/B or compare two ways of doing something, when you consider bun:ffi, TinyCC, a C helper or a cache, when code spawns ps, lsof or pgrep, when a test starts an executable, when a CI run is red, slow or hangs, and when you write a performance number into a commit, note or ledger — even when the user only says "this is slow", "why does it take 1 s", "can we make it faster", "is FFI faster", "measure it", or asks whether a cache would show stale data. Also use it for a daemon or scheduler tick that freezes or blocks the event loop, memory or RSS that grows, an append-only log, journal or database that grows and slows every read, a git hook (pre-push, post-checkout) that is slow or hangs, process priority or QoS, a child process that keeps a command from exiting, and the CPU cost of git status. For SwiftUI/AppKit app performance use the swift skill; this one covers the Bun side it calls. --- # Performance work in GenesisTools (Bun / TypeScript) @@ -43,12 +43,15 @@ measured. So the rule of this skill is simple: no change, and no claim, without wall time a lot and CPU time little. Note `uptime` before and after a run. - **Measure the real input.** Use the real session trees and transcripts (read-only), not a synthetic folder of ten files. Write the sizes into the result (files, lines, MB). -- **Bun 1.3.13 is still supported.** A Bun 1.4-only API (for example `Bun.JSONL`) may appear in a benchmark arm, - never in shipped code. +- **Bun 1.3.13 is still supported, and this Mac and CI run 1.4.2.** A Bun 1.4-only API (for example `Bun.JSONL`) + may appear in a benchmark arm, never in shipped code. Name the Bun version in every result: the two versions + disagree often enough to flip a verdict (the zstd frame walker wins only on 1.4.x, 2ff160f251; 1.4.2 loses a + `spawnSync` child's exit under `bun test --parallel`, c3ed8e3803). - **Diagnostics never mutate** (repository rule). A benchmark reads live data; it never rewrites it. - **No new `ps`, `lsof` or `pgrep` spawn.** Ask `@genesiscz/utils/process`; the lint rule `no-process-tool-spawn` fails a new spawn, and the platform fallback is the layer's decision, never the caller's. Skip a row you cannot - read, and never treat a failed read as "gone". `references/ffi-and-native.md` sections 3b and 3c. + read, and never treat a failed read as "gone". `references/ffi-and-native.md` sections 3b and 3c. An exception is + a named entry in the rule with its reason (tmp-reclaim's whole-machine `lsof`, 1ff8647046), never a silent spawn. - **A test never makes an executable per run.** Executing a file that a test just made, then renaming or deleting it, crashed syspolicyd and froze the Mac on 2026-10-14. Use `persistentFixture`. `references/tests-and-ci.md`. @@ -69,8 +72,21 @@ measured. So the rule of this skill is simple: no change, and no claim, without - "Which call sites still spawn, and at what cost?" → `bun scripts/benchmarks/process/call-sites-bench.ts --runs 3 --compare` (cold and warm CPU per site, spawned children included). - "Did CI really pass, and why is it red?" → `references/tests-and-ci.md` (a green job can carry failing tests). +- "Why does type-checking need gigabytes, and which code makes the checker work?" → `tools ts program cost|files|own|closure|trace`, + `references/typecheck.md` (the shapes that cost 175k instantiations per file, and the traps of a smaller program). +- "Is this process alive?" → the registry or pid file plus `process.kill(pid, 0)`, never a `ps` (`references/patterns.md`). +- "A file, journal or database grows and every read gets slower", "a tick or the scheduler freezes", "a hook hangs", + "a command waits for a child it abandoned" → `references/patterns.md`. A growing file always has two fixes, and an + answer names both: the reader reads from the end and stops early (after proving the file is time ordered), and the + writer writes less. Never compact or delete the history to get speed. +- "Which benchmark already covers this area?" → `scripts/benchmarks/README.md` and its folders: `startup/` (CLI + start and import cost), `agent-sessions/`, `hooks/` (hook latency, attribution), `polls/` (event-loop stalls, + wakeups), `macos-resources/` (spawn counts), `process/`, `fs/`, `json/`, `compress/`, `widget/`, `swift/`, + `dashboards/`, `statusline/`, `clones/`, `du/`. +- "The native app hangs or a SwiftUI view is slow" → the swift skill; `references/profiling.md` sections 7, 8 and + "The native app" hold the instrument recipes this skill shares with it. -## Measured facts to start from (2026-10-12, this Mac) +## Measured facts to start from (2026-10-12 to 2026-10-14, this Mac) - Bun process start plus imports dominate a small CLI call: about 86 ms of wall time for any tool, and 149 ms of CPU of imports for `ai usage sessions`. @@ -89,6 +105,9 @@ Every number above has its method and commit in `references/` and in the ledger. ## References +- `references/patterns.md`: the shapes that paid off since 2026-10-12, one commit each: reading a growing file from + its end, one snapshot for many keys, signal-0 liveness, overlapping legs, ticks that read only what is due, no + cleanup on the scheduler's path, priority as a setting, unref'ing an abandoned child, cheap and bounded hooks. - `references/profiling.md`: `tools ts profile`, `bun --cpu-prof`, `sample`, the repository profiler, and the traps in each (wall-sampled JS thread, await gaps, `bun test` ignoring `--cpu-prof`). - `references/measuring.md`: the benchmark harness, fresh-process cold/warm samples, interleaving, blocks, the @@ -105,5 +124,7 @@ Every number above has its method and commit in `references/` and in the ledger. - `references/caching-and-freshness.md`: the never-staler rule in practice, `FileIdentityCache`, persisted caches, serve-time clocks, the rows cache, the hub server, and the rejected skip-rebuild. - `references/ledger.md`: where results go and the entry format. +- `references/typecheck.md`: the TypeScript program's memory per phase, `tools ts program`, the code shapes that + multiply checker work (with before and after numbers), and what a smaller program misses. - `references/compression.md`: zstd levels and speeds, the Bun 1.3.13 first-frame trap, seekable frames, reading compressed data within the 30% budget (measured per real command), backpressure for a spawned reader, APFS clones. diff --git a/.claude/skills/performance/evals/evals.json b/.claude/skills/performance/evals/evals.json index a4befd53ef..3296d22ce0 100644 --- a/.claude/skills/performance/evals/evals.json +++ b/.claude/skills/performance/evals/evals.json @@ -18,6 +18,24 @@ "prompt": "The hub's agent list re-reads the first line of every Codex rollout on each refresh. Design a cache for that so it's cheap, but it must never show an older header than what's on disk. Just the design, with the exact invalidation rule.", "expected_output": "Uses file identity (inode, size, mtime, ctime) taken before the read, caches only the parsed header, never caches failures, persists with a version, rejects TTLs and folder-mtime checks, and points at FileIdentityCache.", "files": [] + }, + { + "id": 4, + "prompt": "The widget shows cards for the selected session, and we want the newest cards of up to 16 other inbox sessions ready too so a switch is instant. Adding a per-session answers query for each took the snapshot from 80 ms to 183 ms of CPU. Find the cost and fix it without showing older data. Describe how you would measure it. Do not edit files.", + "expected_output": "Finds that each per-session query opens its own read snapshot and re-runs the answer-log catch-up (a fixed cost per item), and proposes one snapshot that reads all warm sessions with one catch-up and a per-session limit (patterns.md, 8c431c540f: 183.2 to 95.7 ms). Measures CPU time on real data with interleaved arms and a median, writes the load average, checks that the cards are identical, and adds no TTL or stale cache. A weak answer guesses at JSON encoding or adds a timer cache without profiling.", + "files": [] + }, + { + "id": 5, + "prompt": "The cmux surface journal is 6.9 MB, 19,344 lines, and every `tools codex who` parses all of it. I propose compacting the file to the last 24 h. Is that right? What would you do instead, and how do you prove it returns the same answer? Do not edit files.", + "expected_output": "Keeps the journal whole (the compaction was undone at the user's request on 2026-10-15) and either finds that both fixes already shipped (the reader reads from the end and stops after the readers' 7 days, 4d0f2cc3c4; the writer writes a line only when the tab changed or 30 minutes passed, 77a0e0077c) or designs them: read from the end and stop early only after proving the file is time ordered, and cut the writer. Proves any reader change on a frozen copy at a fixed clock, with interleaved fresh processes, medians and an identical result digest; a 24 h cut changes the answer. Rejects filtering lines to live surfaces before the parse, because the newest line on a closed tab must hide an older line on a live one. A weak answer compacts or deletes history, or stops early without proving time order.", + "files": [] + }, + { + "id": 6, + "prompt": "I moved ten call sites from spawning ps onto the process layer. My benchmark says total cold CPU went from 4,693 ms to 3,446 ms, and a teammate says the before arm cheats. Who is right, and how do I make the comparison fair? Do not edit files.", + "expected_output": "Explains the stand-in arm trap (measuring.md): the old arm ran the spawn alone while the new arm also imported the real module, so the comparison charged the migration for a module load (or the reverse). Fix: both arms start with the site module loaded, and the baseline is recorded again at the old commit the same way. Uses the real benchmark (scripts/benchmarks/process/call-sites-bench.ts --runs 3 --compare) with cold and warm CPU including spawned children, and reports CPU time, not wall time. A weak answer trusts either number without checking what each arm loads.", + "files": [] } ] } diff --git a/.claude/skills/performance/references/caching-and-freshness.md b/.claude/skills/performance/references/caching-and-freshness.md index 96857138b3..ec4cd0b24a 100644 --- a/.claude/skills/performance/references/caching-and-freshness.md +++ b/.claude/skills/performance/references/caching-and-freshness.md @@ -82,6 +82,13 @@ it differs (the old code kept the old file's count for a replaced rollout; fixed in-place rewrite that keeps the inode and grows is still not caught; if that can happen, also keep a few bytes before the offset and compare them. +## 7a. Change events must cover every writer (2026-10-25) + +A model refreshed by change events is only as fresh as its event source. GenesisKit's `GitRepoModel` listened to the +`.git` folder and to saves in the viewer, so edits by other programs never refreshed the status or the file dates: +"Last update" showed a note 5 h old while files changed minutes ago. It now also watches the working tree (`.git` +excluded, throttled to 3 s). Ask of every event-driven model: "which writer does not produce this event?" + ## 8. Rejected - **Skip the daemon's rebuild when no session file changed.** The rows also depend on the pin journal, cmux diff --git a/.claude/skills/performance/references/compression.md b/.claude/skills/performance/references/compression.md index 4f2f80fc7b..a5c09e3a21 100644 --- a/.claude/skills/performance/references/compression.md +++ b/.claude/skills/performance/references/compression.md @@ -20,6 +20,10 @@ Measured 2026-10-13 on this Mac (Apple Silicon, load average 10 to 48 during the - **`FileSink.write` in a loop does not apply backpressure.** Feeding rg's stdin that way held 519 MB RSS for a week of logs. Pass a pull `ReadableStream` as `stdin` instead (the next frame decompresses when rg asks): 133 MB. Frames decompressing ahead: 1 ahead 83 MB, 3 ahead 171 MB on a 1 GB file, no wall gain once rg is the consumer. +- **The frame walker beats Bun's native zstd stream on 1.4.x**: 2.1 to 3.0 times faster for a whole read and 7 to 27 + times for a tail, with byte-identical output (2ff160f251). Measure again on a new Bun before you drop it. +- **Binary and database presets use level 3**: 2.4 and 2.7 points less saving than before, at 2.7 and 2.6 times the + speed (2d1745ce68). - **APFS clones make `du` lie.** Three `cp -c` backup runs showed 2.76 GB in `du` and 1.08 GB unique (`tools du clonesize`); removing one run freed about 1 MB, because a newer run shared its blocks. Pack clone families together, and measure with `clonesize`, not `du`. diff --git a/.claude/skills/performance/references/ledger.md b/.claude/skills/performance/references/ledger.md index 43b952af7c..3b237fc1c1 100644 --- a/.claude/skills/performance/references/ledger.md +++ b/.claude/skills/performance/references/ledger.md @@ -19,8 +19,9 @@ afternoon. - **Commit subjects** carry the headline number: `perf(fs): ... so cold Codex discovery spends 82 ms instead of 143 ms of CPU`. Scopes follow the repository's commit-msg hook vocabulary. - **Baselines** in `scripts/benchmarks/baselines/.json`, committed with the script. -- **Long write-ups** (research, proposals, explanations) go into the Obsidian vault, never `/tmp`: - `/Dev/Bun/` for Bun runtime research, `/Dev/Typescript/` for repository proposals. +- **Long write-ups** (research, proposals, explanations) go into the Obsidian vault, never `/tmp`: a report the user + will read about this repository goes to `/GenesisTools/YYYY-MM-DD-.md` (a dated subfolder for a + set of notes); general Bun runtime research stays under `/Dev/Bun/`. - **Area docs**: `docs/benchmarks-cpu.md` (CPU harness and idle work), `docs/benchmarks-du.md` (`tools du`, with its own rules: append a section per feature), `src/utils/bun/compiler/README.md` (FFI and compiler measurements). - **JSDoc** of a function that exists for speed states the measured reason in one or two sentences, so nobody diff --git a/.claude/skills/performance/references/patterns.md b/.claude/skills/performance/references/patterns.md new file mode 100644 index 0000000000..efe3fee41a --- /dev/null +++ b/.claude/skills/performance/references/patterns.md @@ -0,0 +1,76 @@ +# Patterns that paid off (2026-10-12 to 2026-10-24) + +Each pattern is one measured change in this repository. The commit has the numbers, the ledger +(`.claude/docs/performance.md` in the main checkout) has the method. Look here before you design a fix: most new +slowness is one of these shapes again. + +## Growing files: bound the reader and the writer + +- **Read an append-only file from its end.** The cmux surface journal (6.9 MB, 19,344 lines) was parsed whole by + every reader. `readLinesBackwardSync` reads from the end and stops after 64 lines older than the readers' window + (7 days plus an hour): 9.7 to 4.2 ms, the same 139 sessions (4d0f2cc3c4). Stop early only after you proved the + file is time ordered (0 of 19,344 lines were out of order). Do not compact or delete history to get there: the + compaction was undone because the user wants the journal whole (ledger 2026-10-15 18:10). +- **Write less.** A hook wrote a journal line per prompt: 19,203 lines in a week, and each line cost a cmux identify + call. It now writes only when the tab changed or 30 minutes passed (77a0e0077c). +- A filter that runs before the parse can change the answer. Rejected: dropping journal lines of closed surfaces + before the parse, because the newest line on a closed tab must hide an older line on a live one. + +## Pay a fixed cost once, not once per item + +- **One read snapshot for many keys.** Warm widget cards for 16 sessions ran one `queryEntriesSnapshot` each, and + each re-ran the answer-log catch-up (5.5 ms, 98 of 206 ms). `queryEntriesOfSessionsSnapshot` reads all of them in + one snapshot with one catch-up: 183.2 to 95.7 ms of CPU (8c431c540f, ledger 2026-10-24 18:20). +- **One folder read for many ids.** The stuck check resolved each watched session by its own walk; one read of the + session folders for all 15 ids: 234 to 31 ms of CPU (dbb74251ed). +- Look for the shape: a loop over N items where each call opens, reads or catches up the same thing. + +- **Filter by the cheap property, hash only the matches.** Pairing moved files by content: `git cat-file + --batch-check` gives every deleted blob's size, `lstat` gives every untracked file's size, and only files whose size + matches a deleted blob are hashed (`hash-object --stdin-paths`). 46 ms on the vault's 650 deletions and 5,000 + untracked files, against 3.9 s for git's own rename detection; hashes cached by file identity (GenesisKit + `GitWorktreeRenames`, TS `src/utils/git/worktree-renames.ts`). +- **Two languages, one rule, one fixture.** The Swift and TypeScript twins of a rule read the same case file + (`GenesisKitFixtures/Git/contract-worktree-renames.json`), so the rule cannot drift between the app and the CLI. + +## Ask the cheapest source + +- **Liveness by signal 0.** Whether a Claude session is alive: the `~/.claude/sessions` registry plus + `process.kill(pid, 0)` costs 0.4 ms against 213 ms with `ps` (93fcb07b9e). A `ps`, `lsof` or `pgrep` spawn is a + lint error anyway (`no-process-tool-spawn`). +- **git status single-threaded in a hook.** The diff hook's `git status` costs 220 ms of CPU single-threaded against + 417 ms with its default threads, for the same output (c468a81cd3). Threads that each stat the tree can cost more + CPU than they save in wall time. + +## Schedulers, ticks and daemons + +- **Overlap independent legs.** The session-rows walk runs beside the usage poll, so a tick lasts as long as the + slower of the two, not their sum (69dc2ee44a). Spends that arrive together share one ledger lock instead of + queueing (2ddf62a1e4). +- **A tick reads facts only for what is due.** The hub notify tick reads git facts only for repos whose cadence has + come, and runs the rules only on ticks that polled (e33c1b788e). Each repo has its own backoff and deadline, so a + hung repo never holds back the others (65f11d1fc1). +- **Never do cleanup on the scheduler's path.** A retention prune on a slow disk froze the daemon's scheduler for up + to 82 s; it is now asynchronous and detached (51113cf8f6). Bound walks of an open window: the stats timeline reads + at most its last 400 buckets (f3bbe4efbf). +- **Process priority is a performance setting, so A/B it.** Low priority made the whole daemon slow under load; it + runs at Standard process type, and only the clones scan and cache prune run low (191f7aae16). The clones + reconcile benchmark measures both QoS levels (6018c717d3). + +## Child processes and hooks + +- **An abandoned Bun child needs its pipes and timer unref'd too.** A build waited 20 s for a child it no longer + needed. Unref'ing the child alone: 4 s; also its stdout pipe and the deadline timer: 0.03 s (22089d5c24; details in + the swift skill's gotchas.md). +- **Keep git hooks cheap and bounded.** Bun's `$` shell hung the pre-push hook; the guard now lists files with a + plain spawn, and every pre-push guard has a deadline (ad23abfe41). The post-checkout hook started a whole `tools` + run on every rebase in a linked worktree; it now acts only when `git worktree add` creates one (5e121bd229). + `./tools` never runs `bun install` by itself in a linked worktree (4b8196febd): a fresh install's native binaries + wait for macOS's first-run check. +- **A watcher's backend probe can leak.** `@parcel/watcher` ran a watchman probe that left a zombie `sh` and a + descriptor on every subscribe when watchman was missing; the watcher now names its native backend (d846dd0232). + +## When the new code is slower on purpose + +Accept extra cost when it buys a feature, and say so in the ledger with the number (2026-10-14 23:02: `codex who` ++47 ms for a new column). A "regression" that nobody wrote down is the one that gets reverted. diff --git a/.claude/skills/performance/references/profiling.md b/.claude/skills/performance/references/profiling.md index 84a01c2586..4a204cbcc9 100644 --- a/.claude/skills/performance/references/profiling.md +++ b/.claude/skills/performance/references/profiling.md @@ -140,3 +140,12 @@ Research notes: `/Dev/Bun/2026-10-12-BunCpuProfiling/`. `Button`) recomputed per cell per frame. Fewer hover and focus regions per row means less of it. - "External: Time" as a root cause on a scrolling list is the frame clock, not a stray animation: turning animations off on cell reuse changed neither the count nor the CPU. + +## The native app: hangs and spins (2026-10-23 21:14) + +The TypeScript tools above do not see GenesisTools.app's main thread. For the app, run `tools hub dev monitor` +under the Monitor tool for the whole session (pass `--min-profile-ms 4000`, or every slow background CLI timer +floods it), and read the stack file each `hang` or `spin` line names. A face reports only when its entry point +calls `HangWatch.start()`; on 2026-10-23 the widget, markdown and settings faces did not, so two widget spins and +the Clicky key-tap stalls went unseen. The swift skill's gotchas.md ("Hangs that reached the user on 2026-10-23") +has the details. diff --git a/.claude/skills/performance/references/tests-and-ci.md b/.claude/skills/performance/references/tests-and-ci.md index edd3d9c97f..12887cf003 100644 --- a/.claude/skills/performance/references/tests-and-ci.md +++ b/.claude/skills/performance/references/tests-and-ci.md @@ -91,3 +91,14 @@ docker wait gt-linux-check; cat /out/summary.txt `TEST_WORKER_LOG=1` (set 2026-10-14 19:34) makes every CI run write the worker log and put its report (`bun scripts/test-worker-log.ts`) into the job summary: the report names the file that left the process. - `gh run rerun --failed` gets a second sample without a push, so it costs no paid review. + +## 5. Make a check cheaper without making it weaker + +- **Typecheck only what a change can reach, incrementally.** CI checks the projects a change reaches against the PR + base (or the commit before a push) with that commit's build info; locally against each project's last pass + (c16b113da6). +- **Shorten a timing test by its phases, keep its margin.** The idle-then-busy CPU test waits one second per phase + instead of three, with the same ratio margin: 6.70 to 2.69 s (fcdfb11031). +- **Freeze the clock in a test that prints times**, so a slow second cannot turn "30s" into "31s" (a20257abac). +- **Git hooks stay cheap and bounded.** A hook never starts a whole `tools` run and never uses Bun's `$` shell, and + each pre-push guard has a deadline (ad23abfe41, 5e121bd229). `references/patterns.md` has the details. diff --git a/.claude/skills/performance/references/typecheck.md b/.claude/skills/performance/references/typecheck.md new file mode 100644 index 0000000000..b269aafcd6 --- /dev/null +++ b/.claude/skills/performance/references/typecheck.md @@ -0,0 +1,77 @@ +# Type-check cost: memory and time of a TypeScript program + +A TypeScript program's memory follows its size first and its code shapes second. Measured on the GenesisTools root +program on 2026-10-25 (TypeScript 6.0, 9,995 files, 101.6 MB of source): retained heap after a full GC was parse +1,943 MiB, bind 2,451 MiB, check 4,389 MiB. Parse plus bind costs about 24 MiB of heap per MB of source, `.d.ts` +included. A reviewer that runs `ts.createProgram` with a 3 GiB heap failed on it. + +## The instrument: `tools ts program` + +It checks in a separate `node` process (V8, the heap `tsc` and reviewers use) with the project's own `typescript`. + +- `tools ts program cost [tsconfig]`: heap after each phase, counts (types, instantiations, symbols), diagnostics. + `--heap 3072` passes or fails as out of memory, like a 3 GiB reviewer. +- `tools ts program files --top 30` (`--by instantiations|types|symbols|ms`, `--folders`): what each file's check + created. The first file that touches a library type pays for it: read a ranking, not ownership. +- `tools ts program own `: one file's own work (everything else checked first). THE before and after + instrument for an edit. +- `tools ts program closure` (`--why `): what a tsconfig of one folder would hold; declaration packages by + size with their direct importers; packages present from more than one installed copy (from an audit of a large + private app on 2026-07-10, where duplicate copies were typechecked 2 to 6 times). +- `tools ts program trace` (`--min-ms`): `tsc --generateTrace`, then the slowest files and single expressions with + their source line. +- `tools ts program sites` (`--by median --min-sites 3`, `--project-only`, `--key `): THE "next worse" finder, + no agents and no reading. Each call, `new`, JSX element and type reference is resolved on its own during the first + check and grouped by what it calls (`name @ declaring file`). A high median over many sites is a shape that costs + at every use; one big site with a low median is first use of a library type. Positive control (2026-10-25): on the + commit before the fixes below it ranked the ElementType icons 2nd and 6th to 11th, `new Chart` 12th, + `generateText` 15th and `Parameters` 34th, with nothing pointed at them. + +## The loop + +1. `cost` for the baseline, `sites` (then `--by median --min-sites 3`) and `trace --min-ms 50` for candidates. +2. For each candidate: `own `, change the TYPE only, `own ` again. Keep the change only when the numbers + drop and diagnostics stay 0. +3. Prove the JavaScript did not change: transpile old and new with `new Bun.Transpiler({ loader: "tsx" })`, strip + comments, compare. A planted change must read DIFFERENT (positive control). +4. `cost` again for the program-wide number, and an interleaved A/B (a HEAD worktree against the change, 3 rounds, + medians) for time. A single file's `own` number overstates program savings when the cost was shared first-touch + work: on 2026-10-25 a per-file sum claimed 43k fewer instantiations and the program showed 10.8k. + +## Shapes that cost a lot, with numbers + +- **Inference over a big union parameter.** `new Chart(canvas, config)` (chart.js) inferred three type parameters + over a union of every chart type: 1,084 ms and 250k symbols for ONE expression. `new Chart(…)`: 12 ms. + A typed variable and an `Object.assign` instead of the spread did not help; the explicit type argument did. +- **A prop type that is secretly a union of every HTML tag.** `React.ElementType

` (icons, `LinkComponent`) made + each JSX use check its props against every intrinsic element: about 175k instantiations and 220 to 250 ms per file. + `React.ComponentType

`: 4 to 38 ms. ElementType stays right only for a polymorphic `as` prop. +- **Spreading a wide type into an object literal.** `{ ...bandLayer }` with `bandLayer: CSSProperties` copies every + CSS property into a new type: types 4,996 → 2,403 with `Pick`. +- **A type derived from a library function's parameters.** `Parameters[0]["model"]` instantiates + the whole generic signature; the library exports the same type as `LanguageModel`. +- **A generic inferred from a zod schema parameter.** `fn(schema: z.ZodType)` makes the checker infer `T` by + walking the whole schema type against ZodType at every call: about 3,400 instantiations per `callLLMStructured` + call and 81k for the first `parseDecisionInput` of a large schema. Make the schema the type parameter: + `fn(schema: S): z.output`. Both together: program instantiations 2,380,257 → 2,333,331 + (−2.0%), 0 diagnostics, identical JavaScript (2026-10-25). Found by `sites --by median --min-sites 3`. +- **A library's typed routes or query builder, paid once per program.** Any typed `octokit.request("GET …")` costs + about 44k types and 160k instantiations whatever the call shape; kysely chains were 17 to 21% of all instantiations + with no type-only fix found. Measure, then leave them unless the program can drop the library. +- **A large `.d.ts` package used by one file.** Stripe: 228 files, 5.9 MB, one importer; moved to the package store + (installed on first use) with a local type for the one call: −228 files from every program. +- **Test files.** 1,983 bun:test files held 35% of all symbols and 27% of check time (2026-10-25). Not a code fix; a + reason a smaller program (one folder, or only a change's files) is the real lever. + +## Correctness traps of a smaller program + +A program of one folder, or of only a change's files, needs 0.3 to 1.5 GB instead of 4 GiB, and it misses what the big +program got by accident: + +- **TypeScript 6 adds no `@types` package to the global scope unless `types` names it.** An import of a package still + loads that package's types; only the automatic global inclusion stopped. Bun's globals came only from the one file + that imports `bun`, so a smaller program said "Cannot find name 'Bun'" (141 errors in one sample). Name it: + `"types": ["bun"]`. +- **Ambient `.d.ts` files that nothing imports** (`declare module "asciichart"`, a `marked` augmentation) exist only in + a program whose include matches them. Include `src/**/*.d.ts` (or the folder's `../**/*.d.ts`). +- **`declare global` inside a module file** is seen only when that file is in the program. diff --git a/.claude/skills/swift/SKILL.md b/.claude/skills/swift/SKILL.md index d0f6c04c1f..1b10a71cc2 100644 --- a/.claude/skills/swift/SKILL.md +++ b/.claude/skills/swift/SKILL.md @@ -1,6 +1,6 @@ --- name: swift -description: Swift, SwiftUI and AppKit work on macOS apps, above all performance — and the data layer behind them. Use it whenever you edit or debug a SwiftUI/AppKit app (here GenesisTools.app, its hub, review window, widget preview and the shared GenesisKit package under src/macos/), chase a hang, stall, "not responding", high CPU, high memory, frame drops, a slow click or a layout loop, add a SwiftUI animation, a WKWebView, a List or a custom Layout, or need to measure, bench, sample or A/B a change — even when the user only says "the hub is slow", "it jumps", "it uses 40% CPU" or "click around the app". Also use it when the slow part is the CLI or server process that feeds the app data. +description: Swift, SwiftUI and AppKit work on macOS apps, above all performance — and the data layer behind them. Use it whenever you edit or debug a SwiftUI/AppKit app (here GenesisTools.app, its hub, review window, widget preview and the shared GenesisKit package under src/macos/), chase a hang, stall, "not responding", high CPU, high memory, frame drops, a slow click or a layout loop, add a SwiftUI animation, a WKWebView, a List or a custom Layout, or need to measure, bench, sample or A/B a change, or review or audit Swift code for cost, redraws, main-thread work, concurrency mistakes or state that is wrong or late — even when the user only says "the hub is slow", "it jumps", "it uses 40% CPU", "review this view", "is this SwiftUI right" or "click around the app". Also use it when the slow part is the CLI or server process that feeds the app data. --- # Swift app performance @@ -14,6 +14,19 @@ multiplies every update), or work the data layer repeats because nothing remembe So the loop is always: **see it in a measurement, find the frame or the call that owns it, change the shape of the code, measure again with the same instrument, and prove the output did not change.** +## Reviewing or auditing code + +Asked to review or audit Swift code, or to list what you would change: follow +[references/audit.md](references/audit.md). Audit at most about 10,000 to 15,000 lines per pass and split larger +areas: whole 39,000- and 112,000-line areas found under 40% of the known defects, and the same code in smaller +surfaces found 90%. The guide starts with the facts of this codebase that decide which advice +applies (`ObservableObject`, Swift 5 mode, a process per CLI call), then walks seven passes with search recipes: the +main thread, work that runs too often, redraws wider than the change, work inside a body, animation and drawing, +AppKit bridging, and concurrency. Its first step is `bun .claude/skills/swift/scripts/swift-scan.ts `, +which prints the candidates of every pass at once. It ends with a report shape that separates findings from shapes that need a +measurement. [references/sources.md](references/sources.md) lists where its rules came from, the popular advice +that is wrong for this app, and the rules that wait for `@Observable`, Swift 6 or macOS 26. + ## The loop 1. **Watch continuously.** Stream hangs, stalls, layout loops, frame drops, crashes and slow CLI diff --git a/.claude/skills/swift/references/audit.md b/.claude/skills/swift/references/audit.md new file mode 100644 index 0000000000..9db9dc365f --- /dev/null +++ b/.claude/skills/swift/references/audit.md @@ -0,0 +1,163 @@ +# Auditing Swift code for cost and wrong state + +Use this when you review or audit SwiftUI and AppKit code for performance and correctness, or before you call your +own change done. A code audit finds the shape; a measurement ([measuring.md](measuring.md)) decides whether the user +notices. Rules come from the skills distilled on 2026-10-24 ([sources.md](sources.md)) and from this repo's +measurements ([performance.md](performance.md), [gotchas.md](gotchas.md)). + +## 0. Facts about this codebase that decide which advice applies + +- State is `ObservableObject` with `@Published` (114 types, 667 properties, 0 `@Observable`, 2026-10-24). Every + view that observes an object runs its body on ANY publish of that object, so the width of an object is the width + of its redraws. Advice written for `@Observable` (per-property tracking) does not apply until a type moves. +- The packages build in Swift 5 language mode for macOS 14. Swift 6 and macOS 26 or 27 advice is in the later list + of [sources.md](sources.md). +- Data comes from the `tools` CLI. Each call is a process (about 350 to 420 ms on this Mac) unless a resident hub + server door answers it. +- `DispatchQueue.main.async` is the local pattern (225 sites); it is not a defect by itself. +- Measured facts beat skill claims: a removed `AnyView` root changed nothing here, and `TimelineView` with `Canvas` is + an idle-CPU cost here. [sources.md](sources.md) lists the advice to reject. + +A finding names how often the cost happens (per key, per publish, per snapshot, per second, once at open) and what +the user notices. A shape with no frequency is a candidate for a measurement, not a finding. + +## Size the surface first: about 10,000 lines per audit + +One audit pass reaches only the files it opens before its report is full, so a large area is sampled, not audited. +Measured on 2026-10-24 with the same skill and model against 36 verified defects: whole areas of 39,000 and 112,000 +lines found 27 to 40%; the same code split into surfaces of 6,000 to 21,000 lines found 19 of 21, and a 13,000-line +area found 13 of 15. Count first (`cat $(fd -e swift . ) | wc -l`); split anything over about 15,000 lines by +feature folder or by file group (window shell, lists, models), and audit each surface on its own, in parallel when you +can. Merge the reports and drop duplicates at the end. + +## Start with the scanner + +```bash +bun .claude/skills/swift/scripts/swift-scan.ts +``` + +It runs the search recipes of the seven passes below in one go. Narrow checks print each line; broad checks +(task restarts, derived lists, rows that observe a model, identity, observers) print the files ranked by hit count, +so you know which files to read first. A line is a candidate, not a finding: for each one, follow the callers to +learn which thread runs it and how often. Walk every pass of the output. A file that tops several broad checks +(a big view file, a model with many `@Published` properties) is where the costly defects cluster: read it whole. + +## 1. The main thread + +- **Never read files, stat paths, encode JSON, run processes, decode images or query a database on the main thread** + from a body, a click handler, a timer, a menu validator or a `@MainActor` model. Find: + `rg -n 'String\(contentsOf|Data\(contentsOf|NSImage\(contentsOf|contentsOfDirectory|enumerator\(at|fileExists\(|attributesOfItem|Process\(\)|waitUntilExit|Thread\.sleep|JSONEncoder\(\)\.encode|CIContext\(\)|FileLock\.withLock' `. + A hit is a defect only when its function runs on the main actor on a click, a tick or a publish; read the caller. + `validateMenuItem` runs on every menu open. +- **Apply an incoming batch with one main-actor hop and one write** to published state, never one write per item: + build the new array locally, then assign once. Find `for … in … {` with `self..append(` or an indexed + write inside it. +- **Set time-sensitive UI state in the synchronous action**, then start a `Task` for the long work. +- **A hang can be waiting, not computing**: a lock, synchronous I/O, an actor or a dependency cycle. Read the main + thread's CPU time before blaming CPU. + +## 2. Work that runs too often + +- **Coalesce file events and task restarts, and skip the work when nobody consumes the result.** Every + `FSEventStreamCreate`, `DirectoryWatcher(` and `.task(id:)` gets three questions: does it ignore paths nobody + reads (build output, caches), does it debounce, and does anyone consume the result right now (a hidden window, a + file no reader watches)? Find: `rg -n 'FSEventStreamCreate|DirectoryWatcher\(|\.task\(id:' `. +- **A loop that probes or polls** (`Task.sleep(for: .seconds(` inside a `.task`) runs for every window that exists, + also behind other windows. Probe on appear and on the action that needs it ([performance.md](performance.md), + "Polling watchers"). +- **A process per call** ([SKILL.md](../SKILL.md) table): list every `ToolsBridge` call site and its trigger. A + prewarm that starts a process for a view the user may never open costs a process each time; a lookup per item + where many items share one answer needs a dedupe first. +- **Build a change signature from cheap fields** (flags, counts, `isEmpty`), never from a large text. +- **Return the input unchanged when a patch or overlay is empty**, before any encode and decode round trip. + +## 3. Redraws wider than the change + +- **A fast-changing value lives in its own small `ObservableObject`**, read only by the view that shows it: an audio + level, a partial transcript, a draft text, a progress value. On a shared model it redraws every pane that observes + the model, many times a second. Find: + `rg -n '@Published .*(level|rms|partial|micLevel|voiceText|progress|draft)' `, then count the views that + observe the owner. The fix is a small meter object that only the bars read. +- **A row gets the element it shows**, never the whole store plus an id or index it must search, and never an + `@ObservedObject` it only calls actions on. Find: `rg -n 'first \{ \$0\.(key|id) ==|\.filter \{ \$0\.' ` in + view files, and `@ObservedObject var` in row structs ([performance.md](performance.md), "Rows take values"). +- **A list view observes only what it reads.** A list that observes a large model re-runs its filter, group and sort + for every unrelated publish. Hand it the values, or split the model. +- **Own a model with `@StateObject`**; never `@ObservedObject var x = X()` with an inline initializer (it is recreated + with the parent). +- **Keep fast-changing values and closures out of environment keys**: every reader checks the whole + `EnvironmentValues`. + +## 4. Work inside a body + +- **Build formatters, regexes, decoders and parsed markdown once** (a `static let` or the model), never in a body, + in a computed property the body reads, or in a row. Find: + `rg -n '(DateFormatter|NumberFormatter|ISO8601DateFormatter|RelativeDateTimeFormatter|ByteCountFormatter|JSONDecoder|NSRegularExpression)\(\)|\.regularExpression|AttributedString\(markdown:' `, + then follow every computed property and function the body calls, not only `some View` helpers (a formatter in + `private var items: [Item]` that the body reads is in the body). One create-and-format costs about 135 µs (Axiom, + measured), so 100 rows cost about 14 ms per update. +- **Derived lists (filter, sort, group, join) are computed once per change of their inputs**, in the model. Find + computed arrays and strings read from a body: `rg -n 'private var \w+: \[' `, `.joined(separator:` and + `localizedCaseInsensitiveContains` inside a body. A filter over a thousand items per keystroke is a hitch. +- **`View.init` and `@State` defaults copy inputs in constant time**; loads start in `.task` or in a model. +- **Each `ForEach` element makes a constant number of views**: no `if` filter or `AnyView` inside the closure; filter + in the model. Give `ForEach` a stable id from the model: never indices, offsets, `UUID()` or `id: \.self` over + values that change or repeat. +- **Vary a modifier's value inside one view**; an `if`/`else` on the root or a conditional-modifier helper resets + identity and state. Change `.id` only to reset on purpose, keyed to one value; never `.id(UUID())` or `Date()`. +- **A lazy row is recycled**: keep state that must survive in the model; `onAppear` is not a one-time hook. +- **Bind through projected state or key paths**; no `Binding(get:set:)` built in the body of a repeated or hot view. +- **Read geometry once at a stable container** with `onGeometryChange`; no `GeometryReader` or geometry preference + in each row. +- **Add `.equatable()` only where comparing the inputs costs less than the body**, and measure it. + +## 5. Animation and drawing + +- **Attach an animation to the smallest view that changes, always with `value:`**, and animate opacity and + transform, not layout, blur or shadow. A `withAnimation` opens a transaction for every view that re-renders + ([performance.md](performance.md), "A SwiftUI animation costs the whole graph it lives in"). +- **No `withAnimation` around a first load or a bulk change** of a list ([gotchas.md](gotchas.md)). Where a list + helper offers a guard (`SWR.animates(before:after:limit:)` in the hub), use it. +- **A continuous animation belongs to the render server**: a layer animation in an `NSViewRepresentable`, never a + SwiftUI `repeatForever` in a big hosting view, and never an audio-rate SwiftUI animation (feed a layer at 15 to + 20 Hz). In a Core Animation wrapper, create layers once and start an animation only when its target changed. +- **Put a `TimelineView` in the leaf that needs the ticker**, with a schedule that matches the visible precision. +- **In a custom `Layout`, measure children once** (`makeCache`) and reuse the sizes in `placeSubviews`. + +## 6. AppKit bridging and windows + +- **In `updateNSView`, compare before writing** to the AppKit view (`rootView`, `stringValue`, `image`), refresh the + coordinator's `parent`, and never set `frame` or `bounds`. Find: `rg -n 'func updateNSView\('`, then read each for + unconditional sets. An unconditional `rootView` assignment rebuilds a nested hosting view on every update. +- **Every observer, timer, event monitor and sink a representable creates is removed** in `dismantleNSView` or the + coordinator's `deinit`. +- **Give each `NSViewRepresentable` a sizing story** (`sizeThatFits` or `intrinsicContentSize`) that handles a nil or + zero proposal. +- **Configure a floating panel explicitly**: non-activating style, `canBecomeKey`, `hidesOnDeactivate`, collection + behaviour, and the level last. +- Liquid Glass: neighbouring `glassEffect` views share one `GlassEffectContainer`; glass is for chrome, applied after + layout modifiers, never stacked with a material. SwiftUI does not hit-test glass, so a glass panel needs + `.contentShape` ([gotchas.md](gotchas.md)). + +## 7. Concurrency and state that is wrong or late + +- **In a sink on a `@Published` publisher, use the emitted value** or `.receive(on: RunLoop.main)`: the publisher + fires in `willSet`, so the property still holds the old value. Find: `rg -n '\$\w+\s*\.sink|\.sink \{'`, then check whether the closure reads the owner's property. +- **Cancellation is a normal end**: never show `CancellationError` as a failure; check `Task.isCancelled` in long + loops. Store the running `Task` so an expensive load is single-flight, and re-check state after every `await`. +- **No unowned `Task {}` in a loop**, and no thrown error that vanishes inside `Task { try … }`. +- **State why each `Task.detached` detaches**; otherwise a plain `Task` keeps priority and cancellation. +- **Never block a cooperative thread** with a semaphore or a group wait inside async code. Resume a checked + continuation exactly once on every path; give an `AsyncStream` an explicit buffering policy. +- **Write the invariant beside each `@unchecked Sendable`, `nonisolated(unsafe)` or `@preconcurrency`.** In closures + SwiftUI may run off the main thread (`visualEffect`, `Shape.path`, `Layout`, `onGeometryChange`), capture copied + values, not `self` or a model. +- Read the package's language mode and strict-concurrency level before you judge a concurrency finding. + +## 8. Report + +One block per finding: where (path:line), what the user notices and how often, the rule (this file's section or the +reference it points to), the code quoted, the fix in one sentence. Order by cost: stalls and hangs, idle CPU and +wrong state first; hitches in frequent actions next; rare or small costs last. End with **Coverage**: one line per +section above with the searches run, their hit counts, and the findings or "none". Put a shape whose cost depends on +numbers you do not have under **Needs a measurement**, with the instrument and the result that would confirm it. diff --git a/.claude/skills/swift/references/build.md b/.claude/skills/swift/references/build.md index 12cda32bf4..13ad8c8873 100644 --- a/.claude/skills/swift/references/build.md +++ b/.claude/skills/swift/references/build.md @@ -6,12 +6,12 @@ | Compile the app as installed | `cd src/macos/GenesisTools && swift build -c debug -Xswiftc -O --scratch-path .build/opt` | | Install (build, sign, swap, reap) | `bun run app`; `bun run app:status` names the installed commit | | Tests | `swift test` in GenesisTools and in GenesisKit | -| Off-screen picture | `GenesisTools --hub … --snapshot /tmp/cc/<…>/x.png` (see the app CLAUDE.md) | +| Off-screen picture | `GenesisTools --hub … --snapshot /tmp/cc/<…>/x.png` (`.claude/rules/macos-app.md`, "Verify without a screen") | | Bench | `GenesisTools --hub --bench …` (measuring.md §4) | | Widget preview | `bun scripts/build-widget-preview.ts` in its worktree, then restart the app | After installing, check what runs: `ps -Ao pid,lstart,command | rg "MacOS/GenesisTools"`. An old face -keeps answering with old code (the app CLAUDE.md explains the reap). Then let the monitor run through +keeps answering with old code (`.claude/rules/macos.md` explains the reap). Then let the monitor run through the scenario you changed and read every event it reports. Background screenshots never take focus: `tools control screenshot --app --window "" --path …`. diff --git a/.claude/skills/swift/references/genesistools.md b/.claude/skills/swift/references/genesistools.md index 330fe57b3b..4e752d0540 100644 --- a/.claude/skills/swift/references/genesistools.md +++ b/.claude/skills/swift/references/genesistools.md @@ -5,7 +5,9 @@ GenesisTools checkout. ## Where things live -- App: `src/macos/GenesisTools/Sources/` (Hub/, Review/, App/); tests `swift test` there. +- App: `src/macos/GenesisTools/Sources/`: `Host/` (the app host: launcher, permissions, approvals, notifications) and + `Parts/` (optional parts: `Hub/`, `Widget/`, `Clicky/`, `Markdown/`, `Features/`); `Hub/Stolen/` holds the copies + shared with Genesis. Tests: `swift test` there. - Shared Swift: `src/macos/GenesisKit/Sources/GenesisKit/` (Sessions/, Controls/, Perf/, Window/); tests `swift test` there. A generic component belongs here, never as an app-local copy. - Rules for the app: `.claude/rules/macos-app.md` (never block the main thread in a body, measure diff --git a/.claude/skills/swift/references/gotchas.md b/.claude/skills/swift/references/gotchas.md index 0e8d1e0fa0..aa613acda3 100644 --- a/.claude/skills/swift/references/gotchas.md +++ b/.claude/skills/swift/references/gotchas.md @@ -28,12 +28,12 @@ animated properties; use a layer animation for a measured hot path (see performance.md). - **`withAnimation` around a first load** animates every row; skip it when nothing was on screen. - **`Process.waitUntilExit()` inside a body** spins the run loop and can double-free StackLayout - (crash). Load in a store on a background queue (CLAUDE.md of the app). + (crash). Load in a store on a background queue (`.claude/rules/macos-app.md`, "Never block the main thread"). - **A layer loses its animations when its view leaves the window.** Re-add them in `viewDidMoveToWindow`. - **CA coordinate space:** an `NSView` layer has y up. `addArc(clockwise: true)` from angle 0 and a rotation to `-2π` look clockwise on screen, matching SwiftUI's `Circle().trim` + positive degrees. - **A SwiftUI `List` viewport moved inside a row insert's resize** stops AppKit re-measuring rows (see - the app CLAUDE.md, `TranscriptScrollAnchor.remeasureVisibleRows`). + `.claude/rules/macos-app.md` and `TranscriptScrollAnchor.remeasureVisibleRows`). ## Expanding surfaces and input @@ -100,8 +100,8 @@ ## Process and repo -- **Another session's worktree is not yours.** `.claude/worktrees/feat-agents-comms` (widget, Clicky) - belongs to a Codex session; edit there only when Martin says so, commit only your paths. +- **Another session's worktree is not yours.** A worktree another session works in (in 2026-10 a Codex + session had the widget and Clicky one) is edited only when Martin says so; commit only your paths. - **placeholder-check blocks a push** on internal repo names in fixtures; replace them in a new commit (no history rewrite). - **Wait for a free Swift slot by process name, never by command line.** `until ! pgrep -f 'swift-(build|test)|xctest'` @@ -390,3 +390,173 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not - **Driving a background app's context menu with `tools control` is not safe yet.** `AXShowMenu` on a SwiftUI tab blocked 2 s and opened the parent's menu first; the menu rows were not in `see --scope menu`; a coordinate click into the open menu closed it without choosing. Trigger the code path from a test instead. + +## Row-only controls with popovers, key modifiers, shared builds — 2026-10-23 15:17 + +- **A control that exists only on the hovered row must keep its popover open after the pointer leaves.** The + popover's `isPresented` lives in the control; remove the control on hover-out and SwiftUI closes the popover. + Keep the `.popover` modifier on a same-size `Color.clear` placeholder while the row is not live, and treat "popover + open" as live (`WidgetCmuxButton(live:)`, GenesisKit Widgets). Hover is the row's, not the control's: a hover + sensor on the button covers only the button (`WidgetHoverRow { hovering in … }`). +- **A popover from an edge panel needs its own "dialog open" flag.** Setting the host's shared `dialogOpen` from the + popover's `onChange` can release a window the host opened when the popover closes. One flag per presenter, ORed + into the getter (`sessionChooserOpen`, `cmuxPopoverOpen`). +- **`.onKeyPress(.downArrow) { … }` has no key press to read.** Its closure takes no argument; to read Shift use + `.onKeyPress(.downArrow, phases: [.down, .repeat]) { press in press.modifiers.contains(.shift) }`. A click's + modifiers come from `NSEvent.modifierFlags` inside the button action. +- **"input file … was modified during the build" is another session editing GenesisKit**, not your code. Rerun the + build through `.claude/skills/babysit/scripts/swift-slot.ts`; do not stash or touch their file. + +## A whole window ignores clicks after one exception in an action — 2026-10-23 16:58 + +- **`NSEvent.clickCount` raises for any event that is not a mouse click.** It is an Objective-C exception + (`NSInternalInconsistencyException`), not a Swift error, and `NSApp.currentEvent` inside a SwiftUI button action is + not always a mouse event: a key press, an accessibility press or a system-defined event can be current. The raise + unwound through SwiftUI's event dispatch and left the Genesis Markdown window ignoring every click, tab bar and + sidebar included, until relaunch (2026-10-23 16:36, the tab group chip's double-click check). Read the count through + GenesisKit `MouseClick.current` or `MouseClick.count(of:)`, which checks the event type first. lint-rules + `swift-current-event-click-count` fails `currentEvent?.clickCount`. Inside `mouseDown(with:)` or a monitor + filtered to mouse types, `event.clickCount` is safe. +- **When "nothing is clickable", read the app's unified log for an exception first.** `command log show --predicate + 'processID == <pid>' --last 10m | rg -i "exception|raise|terminat"` found the line in one query; hit tests, AX + walks and layout theories had found nothing. +- **A test window's synthesized click always makes a mouse event current.** `NSApp.postEvent` down and up gives the + action a real `leftMouseUp`, so a test can never reproduce a crash that needs a key or AX event as the current + one. A unit test of the reader with a key event (`NSEvent.keyEvent(...)`) covers it; a passing click test does not. +- **An AX hit test over a `WKWebView` names the web area, not the view on top.** `tools control hittest` at a tab + that sat above the page reported `AXWebArea` (the web document's rect), while real clicks reached the tab. Judge + delivery by the app's own action log, never by the AX hit test. +- **A hidden test window has no accessibility tree, so find click targets with `FrameProbe`.** GenesisKit + `.frameProbe("name")` records a view's window frame only while `FrameProbe.enabled` (a test sets it); click the + centre of `FrameProbe.frames["name"]`. Never scan a grid of clicks to find a target: the scan pressed the toolbar's + Obsidian and Editor buttons and opened dialogs and editor tabs in the user's real apps. + +## Trigger animations, and loops no stall detector sees — 2026-10-23 17:47 + +- **An animation started from `onChange(of: trigger)` never plays in a view built after the change.** The widget's + hover list was opened by the same alert that bumped the flash counter, so its rows started with the new value and + did not flash. Pass the start time too and play the rest on appear (`AttentionPulse(since:)`). +- **A SwiftUI update loop never delays a main-queue ping.** Each pass returns to the run loop, so a queue-latency + watchdog reports nothing while the app burns a whole core (the widget, 2026-10-23 17:10, three minutes, no + log line). Read the main thread's CPU time instead: `thread_info(THREAD_BASIC_INFO)` on the port from + `mach_thread_self()` taken on the main thread (`HangWatch.SpinTracker`, `main-spin` lines). +- **Read a spin's sample for who schedules the next pass.** Inside the transaction, look for + `asyncTransaction` callers: `LazyLayoutViewCache.signalPrefetch` (a lazy stack that never settles) and + `EventBindingManager.enqueueHoverUpdateIfNeeded` (hover re-checked after every update) were the two in the widget's. +- **A test process has no `NSApp` until something makes one.** `NSApp.postEvent` or `nextEvent` then crashes + with "unexpectedly found nil" on the line that reads `NSApp`; call `_ = NSApplication.shared` first. +- **A window resize lays SwiftUI out synchronously, and content can call back into its controller from there.** The + edge panel moved its window in `begin`, the hover list measured itself in that layout and asked for its real size, + and the nested request started a reshape that `begin` then overwrote with its own transition: the panel kept the + estimate (336 instead of 489 pt). Defer a request made while a transition is being set up, and run the latest one + after it (`EdgePanelController.applyingPresentation`). +- **A `CADisplayLink` keeps a strong reference to its target.** A ticker held only by a struct that gets overwritten + never deinitialises and fires every frame for the life of the process. Stop it wherever the owner is replaced (a + `didSet` on the property), and count live ones for a test (`EdgeFrameTicker.live`). Find them in a sample of the + idle app: `EdgeFrameTicker.step` under `CA::Display::DisplayLink` with nothing animating. + +## Hangs that reached the user on 2026-10-23, and why they got through — 2026-10-23 21:13 + +- **A face that does not start `HangWatch` reports nothing.** Only the hub, the review window and the link relay + called `HangWatch.start()`; the widget, the markdown viewer and the settings window (which hosts Clicky) never did. + So the spin detector added at 17:57 could not see the widget spin at 20:14, and the settings window's key-sound + stalls were invisible. Every face's entry point starts it now (94cf0dd8f4). Check a new face: `rg -n + 'HangWatch.start' src/macos` must list it. +- **A guess is not a fix.** The 17:11 widget hang was blamed on leaked display links at 55% and called fixed. The + 20:14 sample had the same signature as the 17:11 one (`NSRunLoop.flushObservers → NSHostingView.beginTransaction → + GraphHost.runTransaction`, `LazySubviewPlacements.placeSubviews`, no display link). Compare a new sample's + signature with the old one's before you call a hang fixed, and keep every sample in scratch. +- **A layout loop with no body updates.** In the widget's inbox list the main thread re-ran layout on every + run-loop pass while no view body was evaluated (RenderProbe counted only a clock tick). A replay of the real + snapshot in a hidden window (`WidgetScrollReplayTests`, env-gated) reproduced it at some scroll offsets (700 to + 2000 passes per 0.5 s) and not at others, and with a plain `VStack` too, so neither the lazy stack nor the + overflow fade nor the fit measurement alone is the cause. The root cause is still open; what ships is the + breaker: `HangWatch.onSpin` closes the widget panel after a 5 s spin and says why. +- **Do not measure the whole scroll content to learn two edges.** A background `GeometryReader` or + `onGeometryChange` on a scroll view's content lays out every row of a lazy list on each pass. On macOS 15 and + later read `onScrollGeometryChange` (`visibleRect`, `contentSize`) instead (`ScrollOverflowHints`, + `DocumentHeight` in LiveWidgetView). +- **Never start audio hardware on the main thread, least of all inside an event tap.** Clicky stopped its + `AVAudioEngine` after 3 s idle and started it again inside the keyboard tap's callback: 360 ms to 1 s per start + while the output device was slow, every typing pause, and a tap that waits delays every key in every app. Start it + on its own queue and play the first click when it runs; keep the synchronous start only for offline rendering in + tests (`isInManualRenderingMode`). +- **A posted click on a hidden test window never reaches a SwiftUI gesture.** The window is not key, so AppKit + treats the click as the first mouse and drops it before SwiftUI sees it: a plain `Button` and an `.onTapGesture` both + stayed silent, while a click on a `TextField` worked (it is an AppKit view, hit-tested directly), which made the + harness look fine. Making the test window key would take the user's keyboard. Host the view in an `NSHostingView` + subclass whose `acceptsFirstMouse` returns true (`FirstClickHost` in WidgetInboxTasksPolishTests); that is how a + key panel delivers it. Prove the harness with a control (a `Button` that must fire) before you call a dead click + a bug. +- **Moving a start off the main thread is not enough when the main thread then asks the same object.** + `AVAudioEngine.isRunning` takes the engine lock that `start()` holds on the other queue, so the first fix still + blocked the key tap for the whole start (21:14). Keep the state yourself (`running`, `starting`). The same holds + for `NSSound.play()`: it waits for the output device, 0.8 s in the widget's alert; play it from a queue. +- **The first read of `AVAudioEngine.mainMixerNode` creates the output unit and waits for CoreAudio too.** Clicky + wired its voices to the mixer in `init`, which cost 0.5 s on the main thread every time the settings face launched + (21:30). Create the nodes in `init`, attach and connect them on the start queue just before the first `start()`. + +## A button that works once, and pictures that come out white — 2026-10-23 22:44 + +- **A SwiftUI button whose label has no hit-testable pixel ignores the click, with no error.** The widget's rail + bubble put its count under `.allowsHitTesting(false)`, and its only background was a fill whose opacity was 0.07 + while the attention ring showed and 0 after it. SwiftUI does not hit-test a view at opacity 0 (nor `Color.clear`), + so the first click worked (the ring showed) and every click after it fell through, hover too. AppKit still + delivered the click to the hosting view (`mouseDown` logged, `tools control hittest` named the button), which made + it look like an event problem. Fix: `.contentShape(Circle())` (or the control's shape) on any control whose label + can become fully transparent. Test with a click at the control's edge, off its glyph (`WidgetBubbleHitTests`). +- **`cacheDisplay(in:to:)` and `CALayer.render(in:)` leave out SwiftUI materials and layer-drawn backgrounds.** A + dark panel came out with its white text on white, and filling the bitmap dark first did not help. Use the window + server's picture instead: `screencapture -x -o -l <windowNumber>` of the test window. The window must not be + occluded (below the desktop it is never drawn), so show it at alpha 0.02 above the others with + `ignoresMouseEvents`, then drop the alpha channel of the capture: the colours are stored unassociated, so the + opaque copy is the panel as it looks on screen (`WidgetScrollReplayTests`, `GENESIS_WIDGET_REPLAY_PNG`). +- **A reused `NSHostingView` with `sizingOptions = []` measures 0x0 after a new root view once it has been on screen.** + The recording picker set `hosting.rootView`, called `layoutSubtreeIfNeeded()` and sized its panel from + `fittingSize`: right the first time, 0x1 on every later opening, and `onGeometryChange` never fired to repair it + because the content's size had not changed. Make a fresh hosting view per presentation, or size from the view. +- **`tools control click` (an accessibility press) on a SwiftUI control sometimes does nothing on the first try.** In the + live widget, the video button, Decisions tab and Stop and discard each needed a second press now and then. Check the + effect after each press (a window appears, a value changes) and press again; never read one silent press as a broken + control, and never as a working one. +- **A window per item stalls the main thread.** The recording outline made one borderless `NSPanel` per listed window + up front (76 windows) so their ids could be excluded from the movie: 1.6 s of main thread at the recording start + (hang stack: `RecordingTargetOutline.init`). One clear panel per display that draws every rect on it costs two or + three windows. Read the `main-stall` stack after the first live run of any code that creates windows. +- **Under Bun, an abandoned child keeps the process alive three ways.** A cancelled caller of a detached build exited + only when the build ended (20 s) until it unref'd the child, its stdout pipe and the build's deadline timer; the + child alone was not enough (probe: 4 s with the child unref'd, 0.03 s with the pipe too). +- **SwiftUI does not hit-test Liquid Glass.** A view whose only background is `.glassEffect(in: shape)` takes taps and + hover only on its opaque content: between two rows, or on the blank part of a card, a tap reaches nothing and + `.onHover` reports false (scratch probe, 2026-10-24: row TAP, gap none, blank none; solid fill TAP everywhere). The + window server still owns those points (`SLSFindWindowAndOwner` names the window), so the window is not + click-through, and a hit test through AppKit looks fine. Add `.contentShape(shape)` after the glass. A window with a + clear background does pass alpha-0 pixels through; `ignoresMouseEvents = false`, set explicitly, makes it take them. + Synthetic `mouseMoved` events do not drive SwiftUI hover, so test the hit region with a click (`WidgetBubbleHitTests`). +- **Correction (2026-10-24 19:43): the hit-test gap above is real, but it was not why the widget's hover list closed.** Inside a + ScrollView a gap between rows is hit-tested under plain glass, so the list's gaps were never the hole. SwiftUI's + `.onHover` on the panel still reported "left" while the pointer sat on it during a scroll. A window-level "pointer + inside" now comes from an `NSTrackingArea` on the window's root view (`.mouseEnteredAndExited`, `.activeAlways`, + `.inVisibleRect`); SwiftUI hover only logs disagreements. +- **One process per item, on every refresh, is a storm.** A model per open document that spawns its own child on each + repository change multiplies: Genesis Markdown ran 51 to 66 `tools agents blame` at once (each a full app launch), + plus one `git rev-parse` per note. Queue such work per shared resource (one run for every item that asked, one run + in flight, the next after it) and cache what is per folder. GenesisKit's launchers count starts (`SpawnMeter`), so + the next one shows up as `spawn.storm` in the perf log. +- **Second correction (2026-10-24 20:29): the widget's hover list closed because its window passed transparent pixels through.** The edge panel (clear background) never set `ignoresMouseEvents`, so the window server sent every alpha-0 point to the window below. While the list grew and laid out, the point under a resting pointer was blank for a moment: SwiftUI's `.onHover` and an `NSTrackingArea` both reported "left" while AppKit's hit test still found the hosting view, and the list shrank and grew about once a second (20:25 to 20:27). `panel.ignoresMouseEvents = false`, set explicitly, fixed it (3e80832422): the list stayed open under the pointer. Rule: when two independent hover trackers say "left" at once and a hit test finds your view, look at the window server's routing (alpha passthrough), not at the views. + +## git from a deleted folder, a flashing empty state, a ring the scroll view cut — 2026-10-25 21:52 + +- **git cannot start in a folder that no longer exists.** `git rev-parse --show-toplevel` with a deleted file's + folder as its working directory fails, so a lookup by "the open document's folder" said "not in a git repository" + for every deleted file. Walk up to the nearest existing ancestor first (`GitQueries.existingFolder`). +- **A model that resets to `.loading` on every reload makes its empty state flash.** The Changes pane showed "No + unstaged changes in this file." and blanked it on each status refresh (every 3 s on a busy repository). Show + loading only when nothing is on screen for a NEW request; a reload of the same request keeps the shown answer until + the new one arrives (`GitFileDiffModel.shownRequest`). Test it with a plant that records every state the view saw. +- **A system focus ring on a row inside a scroll view is clipped and drawn off the row.** Use + `.focusEffectDisabled()` and draw the keyed row's own ring (`strokeBorder` in the row's shape) while the list has + focus. +- **A file view that follows the selection must keep its last answer.** When the open tab is a file outside any + repository, the repository list kept showing the previous repository (`lastRepo`) instead of "not in a git + repository". diff --git a/.claude/skills/swift/references/performance.md b/.claude/skills/swift/references/performance.md index 19c2658d01..d93ce94356 100644 --- a/.claude/skills/swift/references/performance.md +++ b/.claude/skills/swift/references/performance.md @@ -234,7 +234,7 @@ Key the cache by file identity (`stat`: inode, size, mtime), bucket pixel sizes ### Sample from inside the bench - `sample <pid>` from the shell missed the bench: the test prints its lines only at exit, so a shell loop that waits for the first line samples nothing. Start `/usr/bin/sample <getpid()> 8 -file <out>` with `Process` inside the bench - (`BENCH_SAMPLE=<path>`), then rank the main thread's own frames. It named the costs at once: + (the markdown bench had a `BENCH_SAMPLE=<path>` switch for it, since removed; add the call while you measure), then rank the main thread's own frames. It named the costs at once: `MarkdownWebView.makeNSView` 182 of 466 samples (39%, the web view per tab), `GitRepoPanel.body` 55 (the fold and sorts in the body), `GitFileStateModel.state.setter` 67 (a publish that re-ran the panel). @@ -288,7 +288,8 @@ Release check app, a 600-row tree, 360 wheel events through the window, CPU per - A window behind the desktop or at alpha 0 is not drawn, so its scroll cost reads near zero. Use a visible window on a second display (or a quiet moment), interleave the before and after builds, and report each round. -- Debug builds cost about 25% more than release here. Install with `bun run start` (release) before judging lag. +- Debug builds cost about 25% more than release here. Install a release build before judging lag (Genesis: `bun run start` in `GenesisPlayground/Genesis/apps/Genesis`; + GenesisTools.app: `bun run app`). ### Divider drags: log, do not guess @@ -415,5 +416,54 @@ select-phase mark file for `sample`). 10 switches 0.5 s apart, debug, busy ms): SwiftUI fade 2238 / 2208, no fade 938 / 901, Core Animation fade 925 / 925. ### Check the build configuration first -- The installed app at 04:01 was a DEBUG build (`start:debug`; same size as the worktree's debug product, no release +- The installed Genesis app at 04:01 was a DEBUG build (`start:debug` in the Genesis repo; same size as the worktree's debug product, no release product there). Before profiling a user's lag, compare the installed binary with the build products. + +## Fifth pass on the markdown viewer: the git sidebar's repository list (2026-10-25 21:52) + +Bench: `MarkdownRepoPanelBenchTests` (`MARKDOWN_REPO_BENCH=<repo>`, phases and sampling in its header), the vault +with 5,650 status entries, a hosted window, debug build, main-thread CPU medians of 4 interleaved rounds. Before the +work, the real release viewer spent 6.86 s of CPU on 360 scroll events (19 ms each, load 24 to 28), 4,609 of 7,421 +samples in `MarkdownRootHostingView.layout`. Report: the notes vault's `2026-10-25-RepositoryPanel/` folder. + +### A long list in a sidebar is an AppKit table of hosted cells +- A `LazyVStack` of 5,650 rows inside the window's SwiftUI root lays out through the root hosting view on every scroll + step, and its estimated document height swung from 18,218 to 977,538 pt between steps. An `NSTableView` of + `NSHostingView` cells over the sidebar (`MarkdownOverlaySlot`, `GitRepoTable`, the file tree's pattern) builds only + the visible rows. **Measured:** scroll step 9.4 → 5.8 ms, with a folder open 12.2 → 4.0 ms. Pages ("Show more") go. +- Give the table ONE row height, measured once from a real row. A height per row made every refill ask each cell to + measure itself. + +### Never `reloadData()` a hosted-cell table for a content change +- `reloadData` re-runs the window's key-view loop for every row view (`_updateKeyViewLoopForRowView`, 1,159 samples) + and moves every host into the window again (`NSHostingView.viewDidMoveToWindow`, 892). Insert and remove the changed + rows (`noteNumberOfRowsChanged`, a set diff for small changes) and refill the visible row views in place + (`enumerateAvailableRowViews`, set the cell's root view). **Measured:** sort 68 ms before the table, 195 ms with + `reloadData`, 33 to 48 ms with refills. The diff algorithm was the first suspect and changed nothing: sample first. +- Keep what changes often (the open file's highlight) out of the value every cell reads. With the open file in the + table's revision, a tab switch rebuilt every visible cell (+51 ms); refreshing only the two rows it moved between + brought the switch to 74 ms (150 before, 142 before the table). + +### Closures make a view unequal, focus modifiers rebuild the key-view loop +- A view that takes closures never compares equal, so it runs again on every parent publish. Make the heavy child + `Equatable` on what it shows (closures left out of `==`), apply `.equatable()`, and stop observing the window model + in its parent (`MarkdownGitSidebar`, `GitRepoPanel`). **Measured:** an unrelated publish 22.9 → 8.6 ms. +- `.focusable()` on a container of many controls makes every render rebuild the window's key-view loop + (`FocusBridge.updateDefaultKeyViewLoop`), even with keyboard navigation off. The table takes keys itself + (`MarkdownTreeKeyTarget`); the container needs no focus modifier. + +### A counter that wakes every follower +- `GitRepoModel.generation` bumped on every status read, every 3 s on a busy repository, so every follower re-read + dates and re-derived its lists with nothing changed. Bump on a change; keep a separate `reads` counter for the one + consumer that needs every read (file dates). **Measured:** refresh 134 → 28 to 43 ms. +- Index a value that a body looks up by key once, when the value is built off the main thread (`GitRepoStatus.byPath`), + never with a linear search per row. + +### Widget lessons from the same day (gt-native-ux, commit 574fc167ab) +- A `LazyVStack` in a `ScrollView` whose rows have very different heights (one open card among folded ones) can loop + forever: the estimated document height changes (2646 → 2053 → 1406 pt on 11 cards) and the scroll offset chases it, + 99% CPU with no body evaluated. A bounded list (tens of rows) uses `VStack`; lazy is for long lists. Reproduce with + `WidgetScrollReplayTests`, `GENESIS_WIDGET_REPLAY_FILTER` and `GENESIS_WIDGET_REPLAY_VISIBLE=1` (an occluded window + skips display passes and hides the loop). +- `HangWatch.onSpin` recovers only a spin that returns to the run loop. A wedge inside one SwiftUI transaction (log + line `main-thread WEDGED`) never reaches it, so the fix must be the root cause. diff --git a/.claude/skills/swift/references/recording-animations.md b/.claude/skills/swift/references/recording-animations.md index a467db776d..d2a21deb85 100644 --- a/.claude/skills/swift/references/recording-animations.md +++ b/.claude/skills/swift/references/recording-animations.md @@ -204,8 +204,8 @@ is not the recording'sFPS and is not a compositor-FPS guarantee. Keep recorders Store each deliverable as `<datetime>-<native-commit>-<descriptive-state>.<extension>`. Receipts retain process launch/build time, PID, native window ID, logical rectangle, pixel size and hash. Rejected captures stay in private scratch with a reason; never present the closest successful-looking image as -proof of the failed recording. Restore user preferences/drafts, update Requests.md and the single -Implementation.wrapup.md, and publish the clean evidence through the Answer MCP when requested. +proof of the failed recording. Restore user preferences and drafts, update the task's vault notes, and post the clean evidence to the +user's inbox (`tools question message --image`) when requested. ## Transparent padding, no labels: the verified clean grid diff --git a/.claude/skills/swift/references/sources.md b/.claude/skills/swift/references/sources.md new file mode 100644 index 0000000000..e1929f9196 --- /dev/null +++ b/.claude/skills/swift/references/sources.md @@ -0,0 +1,64 @@ +# Where the 2026-10-24 rules came from, what to reject, and what waits + +On 2026-10-24 about 110 SwiftUI and AppKit agent skills were found (the first UX search, GitHub code search and the +skill registries) and read as data, never installed or run. Their rules were judged against this skill (105, 91 and +about 150 rules before merging), merged into 45 rules, and checked against a code audit of the app that found 36 +confirmed defects ([audit.md](audit.md) holds the result). The notes, with every quote, file and line and the hit +counts in `src/macos`, are in the notes vault under `GenesisTools/2026-10-24-SwiftPerfSkills/` +(`Research-SwiftRulesMerged` is the merged list; `SwiftChanges` is the list of defects). + +## The skills worth citing + +- **avdlee/swiftui-agent-skill@swiftui-expert-skill**: state management, rows that take values, scoped animation, + `updateNSView` rules. +- **twostraws/swiftui-pro**: rows get the element, `.task` over `onAppear`, always an animation value. +- **dpearson2699/swift-ios-skills** (swiftui-performance, swiftui-animation): no blocking I/O on the main actor, + transaction ownership. +- **livsy90/iOS-Performance-Agent-Skills** (swiftui-performance, swift-concurrency-performance): layout against + transform animation cost, batching, cooperative threads. +- **charleswiltgen/Axiom** (axiom-macos, axiom-analyze-swiftui-performance): measured formatter cost, AppKit + interop, key view loop. +- **yigitkonur/plugin-swiftui** (audit skills; few stars, so only rules a second source or the audit confirmed): + representable lifecycle, main-actor hops, cancellation. +- **Dimillian/skills** (swiftui-performance-audit, swiftui-ui-patterns, swiftui-view-refactor): cached formatters, + coalesced async work. +- **emilkowalski/skills@write-swift**, **daneden/agent-skills@swiftui-gotchas**, **openai/plugins** (AppKit interop), + **AutisticAF/claude-code-apple-dev-plugin**, **jamesrochabrun/skills@swift-concurrency**, + **rshankras/claude-code-apple-skills**, **ilianovi3/swift-skills@optimize-swift** (Layout cache; one source, kept + because the audit confirmed it). +- Thin or wrong: the four LaughingJackalope/macos-skills (claims that contradict Apple's docs and the AppKit headers), + pproenca (unmeasured `drawingGroup` gains, `TimelineView` plus `Canvas` as fast). One registry skill claimed to be + written by Apple and to supersede model training; it was skipped. + +## Advice to reject here + +Each of these contradicts a measurement in this skill or a fact of this codebase. + +- "`AnyView` defeats diffing, always remove it": removing an `AnyView` root changed nothing measurable here + ([performance.md](performance.md)). +- "`TimelineView(.animation)` with `Canvas` is the fast path": it was an idle-CPU cost here; a layer animation is. +- "SwiftUI transforms are GPU-accelerated, so animate freely": a SwiftUI animation lays out the whole graph it lives + in ([performance.md](performance.md), measured). +- "Use `PhaseAnimator` or `KeyframeAnimator` for a looping pulse": still a SwiftUI animation per frame. +- "Never use GCD" / "migrate to Swift concurrency first": the app builds in Swift 5 mode with 225 + `DispatchQueue.main.async` sites; migrate per measured need, not as a sweep. +- "Prefer SwiftUI over AppKit bridging": the measured fixes here are AppKit (layer animations, `NSPanel`). +- "A per-row `@Observable` model for list rows": not applicable to `ObservableObject`; rows take values. +- "`ImageRenderer` for view images": see the snapshot rules in `.claude/rules/macos-app.md`. +- A flat "use a lazy stack from 50 items": measure; the `LazyVStack` cost per scroll event is in + [performance.md](performance.md). +- `drawingGroup` as a general fix: the skills disagree and nobody measured it here. + +## Later (needs `@Observable`, Swift 6, or macOS 15, 26 or 27) + +- Move hot `ObservableObject` types to `@Observable`: a view then updates only for the properties its body reads. + It needs a replacement for `RenderProbe.watch`, which counts `objectWillChange`. It would fix + whole-window redraws from one shared model at the root. +- After that: `@ObservationIgnored` caches, Equatable stored types, flattened dependencies, AppKit observation + tracking in `layout()` and `draw(_:)` (macOS 26), `Observations { }` (macOS 26). +- Swift 6 mode: `@concurrent`, isolated conformances, static isolation over `MainActor.run`, per-target migration. +- macOS 15 and 26 window APIs: `WindowDragGesture` (replaces `isMovableByWindowBackground`, a trap in + [gotchas.md](gotchas.md)), `windowResizeAnchor`, `@Animatable`, `NSGestureRecognizerRepresentable`, + `NSGlassEffectView`, `scrollEdgeEffectStyle`. +- SwiftUI `WebView` and `WebPage` (macOS 26): bench against the `WKWebView` spare first. +- macOS and Xcode 27: `ToolbarOverflowMenu`, `reorderable`, Instruments 27. diff --git a/.claude/skills/swift/scripts/swift-scan.ts b/.claude/skills/swift/scripts/swift-scan.ts new file mode 100644 index 0000000000..0856557e08 --- /dev/null +++ b/.claude/skills/swift/scripts/swift-scan.ts @@ -0,0 +1,241 @@ +#!/usr/bin/env bun +/** + * Lists candidate cost and wrong-state defects in Swift files, grouped by the passes of references/audit.md. A line + * here is a candidate, not a finding: read the caller (which thread, how often) before you report it. The scan only + * reads files. The engine is shared with the ux skill's scanner. + * + * Usage: bun .claude/skills/swift/scripts/swift-scan.ts <file or folder>... [--max <hits per check, default 60>] + */ +import { type Check, type CountFact, type FileFact, runCli, type ScanSet } from "../../ux/scripts/scan-engine.ts"; + +/** A view, a view model or anything on the main actor: where blocking work stalls the UI. */ +const MAIN_ACTOR_FILE = /:\s*View\b|some View|@MainActor|ObservableObject|NSViewController|NSWindowController/; +const VIEW_FILE = /:\s*View\b|some View/; + +const CHECKS: Check[] = [ + { + pass: "1. The main thread", + name: "blocking-io", + pattern: + /String\(contentsOf|Data\(contentsOf|NSImage\(contentsOf|contentsOfDirectory|enumerator\(at|fileExists\(atPath|attributesOfItem|\.write\(to:|FileLock\.withLock|Thread\.sleep|DispatchSemaphore|\.wait\(\)/, + hint: "file or lock work: follow the callers; does one run on the main actor on a click, tick or publish?", + }, + { + pass: "1. The main thread", + name: "process-or-encode", + pattern: + /Process\(\)|waitUntilExit|JSONEncoder\(\)\.encode|CIContext\(\)|defaults\.set\([^)]*Data|sqlite3_|\.prepare\(/, + hint: "a process, an encode, an image render or a database read: on the main actor? how big, how often?", + onlyIn: MAIN_ACTOR_FILE, + }, + { + pass: "1. The main thread", + name: "menu-validation", + pattern: /func validateMenuItem|func validateUserInterfaceItem|validateMenuItem/, + hint: "runs on every menu open: only cheap reads here", + }, + { + pass: "1. The main thread", + name: "write-per-item", + pattern: /self\.\w+\.append\(|self\.\w+\[\w+\] = /, + hint: "a published write inside a loop? build locally and assign once", + unless: /var \w+ = self\./, + near: 4, + }, + { + pass: "2. Work that runs too often", + name: "file-watch", + pattern: + /FSEventStreamCreate|DirectoryWatcher\(|DispatchSource\.makeFileSystemObjectSource|FSEventStreamSetDispatchQueue/, + hint: "ignores paths nobody reads? debounced? anyone consuming the result right now? which queue?", + }, + { + pass: "2. Work that runs too often", + name: "task-restart", + pattern: /\.task\(id:|\.onChange\(of:/, + hint: "what restarts it, how often, and does each restart start a process or a reload?", + byFile: true, + }, + { + pass: "2. Work that runs too often", + name: "polling-loop", + pattern: + /Task\.sleep\(for: \.seconds\(|Task\.sleep\(nanoseconds:|Timer\.scheduledTimer\([^)]*repeats: true|Timer\.publish\(every:|TimelineView\(\.periodic|asyncAfter\(deadline: \.now\(\) \+ (0\.[0-9]|[1-9])/, + hint: "a loop or a ticker: does it run for hidden windows, and does each tick recompute more than it shows?", + }, + { + pass: "2. Work that runs too often", + name: "cli-call", + pattern: /ToolsBridge\(|bridge\.(run|call|json|action)|runTools\(|execTool/, + hint: "a process per call (350 to 420 ms here): per action, per open, per folder? a server door or a dedupe?", + }, + { + pass: "2. Work that runs too often", + name: "write-or-publish-unconditionally", + pattern: /writePrivate\(|postNotificationName|DistributedNotificationCenter|\.publish\(\)/, + hint: "written or posted on every change: skip it when nobody reads it", + }, + { + pass: "3. Redraws wider than the change", + name: "hot-published", + pattern: + /@Published (public |private\(set\) |public private\(set\) )?var \w*(level|Level|rms|partial|Partial|progress|Progress|draft|Draft|voice|Voice|text|Text)\b/, + hint: "a fast-changing value on a model many views observe: give it its own small ObservableObject", + }, + { + pass: "3. Redraws wider than the change", + name: "row-observes-model", + pattern: /@ObservedObject (public |private )?var \w+: \w+(Model|Store|Session|Session\w*)\b/, + hint: "in a row or a list: does it read the model, or only call actions? rows take values", + byFile: true, + onlyIn: VIEW_FILE, + }, + { + pass: "3. Redraws wider than the change", + name: "linear-search-in-view", + pattern: /\.first \{ \$0\.(key|id|sessionKey) ==|\.first\(where: \{ \$0\.(key|id) ==|\.filter \{ \$0\.\w+ == /, + hint: "a search per read or per row: read once per body, or use the model's dictionary", + byFile: true, + onlyIn: VIEW_FILE, + }, + { + pass: "3. Redraws wider than the change", + name: "inline-observed-init", + pattern: /@ObservedObject (private )?var \w+ = \w+\(/, + hint: "an observed object made inline is recreated with the parent: @StateObject", + }, + { + pass: "4. Work inside a body", + name: "formatter-or-parser", + pattern: + /(?<!static let \w{1,40} = )\b(DateFormatter|NumberFormatter|ISO8601DateFormatter|RelativeDateTimeFormatter|ByteCountFormatter|DateComponentsFormatter|NSRegularExpression)\(\)|options: \.regularExpression|AttributedString\(markdown:|[iI]nlineMarkdown\(|Markdown\w*\.parse\(/, + hint: "built per call: is this reached from a body, a computed property it reads, or a row?", + unless: /static (let|var)/, + near: 0, + onlyIn: VIEW_FILE, + }, + { + pass: "4. Work inside a body", + name: "derived-list", + pattern: + /private var \w+: \[|Dictionary\(grouping:|\.sorted\(|\.joined\(separator:|localizedCaseInsensitiveContains|\.reduce\(/, + hint: "a list derived while drawing: computed once per change of its inputs, in the model?", + byFile: true, + onlyIn: VIEW_FILE, + }, + { + pass: "4. Work inside a body", + name: "identity", + pattern: /\.id\(UUID\(\)\)|\.id\(Date\(\)\)|ForEach\([^)]*\.indices|id: \\\.self|AnyView\(/, + hint: "unstable identity resets state and rebuilds rows", + byFile: true, + }, + { + pass: "4. Work inside a body", + name: "binding-in-body", + pattern: /Binding\(get:|GeometryReader \{/, + hint: "a Binding or a GeometryReader in a repeated or hot view?", + byFile: true, + onlyIn: VIEW_FILE, + }, + { + pass: "5. Animation and drawing", + name: "with-animation", + pattern: /withAnimation\(/, + hint: "around a first load or a bulk list change? (a list helper's guard, SWR.animates in the hub)", + unless: /animates\(|reduceMotion \? nil|GenMotion/, + near: 2, + }, + { + pass: "5. Animation and drawing", + name: "continuous-swiftui-animation", + pattern: + /repeatForever|LoopingAnimation|PhaseAnimator|KeyframeAnimator|\.animation\([^)]*\), value: (level|rms|\w*Level)/, + hint: "a loop or an audio-rate animation in SwiftUI: a layer animation in a representable instead", + }, + { + pass: "5. Animation and drawing", + name: "expensive-effect", + pattern: /\.shadow\(radius: (1[2-9]|[2-9]\d)|\.blur\(radius:|\.drawingGroup\(\)|\.compositingGroup\(\)/, + hint: "a large shadow or blur on something that animates or updates often?", + }, + { + pass: "5. Animation and drawing", + name: "custom-layout", + pattern: /:\s*Layout \{|func placeSubviews|func makeCache/, + hint: "a Layout that calls sizeThatFits on children in both sizeThatFits and placeSubviews measures twice", + }, + { + pass: "6. AppKit bridging and windows", + name: "update-ns-view", + pattern: /func updateNSView\(|\.rootView = |\.stringValue = |needsLayout = true/, + hint: "an unconditional set in updateNSView rebuilds or relayouts on every SwiftUI update", + }, + { + pass: "6. AppKit bridging and windows", + name: "observer-or-monitor", + pattern: + /addObserver\(|addLocalMonitorForEvents|addGlobalMonitorForEvents|NotificationCenter\.default\.publisher/, + hint: "removed in dismantleNSView, deinit or onDisappear?", + byFile: true, + }, + { + pass: "7. Concurrency and state", + name: "published-sink", + pattern: /\$\w+\s*\.sink|\$\w+\.sink\s*\{|\.sink \{ \[weak self\] _ in/, + hint: "a @Published sink fires in willSet: does the closure read the old property instead of the value?", + }, + { + pass: "7. Concurrency and state", + name: "unowned-task", + pattern: /Task\.detached|Task \{ try|for .* in .* \{\s*Task \{/, + hint: "why detached? does an error vanish? a Task per item in a loop?", + byFile: true, + }, + { + pass: "7. Concurrency and state", + name: "unchecked-sendable", + pattern: /@unchecked Sendable|nonisolated\(unsafe\)|@preconcurrency|MainActor\.assumeIsolated/, + hint: "the invariant written beside it?", + byFile: true, + }, +]; + +const COUNTS: CountFact[] = [ + { + name: "@Observable types", + pattern: /@Observable\b/, + zeroMeans: "every observer redraws on any publish of its object", + }, + { name: "ObservableObject types", pattern: /:\s*[^{]*\bObservableObject\b/, zeroMeans: "none" }, + { + name: "signposts and perf spans", + pattern: /OSSignposter|PerfLog\.span|HubMainBusy|RenderProbe/, + zeroMeans: "nothing here is measured; add a span before you judge a cost", + }, +]; + +const FILE_FACTS: FileFact[] = [ + { + name: "main-actor files with the most @Published properties", + test: (text, lines) => { + if (!/@MainActor/.test(text)) { + return undefined; + } + + const count = lines.filter((l) => l.includes("@Published")).length; + return count >= 15 ? `${count} @Published` : undefined; + }, + }, + { + name: "files that animate a list with withAnimation and no first-load guard", + test: (text) => + /withAnimation\(/.test(text) && /ForEach/.test(text) && !/animates\(/.test(text) ? "" : undefined, + }, +]; + +const SWIFT_SET: ScanSet = { title: "Swift scan", checks: CHECKS, counts: COUNTS, fileFacts: FILE_FACTS }; + +if (import.meta.main) { + runCli(SWIFT_SET, process.argv.slice(2), "usage: bun swift-scan.ts <file or folder>... [--max <n>]"); +} diff --git a/.claude/skills/ux/SKILL.md b/.claude/skills/ux/SKILL.md index 0c45b6aed0..d250179e3f 100644 --- a/.claude/skills/ux/SKILL.md +++ b/.claude/skills/ux/SKILL.md @@ -1,6 +1,6 @@ --- name: ux -description: UX polish and verification for GenesisTools' native macOS surfaces — the notch/side widget, Clicky, the Settings window, the hub and the review window (src/macos/GenesisKit, src/macos/GenesisTools). Use it whenever you design, change, review or test anything a user sees or touches there, even if the request only says "polish this", "it jumps", "padding looks off", "can't click it", "make it shippable", "test every setting", "the animation is janky", "the permission error is hidden" or "check the widget". It routes to the shippable checklist, live-testing recipes for the real app, motion verification and the ledger of things that were wrong on the first try. Pair it with the swift skill (performance) and the design skills it names. +description: UX polish, audits and verification for GenesisTools' native macOS surfaces — the notch/side widget, Clicky, the Settings window, the hub, the recording picker, the permission and approval dialogs and the review window (src/macos/GenesisKit, src/macos/GenesisTools). Use it whenever you design, change, review, audit or test anything a user sees or touches there, even if the request only says "polish this", "review this view", "what would you change", "it jumps", "padding looks off", "can't click it", "make it shippable", "test every setting", "the animation is janky", "keyboard does nothing", "VoiceOver", "the error is unreadable", "the permission error is hidden" or "check the widget". It routes to a pass-by-pass code audit, the shippable checklist (pointer, hit targets, keyboard, VoiceOver, feedback, errors, undo, empty states, Reduce Motion, attention, counts and words, settings), live-testing recipes for the real app, motion verification and the ledger of things that were wrong on the first try. Pair it with the swift skill (performance) and the design skills it names. --- # Native UX for GenesisTools @@ -11,13 +11,24 @@ chevron reacted, a list whose last item was cut off, a permission error printed that looked fine in code and showed an empty card for 130 ms on screen. So the work is mostly **using the thing for real, frame by frame, and comparing it with the checklist**, then fixing the shape of the code. +## Reviewing or auditing a surface + +Asked to review, audit or list what you would change: follow [references/audit.md](references/audit.md). It maps +the surface, walks the passes (pointer and targets, keyboard, VoiceOver, feedback and errors, undo and lost work, +empty states, motion, things that appear by themselves, counts and words, layout, windows and settings) with search +recipes, and +starts with what the shared components already do, so that no finding claims a defect they handle. Its first step +is `bun .claude/skills/ux/scripts/ux-scan.ts <files>`, which prints the candidates of every pass at once. Report what +the user meets, not code style. + ## The loop 1. **Read the ledger first** ([references/findings.md](references/findings.md)): each entry is a mistake made once, with how to see it and the fix. Do not repeat one. 2. **Run the real surface, not a mental model.** The real GenesisTools.app with its real grants, driven in the background so the user's pointer and windows are never touched: [references/live-testing.md](references/live-testing.md). -3. **Walk the checklist** for every view you touch: [references/checklist.md](references/checklist.md). +3. **Walk the checklist** for every view you touch: [references/checklist.md](references/checklist.md), with the + passes of [references/audit.md](references/audit.md) over your own diff. 4. **Verify motion with instruments**, not with one screenshot: [references/motion.md](references/motion.md). Window frames from the window server, transition callbacks from the app, and only then a recording. 5. **Evidence in annotated pairs** ([references/screenshots.md](references/screenshots.md): same size and scale, @@ -26,6 +37,30 @@ thing for real, frame by frame, and comparing it with the checklist**, then fixi after sit next to each other. 6. **Write down what was wrong on the first try** in the ledger, generically, with the commit. +## What Martin asks for, before he has to (his reviews of 2026-10-23) + +Read this before a widget, hub or settings change, and do these without being asked. Each line is a request he had +to make by screenshot. + +- **Every clickable thing answers the pointer.** Round bubbles get a round hover ring, not a rectangular plate or + nothing. If he cannot tell a thing is clickable by hovering it, it is a defect. +- **Opinionated means configurable.** A sound, a popup, an auto-expand, a flash, a tooltip, a placement, an order: + ship a setting with your pick as its default (`.claude/rules/opinionated-settings.md`), and say which you added. +- **Say why something moved.** When the widget chimes or opens by itself, the session that caused it flashes + (its bubble, its row, its popup) for a configurable few seconds. A cue without its cause is noise. +- **Rows carry their times.** A session row says when it was last active and when its newest question or answer + arrived ("active 12m ago · asked 3m ago"), live (`LiveAgo`). A header says what the session is: agent, project, + model, working or idle for how long, context size. +- **No jumps.** A filter that empties a list does not shrink an open panel; a tab, filter or headline never scrolls + under a fade; content never blinks empty while a panel grows or a hover changes it. +- **The action is near the eye.** The reply box sits where the reading happens (under the tabs), not at the far + bottom of a tall panel. +- **Attention cues point, they do not cover.** A flash is the border; a tinted overlay over a whole card reads as + covered. Border flash is the `AttentionPulse` default. +- **A tooltip never stands in for the real hover behavior.** If hover should open a preview, a tooltip appearing + instead is a bug to fix, not a fallback. +- **Every count opens what it counts**, and every session offers its terminal (cmux) and its inbox. + ## Design authorities (load before a judgment call) | Question | Load | diff --git a/.claude/skills/ux/references/audit.md b/.claude/skills/ux/references/audit.md new file mode 100644 index 0000000000..c6d365884d --- /dev/null +++ b/.claude/skills/ux/references/audit.md @@ -0,0 +1,246 @@ +# Auditing a surface from its code + +Use this when you are asked to review, audit or "check" a surface, and before you call your own change shippable. +A code audit finds most of what a user meets. The live checks in [live-testing.md](live-testing.md) confirm what +depends on timing, focus or the window server. + +## 0. Before you claim anything: what the shared pieces already do + +Most wrong claims in past audits named a defect that a shared component already handles. Open the component before +you report that something is missing. What they do (2026-10-24): + +- `.genHover()`, `.genHoverPlain()`, `.genHoverIcon()`, `.genHoverRow()` (GenesisKit `Style/GenHoverButton.swift`): + hover fill and border, the pointing-hand cursor, a disabled look, Reduce Motion, and padding that grows the hit + area (4 by 6 pt; `.genHoverIcon` draws a 22 pt circle). A control with one of these has hover and a hand cursor. +- `IconButton` (`Controls/IconButton.swift`): an instant tooltip and the same text as its accessibility label. +- `GenMotion` (GenesisKitBase `Style/GenMotion.swift`) and `@GenReduceMotion`: no animation under Reduce Motion. + `GenDisclosure`, `AttentionPulse` and the hover styles go through them. +- `NoticePill` (`Controls/NoticePill.swift`): a success note fades by itself; an error stays. +- `EmptyState` (`Controls/EmptyState.swift`): takes `actionTitle` and `action`. An empty state without a button is + the caller's choice, not a limit of the component. +- A SwiftUI `Button` reads as one VoiceOver element with its label; a row built from `.onTapGesture` does not. +- Keymaps: `HubKeymap.swift` and `WidgetKeymap.swift` hold the shortcuts. Search them before "there is no shortcut". +- Preferences: a constant may already be a setting. Search `WidgetPreferences` (GenesisKit + `Widgets/WidgetWireModels.swift`), `src/hub/lib/widget/types.ts` and the settings pages before "not configurable". + +An absence claim ("no Esc", "no undo", "never announced", "no hover") needs a search across the whole package, not +only the file in front of you: `rg -n '<what>' src/macos --type swift`. Put the command and its count in the finding. + +## 1. Map the surface + +Size it first. A UX audit reads every control, so keep one surface to about 5,000 lines and split a larger one by +window or pane. Measured on 2026-10-24 (Sonnet, one run each): surfaces of 2,000 to 4,500 lines found 73 to 78% of the +known defects, and the 10,800-line hub-settings surface found 52%. The same test on the swift skill was sharper: whole +areas of 39,000 and 112,000 lines found under 40%, and the same code in surfaces of 6,000 to 21,000 lines found 90%. + +Then run the scanner. It runs every search recipe of the passes below in one go and prints the candidate lines +per pass, plus facts over the whole set (announcements, undo, re-read on activation, arrow keys, files that animate +and never read Reduce Motion, duplicate reload buttons): + +```bash +bun <this skill's folder>/scripts/ux-scan.ts <files or folders of the surface> +# in this repo: bun .claude/skills/ux/scripts/ux-scan.ts src/macos/GenesisKit/Sources/GenesisKit/Recording +``` + +A line in its output is a candidate, not a finding. Open each one in context, check section 0, and keep only what +the user meets. Walk every section of the output: a section with candidates you did not open is a pass you skipped. +The scanner cannot see everything (a count that adds two kinds of thing, a verb that differs between two strings, a +failure written into a panel that closes): the questions of each pass still apply. + +Before you judge anything, also list with line numbers: + +- every control: `rg -n 'Button\(|Button \{|Menu|Toggle|Picker|TextField|\.onTapGesture|\.onHover' <files>`; +- every timer and delay: `rg -n 'asyncAfter|Timer\.|Task\.sleep|\.delay\(' <files>`; +- every count, badge and number the user sees, and what feeds it; +- every message the user can get: success, error, empty, loading, missing permission; +- every animation: `rg -n 'withAnimation|\.animation\(|\.transition\(|repeatForever' <files>`; +- every key handler: `rg -n 'keyboardShortcut|onKeyPress|onMoveCommand|onExitCommand|cancelAction|defaultAction' <files>`. + +Then walk the passes below over that list. A large file is no excuse to skip a pass: run the recipe, then read the +hits and the code around them. + +## 2. The passes + +Each pass names the questions, the recipes and the checklist section that holds the rule. + +### Pointer and targets ([checklist](checklist.md#affordance), [hit targets](checklist.md#hit-targets)) +- Does every control from the map use a shared hover style, or draw its own hover and cursor? Bare ones: + `.buttonStyle(.plain)`, `.buttonStyle(.borderless)`, a `Menu` without a hover style, `.onTapGesture` on a view. +- Is the hit area at least the drawn shape, and do neighbours split the gap between them? Compare `.frame`, + `.padding` and `.contentShape` with the drawn size and the stack's spacing: a click in the gap between two bubbles + or icons must land on one of them. Padding applied after `.buttonStyle(.plain)` sits outside the button and takes + no clicks. +- Is any target under 22 by 22 pt (`frame(width: 1x, height: 1x)` on a button label, a 12 pt glyph with 3 pt + padding)? +- Does a custom `ButtonStyle` read `configuration.isPressed`? Does every `.onDrop` / `.dropDestination` show + `isTargeted`? Does an `.overlay` or ZStack layer over a control pass clicks (`allowsHitTesting(false)`)? + +### Keyboard ([checklist](checklist.md#keyboard)) +- Can the user walk every list with Up and Down and open the selected row with Return? +- What does Return do in each dialog, prompt and card? `.keyboardShortcut(.defaultAction)` belongs on the safe or + reversible choice, never on Send, Delete, Approve or Discard. Check what Return does when no field has focus. +- Does Esc close every popup, sheet, card and overlay without acting? An `NSPanel` subclass needs `cancelOperation` + or a key handler; a SwiftUI one needs `.onExitCommand` or `.onKeyPress(.escape)` on a view that has focus. +- Does a surface that opens to type (composer, search, find, prompt) focus its field on open? Recipe: files with + `TextField(` or `TextEditor(` and no `.focused(` or `makeFirstResponder`. +- Is a focus ring removed (`focusEffectDisabled`, `focusRingType = .none`) without a focused look in its place? +- Does a bare-letter shortcut (`onKeyPress(characters:`, `keyboardShortcut("j")` with no modifiers) fire while a text + field has focus? +- Does every control with a shortcut show it in its tooltip? Recipe: compare the keymap's bindings with + `rg -n 'instantTooltip\("[^"]*[⌘⌥⇧⌃]'`. +- Does every search or filter field (`TextField("Search`, `"Filter`, `"Find`) have a clear button? +- Is an action reachable only from a right click? Recipe: `rg -n '\.contextMenu' <files>`, then look for the same + action as a visible control or a key. +- When a sheet, popover or panel closes, does focus go back to the control that opened it? +- Does a multi-line composer say which key sends and which adds a line? +- Do the shortcuts work wherever focus is inside the panel, or only when the root view has focus? A handler on a + container stops firing when a child (a field, a list) takes focus. +- Does a shortcut take a key that macOS or the app's other windows use for something else: ⌘H Hide, ⌘M Minimize, + ⌘W Close, ⌘Q Quit, ⌘, Settings? +- Do ⌘1 to ⌘9 switch tabs and modes? + +### VoiceOver ([checklist](checklist.md#voiceover)) +- Does every icon-only control have a label? Does every status icon, badge and count capsule that means something + have one? `.instantTooltip` and `.help` set no accessibility label: a meaning that lives only in a tooltip is + lost to VoiceOver. Recipe: `rg -n 'instantTooltip' <files>` on views that are not `IconButton`, then look for a + label beside each. +- Is a selected row, tab, filter or option exposed as selected (`.accessibilityAddTraits(.isSelected)` or a value)? + A checkmark or a tint alone says nothing. +- Does every action that appears only on hover (`if isHovering { Button … }`) also exist as + `.accessibilityAction(named:)` on its row or as a context-menu item? +- Is every change that happens without the user's action announced (an alert arrives, a send fails, recording + starts or stops, a grant changes)? Recipe: `rg -n 'announcementRequested|AccessibilityNotification' src/macos`. +- Does a row made of several texts read as one element with its state (`.accessibilityElement(children: .combine)`)? +- Does VoiceOver meet the views in their visual order? A ZStack or overlay whose later child draws first needs + `accessibilitySortPriority`. +- Do custom faint colours have an Increase Contrast variant (`colorSchemeContrast`)? +- Is a view that is faded out (`.opacity(0)`), zero-size or off screen still readable by VoiceOver? It needs + `.accessibilityHidden(true)` while hidden. +- Does an accessibility label repeat the control type ("… button") or differ from the visible text? + +### Feedback and errors ([feedback](checklist.md#feedback), [errors](checklist.md#errors-and-recovery)) +- For every async action in the map: what does the user see while it runs, when it works and when it fails? +- If the view that started it is gone when it fails (a panel collapsed, a popover closed), does the failure come + back to the user (the trigger flashes, a popup offers Retry, a notification)? A result written into a closed panel + is lost. +- Does each note have the right life: success fades after a few seconds, an error stays until it is closed? A note + with no timer stays forever; an error with a timer is gone before it is read. +- Where does a failure show: on the row or card that failed, or does it blank the whole pane (a `Text(error)` in + place of the list)? Does each cause (not installed, no receiver, refused, offline, timed out) get its own text and + next step? Does a failed send keep the typed text and the staged items? +- Does a bulk action (send all, clear all, import) end with how many worked and how many failed? +- Raw error text on screen: `rg -n 'Text\([^)]*(error|Error|detail|stderr|reason)' <files>` and + `rg -n 'localizedDescription|stderr|suffix\(6' <files>`; each hit that reaches a label shows the machine's words. +- Does the view build formatters or sort lists while it draws (`DateFormatter()`, `ISO8601DateFormatter()`, + `NumberFormatter()`, `.sorted(` in a computed property the body reads)? The user meets a hitch on every redraw; + the swift skill has the fix. +- Is the error text one sentence of what happened and what to do, with Retry where it makes sense and raw output + only behind Details? Raw stderr or `localizedDescription` alone is a defect. +- Is the tone (success, warning, error) stored with the message? Code that picks the colour from words in the text + is a defect. +- Does a wait that blocks the user's agent (an approval, a question) escalate: a sound, then a notification? +- Does a spinner or skeleton appear for work that ends in under 200 ms (a `ProgressView(` or `Skeleton` shown at + once, with no delay)? Does the placeholder have the size of the content that replaces it? +- Does anything the user waits on end in an error or a Cancel within about 10 s? Recipe: timeouts and default + `timeoutSeconds` above 10 on calls a view waits for. +- Is a success announced with a modal alert (`NSAlert`, `.alert`) instead of a note? +- Does the first frame show a default that `onAppear` or `.task` then replaces with the saved value? +- Does every loading state end, in content, an empty state or an error? A skeleton or spinner that stays forever + after a failed read is a defect. Does a transient state ("Copied", "Saved", "Sending…") revert? +- Does a control show that it is busy while its action runs, and refuse a second press? Does a disabled control say + why it is disabled (a tooltip or a caption)? A greyed control with no reason is a dead end. +- Does a primary action of a pane (Send, Apply, Save) have a shortcut, shown on the button or in its tooltip? + +### Destructive actions and undo ([checklist](checklist.md#undo-and-destructive-actions)) +- For every Dismiss, Discard, Delete, Remove and Clear: can the user undo it (Undo in the note, ⌘Z), or must they + confirm it? Undo beats a confirmation for frequent actions. +- Is the safe choice the default button? A destructive choice is marked as such and never sits on Return. +- Does a confirmation on a frequent action offer "Do not ask again" (`showsSuppressionButton`)? +- Does a sheet with Cancel bind its fields straight to the store (so Cancel cannot undo), instead of a draft? +- Does text the user typed survive a dismissal (a popover closed by an outside click, a panel that collapses, a row + that scrolls away)? Does anything the user staged (an image, a draft) survive a lazy row's `onDisappear`? + +### Empty, loading and denied ([checklist](checklist.md#empty-and-loading-states)) +- What does every list and pane show when it is empty? It says why, and offers the next step as a button, never as + a command to type in a terminal. +- Are "loading", "none yet", "filtered out", "permission missing" and "failed to load" different states with + different text? An empty result behind a missing grant names the grant and opens its settings pane. + +### Motion ([checklist](checklist.md#motion-and-reduce-motion), [motion.md](motion.md)) +- Under Reduce Motion, does any animation still move or scale? Fades are allowed; travel, scale, bounce and loops + are not. Safe paths: `GenMotion`, the shared styles, a check of `@GenReduceMotion` or `accessibilityReduceMotion`. +- Recipes: `rg -n 'reduceMotion: false'` (a hard-coded false); `withAnimation(.` and `.animation(.` with a literal + curve; `repeatForever`; `.transition(.move` and `.transition(.scale`. Read each hit for a Reduce Motion check. +- Files list: `rg -l 'withAnimation\(|\.animation\(|repeatForever|CABasicAnimation|NSAnimationContext|animator\(\)'` + minus `rg -l '(?i)reduceMotion|GenMotion|ShouldReduceMotion'`. Every file left animates with no check at all; + AppKit window moves and Core Animation count too. +- Is Reduce Motion read once into a `static let`? Does a timer wait for an animation (`asyncAfter` of about the + animation's duration)? Does anything scale in from 0? Does a loop stop when its view is hidden? +- Is a spring or a bounce used for a routine change, instead of `GenMotion` or a named curve? + +### Things that appear by themselves ([checklist](checklist.md#attention-and-notifications)) +- Can every popup, HUD, flash and sound that starts without the user be closed without acting (×, Esc)? Does it + name its cause? Are its duration, sound and placement settings ([opinionated-settings](../../../rules/opinionated-settings.md))? +- Recipe: `rg -n 'asyncAfter\(deadline: \.now\(\) \+ [0-9]'` and `Timer` intervals in view and model code. A duration + the user notices, written as a literal, is a missing setting. +- Does a system notification fire for a session the user is already looking at, or twice for one item? Does every + alert open what it is about? Does a burst of alerts give one cue or one per alert? + +### Counts, words and text ([checklist](checklist.md#counts-words-and-text)) +- For every count: what exactly does it count? Does it match the same count on the other surfaces (a tab title, the + hub)? Does it count only what needs the user, or does it add things of another kind? +- Is one thing called by one word across the widget, the hub and settings? Do option badges and the keys that pick + them use the same symbols? +- Raw data shown as words: a string from the data (a state, a kind, an id, a source name, an error detail) + interpolated into a label or tooltip (`"CI: \(state)"`, `status.capitalized`, a raw session id as a fallback + title). Map each value to a sentence, or hide it. Recipe: `rg -n 'Text\([^)]*\\\(' <files>` and + `rg -n 'instantTooltip\("[^"]*\\\(' <files>`, then ask what each interpolated value looks like to the user. +- Tooltips and captions say what happens, not which command runs (`tools …`). Does a tooltip hold something the + user needs to finish the task? +- Em and en dashes in visible strings: `rg -n '"[^"]*[—–][^"]*"' <files>` (Swift string literals only). +- Buttons named OK, Yes or No: `rg -n 'Button\("(OK|Yes|No)"'`. +- State colours: `rg -n 'Color\.(red|green|orange|yellow|blue)\b|\.foregroundStyle\(\.(red|green|orange)\)'` next to + the palette (`KitPalette`, `ReviewPalette`); a raw system hue for a state that the palette already names is a + mismatch between surfaces. +- ALL-CAPS titles: `rg -n 'Text\("[A-Z][A-Z :]{5,}"\)'` and `.textCase(.uppercase)`. Tiny kickers are the exception. +- Small, faint text: `rg -n 'size: (8|9|9\.5|10)[,)]'` near `.opacity(0.3)` to `.opacity(0.5)`. Text the user must + read keeps 4.5:1 contrast against its real background. + +### Layout ([checklist](checklist.md#layout)) +- Text inside a fixed frame (`.frame(height:)`, `.frame(width:)`) with no `.lineLimit` and no truncation: a long + value wraps or clips. Labels that embed a title or a path are the usual case. +- Two controls on screen at once that do the same thing (two reload buttons, a toggle in a popover and in settings + with other words). One navigation per surface. +- The same control drawn two ways in one app (a native `Picker(.segmented)` where the kit has `GenSegmentedTabs`). + +### Windows and settings ([windows](checklist.md#windows-and-overlays-martin-2026-10-14), [settings](checklist.md#settings)) +- Is state that can change outside the app (a privacy grant, a login, a file) read again when the app becomes + active (`NSApplication.didBecomeActiveNotification`) or the window becomes key? +- Can the user find every setting (a search field once there are more than a few pages)? Does each setting live in + one place, with one name and one range? +- Is a sub-option live while its parent switch is off (a `Toggle` followed by controls with no `.disabled(!…)`)? Does + a settings file push pages (`NavigationLink`, `NavigationStack`)? +- Windows: a resizable window without a minimum size; placement by `NSScreen.main`; `runModal()` without + `preventsApplicationTerminationWhenModal = false`; a popover that holds a `TextEditor` or a send button; + `setActivationPolicy` outside `DockTile`; an `NSPanel` that hides on deactivate or uses a shielding level. + +## 3. Report + +One block per finding: + +- **Where:** path:line. +- **What the user meets:** the experience in one or two sentences, not the code. +- **Rule:** the checklist section or SKILL.md line. +- **Evidence:** the code line, quoted; for an absence, the search and its count. +- **Fix:** one sentence. + +Order by harm: lost work, a blocked task and keyboard or VoiceOver exclusion first; confusion and waits next; polish +last. Before you write a finding, check section 0 again: if a shared component already covers it, drop it. One +wrong claim costs more trust than one missed defect. + +Leave out matters of taste and anything the user would not notice. Every finding names the checklist rule it +breaks; a finding with no rule is an opinion. A slot spent on taste is a slot a real defect does not get. + +End the report with **Coverage**: one line per pass, with the searches you ran, their hit counts, and the findings +it produced or "none". A pass with no line was skipped, and a skipped pass is where the misses come from. When the +finding limit forces a cut, cut polish, never a whole pass. diff --git a/.claude/skills/ux/references/checklist.md b/.claude/skills/ux/references/checklist.md index 28a2f092a6..ed55045ca0 100644 --- a/.claude/skills/ux/references/checklist.md +++ b/.claude/skills/ux/references/checklist.md @@ -1,6 +1,17 @@ # Shippable checklist for a native view -Walk it for every view you change. Each line exists because it was missed at least once. +Walk it for every view you change. Each line exists because it was missed at least once, or because one of the +skills distilled on 2026-10-24 states it and the app broke it ([sources.md](sources.md)). [audit.md](audit.md) says +how to find a violation in code. + +Sections: [Affordance](#affordance) · [Windows and overlays](#windows-and-overlays-martin-2026-10-14) · +[Layout](#layout) · [Widget and panel controls](#widget-and-panel-controls-martin-2026-10-23-1440) · +[Hit targets](#hit-targets) · [Keyboard](#keyboard) · [VoiceOver](#voiceover) · [Feedback](#feedback) · +[Errors and recovery](#errors-and-recovery) · [Undo and destructive actions](#undo-and-destructive-actions) · +[Empty and loading states](#empty-and-loading-states) · [Motion and Reduce Motion](#motion-and-reduce-motion) · +[Attention and notifications](#attention-and-notifications) · [Counts, words and text](#counts-words-and-text) · +[Settings](#settings) · [Real time and state](#real-time-and-state) · +[Loading, swaps and diffs](#loading-swaps-and-diffs-martin-2026-10-13-1722) ## Affordance - Every clickable thing shows the pointer cursor on hover and has a hover state. The hand comes from the shared styles @@ -15,6 +26,22 @@ Walk it for every view you change. Each line exists because it was missed at lea view: SwiftUI `Text(.init(markdown))` never draws an image. - Items that can be acted on can be acted on in place: tick a task, expand an answer, create a new one. Opening another window is the exception, not the default. +- 🛑 A render is not a click. After any change to a clickable view, press it once in the REAL installed app + (`tools control click --app <app> --desc <label>`, which does not take focus) and read the app's own action log + (`command log show --predicate 'subsystem == "genesiskit" AND category == "markdown.input"' --last 1m`). A PNG + proved the tab groups looked right while one press on a group chip had made the whole window deaf (2026-10-23). +- Press each NEW kind of control at least once (a chip, a menu, a drop target), not only the old ones beside it. +- A custom button style draws a pressed state that differs from its hover state (`configuration.isPressed`). +- A drop target highlights (an accent outline or fill) while a drag is over it (`isTargeted`). +- Rest, hover and pressed keep the same size, padding and font weight; hover changes only opacity, fill, a ring or + a layout-neutral scale. A hover reacts to enter and exit, never to every pointer move, and clears on exit. +- A click acts on mouse-up, so a press dragged off the control cancels it. No busy cursor as a loading cue: the + element that waits shows it. +- Every context-menu action also has a visible control or a key: a right click is never the only way. +- Status and essential data show inline, not only on hover; what a hover preview shows is also reachable by click + or keyboard. +- An overlay, scrim or note layer that is not a control passes clicks through (`.allowsHitTesting(false)`), unless + it blocks on purpose. A `DragGesture(minimumDistance: 0)` on a container swallows the taps of its buttons. ## Windows and overlays (Martin, 2026-10-14) - No `isMovableByWindowBackground` on a window with SwiftUI content: the hosting view counts as background @@ -27,6 +54,20 @@ Walk it for every view you change. Each line exists because it was missed at lea - A movable overlay tracks the drag in its own `mouseDragged` from each event's location and saves on mouse-up; `performDrag(with:)` moved a panel only on the first drag of a show. - ⌘W closes the current tab; on a window with no tabs left it closes the window. +- A resizable window has a minimum size that keeps its UI usable. +- A floating panel is non-activating, does not hide when the app deactivates, becomes key only when it takes text, + and never uses a shielding window level. +- Place a window by the pointer's screen, the window's own screen or the saved display, never by `NSScreen.main` + alone. +- A modal that does not need an answer does not block quitting or an update (`preventsApplicationTerminationWhenModal + = false`). +- A popover holds a short option or a detail; a reply, a form or a multi-step flow gets a panel, a sheet or inline UI. +- A window opened by the app goes through `DockTile.policy(for:)` for its activation policy, so it comes to the front + and keeps the Dock icon while it is open. +- In a regular window, a selection takes the inactive look when the window is not key. A non-activating panel (the + widget) keeps its look, since it is never key by design. +- State that can change outside the app (a privacy grant, a login, a file) is read again when the app becomes active + or the window becomes key. ## Layout - Equal, intentional insets on all four sides; controls aligned in one column; descriptions under titles. @@ -42,17 +83,238 @@ Walk it for every view you change. Each line exists because it was missed at lea and badges are fully visible at the smallest window size. - Segmented tabs in a non-activating panel: the native `Picker` greys its selection out of the key window; use `GenSegmentedTabs`. -- One navigation per surface; no two controls that do the same thing side by side. +- One navigation per surface; no two controls that do the same thing side by side, and no two reload buttons on + screen at once. +- Text in a fixed frame has a line limit and truncates; a label that embeds a title or a path otherwise wraps or + clips at real lengths. +- One control is drawn one way across the app: the kit's version, not a native one beside it. - Distinct icons for distinct features (two modules once shared `waveform`). +## Widget and panel controls (Martin, 2026-10-23 14:40) +- A text field inside a padded box: the whole box focuses the field (`.contentShape` + `.onTapGesture { focus = true }` + on the box); a click beside or under the one-line field did nothing. +- A button that opened something closes it on a second click when what it opened is still on screen (a rail + session's inbox, the inbox overview). A second click that does nothing reads as a dead control. +- A count leads to the list of exactly what it counts. "35 need you" over 4 rows, and "99+ unread" that opened one + arbitrary session, are both defects: the count opens a list of every counted item (scrollable, filterable), and + each item says which session it belongs to (name, project, short id) and how to get there. +- Every session row offers a way to its terminal: the cmux button with GenesisKit `CmuxSessionPanel` (focus, open + in its last pane, choose a pane), one shared component in the hub and the widget. +- A list's filter and order live next to the list, with a settings shortcut that opens the page at the card and + pulses it (`<page>#<anchor>` + `.nativeSettingsAnchor(id)`, GenesisKit `AttentionPulse`). +- One row style for every top-level item of a list. Sessions with sub-agents drew as disclosure headers and sessions + without as plain rows, in one list: same row, a chevron only where there is something to fold, and selection, pin + and cmux inside the row's own box. +- A scroll view reused for several sections (or sessions) gets an identity per content (`.id(section + key)`): it + otherwise keeps the old offset, and a short section drew its rows over the header. + +## Widget and panel controls, round 2 (Martin, 2026-10-23 15:35) +- Hover on every control, shaped like the control: a circle bubble gets a ring (`WidgetBubbleChrome`), which pulses + once on enter and settles. Opacity only, never a repeating animation. +- A filter bar and its headline sit outside the scroll view they filter, pinned under the tabs. +- A filter change in an open panel holds the panel's height (`WidgetModel.holdAgentFit`); folding a row or switching + view releases it. An empty filter result must not collapse the panel. +- A header line under a title is the place for facts: agent · project · model · working / idle `LiveAgo` · ctx. +- Rows: last active and newest question or answer, both `LiveAgo`. +- An alert (sound, popup, auto-expand) has a setting each, a sound per kind with a play button, and flashes the + session that caused it for `alertFlashSeconds`. +- A hover tooltip that duplicates a hover preview is off by default ("auto"), and the preview must open reliably: + a control the pointer reaches inside a panel starts the panel's hover itself, since the panel's own hover fires + only on crossing its edge. +- Two views of the same sessions (the rail bubbles and the hover list) read in one order; a bubble and its row sit + at the same place. A sticky order that never moves is an option, not the default (Martin, 2026-10-23 17:47). +- A flash reaches every place the session shows, including a list the alert itself opened: such a list is built + after the trigger changed, so it needs the flash start time, not only the trigger. + +## Hit targets +- A clickable control is at least 22 by 22 pt including its padding; a smaller one states why in a comment. +- The hit area is at least the drawn shape. A small glyph gets the padding of a shared hover style (`.genHoverIcon` + is a 22 pt circle); a row that acts takes clicks over its whole width and height (`.contentShape(Rectangle())`). +- Neighbours split the gap between them. A strip between two bubbles or two icons where a click does nothing is a + dead zone: grow each target into half the gap, or move the spacing inside the targets. + +## Keyboard +- Every list: Up and Down move the selection, Return opens it, Esc clears or closes. A list the user can only click + is a defect, in a panel as much as in a window. +- Return picks the safe choice. In a dialog, a prompt or a card, the default button keeps the user's work (Keep, + Cancel, Not now); Send, Delete, Approve and Discard are never on Return. Check what Return does when no field has + focus. +- Esc closes every popup, sheet, card and overlay without acting, cancels an operation that is running, and in an + edited text field puts the previous value back. +- Every action the mouse can start, the keyboard can start too. A view that acts only through `.onTapGesture` needs + a Button or a key handler beside it. +- A surface that opens to be typed in (a composer, a search, a find bar, a prompt) focuses its field when it opens, + without waiting for its animation to end. +- A control that takes keyboard focus shows a focus ring or a visible focused look; never remove the ring without + one. +- A shortcut without a modifier (a bare letter or digit) never fires while a text field has focus, and the user can + turn single-letter shortcuts off; a new shortcut prefers a modifier. +- When a sheet, popover or panel closes, focus returns to the control that opened it; after a delete it moves to a + neighbour, never to nothing. +- A multi-line composer says which key sends (Return or ⌘Return) and which adds a line (⇧Return), in a hint beside + the field. +- A control with a shortcut shows it in its tooltip ("Send (⌘↩)"); where the app has a menu bar, the shortcut is + also a menu item. +- A search or filter field shows a clear button while it holds text, and Esc clears it. +- Shortcuts work wherever focus is inside the window or panel, not only when the root view has focus. +- A key that macOS defines keeps its meaning: ⌘H Hide, ⌘M Minimize, ⌘W Close, ⌘Q Quit, ⌘, Settings. One meaning per + shortcut across all windows of the app. +- ⌘1 to ⌘9 switch tabs and modes, in their visible order. +- Hover never moves the keyboard selection. The pointer's highlight and the keyboard's selection are two states. + +## VoiceOver +- Every icon-only control has a label (`IconButton` gives one); a decorative image is hidden. +- A status icon, badge or count that means something has a label too. `.instantTooltip` and `.help` set none: a + meaning that lives only in a tooltip does not reach VoiceOver. +- A selected row, tab, filter or option says so (`.accessibilityAddTraits(.isSelected)` or a value), not only by a + checkmark or a tint. +- An action that appears only on hover is also an `.accessibilityAction(named:)` on its row, or a context-menu item. +- A change that happens without the user's action is announced: an alert arrives, a send fails, recording starts or + stops, a grant changes (`AccessibilityNotification.Announcement`, or + `NSAccessibility.post(element:notification: .announcementRequested, userInfo:)` in AppKit). +- A row made of several texts reads as one element with its state (`.accessibilityElement(children: .combine)`). +- Colour is never the only signal: a state shown in colour also has a symbol or a word, and the view reads + `accessibilityDifferentiateWithoutColor` where it can add one. +- The accessibility label matches the visible text and leaves out the control type ("Send", not "Send button"). +- A control drawn from shapes or gestures has a role, a value and an action (`accessibilityAddTraits`, + `accessibilityValue`, `accessibilityAction`). +- A view that faded out, has zero size or sits off screen is hidden from VoiceOver, and an exiting view ignores + clicks. +- Translucency comes from system materials, not `Color.opacity` plus a blur, which ignores Reduce Transparency. +- A report of an accessibility change names the manual checks that ran: VoiceOver, Full Keyboard Access, the + Accessibility Inspector. A code audit alone proves little. +- VoiceOver reads in the visual order. Where a layout draws a later view first (a pinned header, a composer above + its list), fix the order with `accessibilitySortPriority` or grouping. +- Custom colours have a stronger variant under Increase Contrast (`@Environment(\.colorSchemeContrast)`), above all + faint text and hairlines. + ## Feedback - Every action answers within 100 ms: visible state change, sound for capture, progress for slow work. -- Errors say what happened and what to do, in a dialog when the user must act. A missing grant goes through - `PermissionCenter` (GenesisKit Permissions/); test each path with `bun scripts/native/staging.ts deny <kind>`. -- Counts mean something: "99+ unread" made of weeks-old items is noise; age them out or let the user clear. - Never print a raw total of sessions; count what needs attention. One numbering scheme per list. +- No spinner or skeleton for work under about 200 ms; after that, one spinner per section; a skeleton only for loads + over about 500 ms. A placeholder has the size of what replaces it, so nothing moves when the data arrives. +- Anything the user waits on shows an error, a reason or a Cancel within about 10 s, not at a 30 s or 120 s process + timeout. +- A success note fades after a few seconds (the hub's `NoticePill` is the model): at least 2 s, about 1 s plus 50 ms + per word. A new note replaces the old one. An error stays until it is closed. +- A success never gets a modal alert; a modal is for a decision or a failure the user must act on. +- The first frame of a window or panel uses the saved setting (size, look, tab), never a default that snaps to the + saved one. +- The result of an action reaches the user where they are. When the view that started it is gone (a panel collapsed, + a popover closed), a failure flashes the trigger and offers Retry, or posts a notification. +- A wait that blocks the user's agent (an approval, a decision) escalates: a sound when it arrives, a notification + after a delay that is a setting. +- Every loading state ends in content, an empty state or an error; a skeleton after a failed read stays forever. + A transient state ("Copied", "Saved") reverts by itself. +- On press, a control flips its own state in the same frame: disabled, label kept, width stable, a spinner inside, + so a second press cannot start the action twice. A disabled control says why (a tooltip or a caption); a greyed + control with no reason is a dead end. +- A bulk action ends with a result: how many worked, how many failed and which. It never completes silently. +- Say what is loading ("Reading the session"), not the bare word Loading. Past 5 to 10 s show progress and a Cancel; + a longer job runs in the background and reports when it ends. +- Stale, partial or disconnected data looks different from live data and says when it was last updated. +- The primary action of a pane (Send, Apply, Save) has a shortcut, and the button or its tooltip shows it. + +## Errors and recovery +- Errors say what happened and what to do, in one sentence, with Retry where a retry can work, in a dialog when the + user must act. Raw output (stderr, a stack, `localizedDescription`) goes behind a Details disclosure. +- The tone (success, warning, error) is stored with the message by the code that knows it. Picking a colour from + words in the text is a defect: one message without the word shows a failure in green. +- A failure shows on the row, card or section that failed. It does not blank the pane, and a recoverable error never + raises a modal. +- Each cause has its own text and its own next step: not installed, no receiver, refused, offline, timed out. One + generic "failed" for all of them is a defect. +- A failed send keeps what the user wrote: the typed reply, the staged answers, the filters. Clear the input only + after the receiver confirms. +- A failure is the app's problem, not the user's: no blame, no "you must", no "Something went wrong". +- A missing grant goes through `PermissionCenter` (GenesisKit Permissions/); test each path with + `bun scripts/native/staging.ts deny <kind>`. + +## Undo and destructive actions +- A frequent destructive action (dismiss, discard, remove, clear) gets Undo: a note with an Undo button for a few + seconds, and ⌘Z. Undo beats a confirmation for things done many times a day. +- A rare, large one (delete a project, forget a key) asks first; its button is marked destructive and is not the + default. It names the action and the object, with the count when it removes several ("Delete 3 drafts"), never + OK, Yes or No. +- Every sheet, alert and multi-step flow has a visible Cancel besides Esc. +- A routine destructive action is a quiet secondary button, kept apart from the common action; each card, dialog or + sheet has one primary action. +- A confirmation the user meets many times offers "Do not ask again", which persists and can be undone in settings. +- A sheet or panel with Cancel edits a draft: it writes on commit and restores on Cancel. +- Text the user typed survives a dismissal: a popover closed by an outside click, a panel that collapses, a row that + scrolls away. Something the user staged (an image, a draft) is never dropped in a lazy row's `onDisappear`. + +## Empty and loading states +- An empty pane says why it is empty and offers the next step as a button (`EmptyState(actionTitle:action:)`). + Never tell the user to run a command where a button can do it. +- Loading, none yet, filtered out, permission missing and failed are different states with different text. Empty + data behind a missing grant names the grant and opens its settings pane. +- An empty pane hides the filters, sort and tabs that would do nothing. +- A first run offers one suggested action, not a tour, and asks for a permission when it is first needed, not at + launch. + +## Motion and Reduce Motion +- Every animation honours Reduce Motion: fades stay, travel, scale, bounce and loops go. Use `GenMotion`, the shared + styles or `@GenReduceMotion`; never pass a literal `false` where a component asks for the Reduce Motion state. +- That includes AppKit and Core Animation: `NSAnimationContext`, `animator()`, `CABasicAnimation` and hand-made + timers that move a window read `NSWorkspace.shared.accessibilityDisplayShouldReduceMotion`. +- Under Reduce Motion, travel and scale become an instant change or a crossfade of 200 ms at most. +- Read Reduce Motion when you animate, never once into a `static let`; test it by switching it in System Settings + while the app runs. +- Nothing waits on motion: a timer or a state change never assumes an animation ran or finished. When the motion is + skipped, the flow still completes. +- Routine changes use `GenMotion` or another named curve: ease-out or a critically damped spring, no linear, no + ease-in, no bounce on a functional control; a spring with overshoot is for a drag release or a one-shot moment. + Hover and press take about 100 to 150 ms, other transitions 150 to 250 ms, an exit about 75% of its entrance. +- Animate opacity and transforms, not frame, padding or spacing. Do not animate frequent or keyboard-driven actions, + or anything in settings: there, speed is the feature. +- An animation yields to new input; chain steps with the completion of `withAnimation`, not a delay. Never use the + old `.animation(_:)` modifier without a `value:`. +- Nothing scales in from 0: start at 0.9 to 0.95 with a fade. +- A decorative loop (shimmer, pulse, breathing dot) stops when its view is hidden or idle, and goes still under + Reduce Motion. A progress indicator keeps running under Reduce Motion: it carries meaning. +- An attention flash lasts 1 to 2 s and blinks at most 3 times a second; a burst of alerts gives one cue, not one + per alert. + +## Attention and notifications +- Anything that appears by itself (a popup, a HUD, a flash, a sound) can be closed without acting (× and Esc), says + what caused it, and has its duration and its sound as settings. +- A notification is for something the user must act on, never for progress. No system notification for a session + the user is looking at; never two for one unread item; group related ones. +- Every alert opens what it is about (its session, its composer) or says why it has no next action. +- A note's timer pauses while the pointer or focus is on it; a note never steals focus and has at most one action. +- Each alert kind has its own short sound (under 1 s), quiet by default; no sound for frequent or hover events. A + master mute and quiet hours sit beside at most three category switches, not one switch per event. + +## Counts, words and text +- Counts mean something: "99+ unread" made of weeks-old items is noise; age them out or let the user clear. A badge + counts one kind of thing, the kind that needs the user, and reads the same as the same count on other surfaces. + Never print a raw total of sessions. One numbering scheme per list. +- One word per concept on every surface: the row, the button, the settings card and the error say the same word. +- Option badges and the keys that pick them use the same symbols. +- Values from the data (a state, a kind, an id, a source, an error detail) are mapped to words before they reach a + label or a tooltip; "CI: success" or a raw session id as a title is the data talking, not the app. +- A tooltip or caption says what happens, not which command runs. It is one or two short sentences, never repeats + the visible label, and never holds something the user needs to finish the task. +- Truncated text shows its full text on hover; an error is never truncated. +- A button names its action in one to three words and keeps the verb through the flow; never OK, Yes or No. +- No em dash or en dash in a visible string: use a period, a comma, a colon or parentheses. +- An icon only where a standard glyph exists, in one symbol style and weight; an icon on every row is noise. +- Each state has one colour from one palette, the same on the rail, the inbox and the hub. Colour means state, never + identity. Numbers that change in place use `.monospacedDigit()`. - Labels: sentence case, no ALL-CAPS titles (tiny kickers excepted), no instructions to run commands that do not exist, no hand-edit-this-JSON advice where a control belongs. +- Text the user must read is at least 11 pt and keeps 4.5:1 contrast against its real background. Faint 9 pt text + (white at 40 to 50% on a dark fill) fails both. + +## Settings +- Every setting can be found: a search field once the window has more than a few pages. +- One setting, one place, one name and one range. A quick control elsewhere (a popover) writes the same key with the + same words and the same choices. +- A sub-option is disabled (or hidden) while its parent switch is off or the permission it needs is missing, and + says which. +- Settings pages are flat: a sidebar or tabs with sections, no push navigation. +- A group holds about seven rows and a page a handful of groups; per-event toggles fold into one choice. +- A text field commits on Return and on focus loss, and validates on commit, not on every keystroke. ## Real time and state - New data appears without a reload; the view does not jump when it arrives. @@ -76,5 +338,14 @@ Walk it for every view you change. Each line exists because it was missed at lea - A control that sits inside a scrolling list (a pill, a "Latest" button) is not the reader's own input: it must not trip the list's "the reader is scrolling or clicking" guards. Test it with a real posted click, not a direct call. - Dense rows carry no instant tooltip each; the row's own text says it, and `.help` covers a cut-off path. +- A list of changes shows a file moved outside git (a folder moved by hand) as ONE rename, "old → new", in every view + that lists changes (the sidebar, the hub review, the status bar's "Moved from"), never as a deletion plus a new file. +- "Last update" is one newest-first list across tracked edits and untracked files. Split sections each sorted on their + own hid every new note under a five-hour-old edit (Martin, 2026-10-25). +- A long list of changes or files (long enough to scroll) with more than one section lets the user fold each + section: a chevron on the heading, the outline keys (Left on a file keys its heading, Left folds an open heading, + Right opens a folded one, Return toggles), Expanded/Collapsed for VoiceOver, and the fold survives a restart. +- A panel that follows the open tab keeps its last useful content when the new tab has none (a file outside any + repository), instead of replacing it with "not in a git repository". - A list of hosted projects quietly leaves out what is not hosted (a clone of a local folder, no remote); an error banner is for a project that should work and does not. diff --git a/.claude/skills/ux/references/findings.md b/.claude/skills/ux/references/findings.md index 54640d8af8..848988aad4 100644 --- a/.claude/skills/ux/references/findings.md +++ b/.claude/skills/ux/references/findings.md @@ -3,6 +3,51 @@ Newest first. Each entry: date, symptom as the user saw it, cause, fix, commit. Generic lessons go to checklist.md or motion.md as well. +- 2026-10-25 21:55 — **The git sidebar's Repository list (Martin's report, six points).** Branch feat/2026-10-25-repo-panel. + - *A deleted file said "This file is not in a git repository", and the list then said it too.* git ran in the deleted + file's folder. Fix: the nearest existing ancestor; the list keeps the last repository (6434034912). + - *650 "deletions" were a folder moved by hand.* Fix: exact-content rename pairing shared by the sidebar, the hub and + the CLI, one fixture for both languages (6434034912, 21a9e51cb3). + - *Last update topped out at a note 5 h old.* Two causes: the status never refreshed for other programs' edits, and + untracked files sat in their own section below the tracked edits. Fix: a working-tree watcher (0bed3775f1) and one + merged list. + - *I replaced the tab's content with commits.* Martin wanted the files as before, faster, and commits as their own + tab. Lesson: a speed change keeps what the view shows; ask before moving content between views. + - *"No unstaged changes in this file." flashed every few seconds.* Fix: a reload of the same request keeps its answer + (6906828cab). *The commit sheet's focus ring sat off the row.* Fix: its own ring (96e48537ed). + - *Sections could not fold.* Fix: foldable headings (9717d30a5d). +- 2026-10-23 16:30 — **Widget round 2 and the markdown tab bar (Martin's screenshots 23-28).** + - *Rail bubbles had no hover answer.* `.genHoverPlain` drew a rectangular plate a round bubble hid. Fix: + `WidgetBubbleChrome`, a round ring that pulses once on enter and settles (opacity only). + - *The hover list blinked and opened cut at the top.* One hover asked the panel for 336, 295 and 489 pt within 42 ms + (`edge.request` lines in `~/.genesis-tools/logs/app-perf.log`): the list is rebuilt per hover and its first layout + measured short. Fix: `FittedScroll(initialHeight:)` from the last height, shrinks wait 300 ms, side content pinned + to the top. Lesson: log every resize request with its branch before guessing at a blink. + - *A bubble showed its tooltip instead of the preview.* The panel's hover fires only on crossing its edge; after a + collapse under a resting pointer it never fired again. Fix: a bubble the pointer reaches starts the panel hover; + the bubble tooltip is off while previews are on (`bubbleTooltip`). + - *A filter that emptied the list dropped the open panel's height.* Fix: `holdAgentFit` before a filter change. + - *72 tabs wrapped into nine rows.* Fix: tab groups in GenesisKit (`MarkdownTabGrouping`), inline by default, the + wrapped bar capped at 4 rows with a fade. A first engine split big groups into singletons, so everything landed in + "Other": render the real data (`MARKDOWN_TAB_GROUPS_RENDER`) before calling a grouping rule done. +- 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** Commit 5e0a6e328d. + - *Only the text line of the composer took a click.* The `TextField` was one line inside a padded box. Fix: the + box is the target (`LiveWidgetView.composer`, legacy `AgentWidgetView` too). + - *A second click on a rail session ("4") did nothing.* Every session button called `openInboxNotification` + again. Fix: `WidgetModel.toggleSession` / `toggleInboxOverview` collapse when that view is already open. + - *"35 need you" with four rows.* The preview took `prefix(4)` of the visible sessions. Fix: `previewRows`, every + session that needs you in a `FittedScroll`, an "Active too" filter, three orders, and a settings shortcut to + Projects / Sessions `#inbox-preview`, which pulses. + - *"99+ unread" opened one random session with no name or way to its terminal, and nothing else was reachable.* + The button picked the newest item of the whole inbox. Fix: an inbox overview over every session (the snapshot + now lists up to eight entries per session, `WidgetInboxSession.items`), each entry with its session and "Open in + its session", a session inbox filter (All / Inbox entries / Decisions), and a cmux button on every session. + - *A session opened from Sessions drew its inbox rows over the header.* The pane's one `ScrollView` kept the long + Sessions list's offset. Fix: `.id(section + "|" + selectedKey)`. + - *Sessions with and without sub-agents looked different.* Two row styles in one list. Fix: one top-level row. + - The first cut put the cmux button on every row of a list of hundreds; the swift skill's dense-list rule caught + it before commit (row-only controls, `WidgetHoverRow`). + - 2026-10-15 00:50 — **The vault approval window (GenesisTools.app `vault.unlock`).** - *"This can be probably nicer": one block of text with full paths, the raw command, two UUIDs and five buttons in a row.* It was an `NSAlert`. Now a SwiftUI panel (GenesisKit Permissions/ApprovalDialog.swift): the reason @@ -163,3 +208,98 @@ checklist.md or motion.md as well. user saw no feedback at all. Fix: the tree watches its root (FSEvents) and a reveal re-reads a folder that lacks the file; the reveal jumps a far row to the nearer edge, glides the last stretch and pulses the row. Before and after recordings in the notes vault's `GenesisTools.native/MarkdownViewer/Screenshots/2026-10-15-0618-*`. +- 2026-10-23 — **One press on a new tab group chip made the whole Genesis Markdown window ignore clicks.** The chip + read `NSApp.currentEvent?.clickCount`, which raises for a non-mouse event; the exception left SwiftUI's event + dispatch broken. I had checked the groups only as a rendered PNG and never pressed a chip in the real app, and my + synthesized test clicks always made a mouse event current, so no test could see it. Fix: GenesisKit `MouseClick`, + lint-rules `swift-current-event-click-count`, an exact-click test through `FrameProbe`, and the checklist line + "a render is not a click". My first test also swept clicks across the toolbar and opened Obsidian and the Editor in + Martin's apps; targets now come from `FrameProbe`. +- 2026-10-23 — **The alert flashed only the rail bubble, and the rail and the hover list disagreed on order.** The hover + list opened after the alert bumped the flash counter, so its rows never saw the change; the rail kept a sticky + order while the list sorted by attention. Fix: `AttentionPulse(since:)` plays the rest of a running flash on + appear; the rail follows the list order, with "Keep places" as the setting for the old one. +- 2026-10-23 — **The widget spun at 100% CPU for three minutes and nothing logged it.** A SwiftUI update loop + returns to the run loop between passes, so HangWatch's ping never ran late. HangWatch now also reads the main + thread's CPU time: 5 s above 85% logs `main-spin`, samples the stack and counts the bodies that ran (the dev + monitor shows it as `spin`). Strongest lead, fixed in the same commit: every preview open lost a resize and leaked + a display link that fired every frame for good (sampled live in the collapsed widget); not proven to be that spin, + because three hidden-window replays with the real inbox stayed idle. +- 2026-10-23 — **A hint line that appears under the focused row moves everything below it.** The Claude Code mods Git + sidebar drew a button's description under its own row while the focus ring was on it, so every Tab shifted the + rest of the pane. Fix (170c6ee191): one status bar with two fixed rows, pinned to the pane's foot; the body is built + from one-row lines, so the plugin counts them and pins the bar only when they fit. Check: read the pane's text + after each Tab and compare every line above the bar with the first read; nothing may differ but a live time. +- 2026-10-23 — **A slow call started from a render died when the next render came.** The engine aborts a replaced + render hook's in-flight process, and the plugin remembered the aborted `tools genesis-tools probe` as "no link + opens here" for 30 minutes, in every session: no hub button, no links. Fix (170c6ee191): start such work from the + session start or a timer, let renders only read it, never keep an aborted result; a test with the real abort text. +- 2026-10-23 — **A full row squeezed one of its chips to nothing.** The cache band's flex row shrank the own chip to + zero width when other sessions' chips filled it. Fix: every chip keeps its width and the row clips at its edge. +- 2026-10-23 21:13 — **Save draft chimed, flashed the session and showed nothing.** The widget treats a newer inbox item time + as a new arrival, and the server stamped decisions with their last update, so the user's own draft looked like a + new decision. Five saves in two minutes, each with a chime (decision log events 17:58 to 18:00). Fix: a decision's + item time is when it was asked (`askedTimeOf`); Save draft stages the note and the card says "Draft saved". +- 2026-10-23 21:13 — **An answer click reordered and collapsed the list under the pointer.** Every new outgoing message + scrolled the list to the bottom three times, also an answer sent from a card in the middle of the list. Only a + composer message follows now (`followsOutgoing`). Rule: a list moves only when the user's own new content lands + where the list scrolls to. +- 2026-10-23 21:13 — **A click that sends at once loses the user's chance to batch and correct.** Decision options now stage + (the decision log's `drafted` state), the inbox says how many are staged, a review shows "3 decisions will be sent + to 2 sessions", and closing with staged decisions asks Send now / Keep staged / Discard. Settings: `decisionClick`, + `stagedOnLeave`. +- 2026-10-23 23:11 — **The second recording of a session opened an invisible picker.** Every test and render made a + fresh picker panel, so all passed, while the live widget reused one: after its first dismissal the panel came back at + 0x0, the recorder waited for a choice nobody could see, and the video button stuck on Stop. Found only by running the + flow twice in the live app. Fix: a fresh hosting view per opening (`RecordingPickerPanelTests` opens it three times). + Rule: a reusable panel gets a test that opens, dismisses and opens it again. +- 2026-10-24 15:12 — **A recording stuck at "Starting" with no border, and Stop did nothing.** The first recording after a + change to the native recorder's sources compiled it for 20 to 60 s. The controls showed only a spinner, no border + was drawn (the recorder draws it once it runs), and the session CLI built synchronously, so its stdin reader was + blocked and Stop was read only after the compile. Worse, opening the picker had started that compile through the + target list, and the app's 20 s timeout killed it midway, so the recording compiled again from the start. Fix: an + async build in its own process group (a cancelled or killed caller leaves it running, the next one reuses it), a + `preparing` event that the controls show as "Preparing" with the reason, a dashed outline around the target from the + first moment, Discard answered in under a second. Rule: a start that can be slow says why, stays cancellable, and + shows what was chosen at once; never test a native flow only on a warm build (plant a one-line source change). +- 2026-10-24 15:51 — **"2 decisions were sent to 1 session" when nothing arrived.** The session ran in two Claude + processes (one resumed twice in cmux); delivery refused to pick a receiver and queued the answers, and the widget + counted every non-failure as sent. Fix: the receiver whose registry entry moved last, by 10 minutes or more, gets + the answers; a send no receiver took says "Not delivered to <session>: <reason>" with a Send again button, for + every kind of agent (`resend-answers`). Rule: a send reports what the receiver confirmed, never what was attempted, + and every not-delivered result offers the retry in place. +- 2026-10-24 18:09 — **The hover list closed under a scrolling pointer, and the scroll went to the window below.** + Under the Glass theme the gaps between session rows were not part of the panel: SwiftUI does not hit-test Liquid + Glass itself, so with the pointer still, each gap that scrolled under it read as "the pointer left", and 220 ms later + the preview closed. Solid themes never showed it (their fill is hit-tested). A window-server probe + (`SLSFindWindowAndOwner`) showed the window did own those points, which ruled out click-through; synthetic taps in a + scratch window then showed taps on the gap reaching nothing. Fix: the glass surfaces take `.contentShape` of their + shape (`NativeGlassShapeSurface`, `NativeGlassControl`, `WidgetGlassControl`). Rule: a glass panel is tested with a + click between its rows, not only on them. +- 2026-10-24 18:09 — **A session was "missing" from the list because it showed under its first prompt.** Claude Code + names a session itself (`ai-title` lines, the name its /resume list and the terminal tab show); the history reader + knew only `custom-title` and the old `summary`, so the row read "pls /genesis-tools:research with xhigh…" while + Martin looked for "Kalendora hotfix research". Fix: `ai-title` is the summary (a `/rename` still wins), parser + version 6. Rule: a row's title is the name the user sees for that thing elsewhere. +- 2026-10-24 18:09 — **"Loading this session's inbox…" on every switch, and the pane shrank and grew back.** The cards + came only with a snapshot naming the session: a `hub widget call` process (330 ms), the watch's 180 ms debounce, the + snapshot (65 to 105 ms). Fix: every snapshot carries the cards of up to 16 sessions that need you (`warmKeys`), the + widget caches each session it saw (`WidgetSessionCache`), the pick goes to the watch's stdin at once, the pane keeps + its height while a never-seen session loads, and nearby messages show the last read while a fresh one runs. Rule: a + switch shows what is known at once and lets the fresh data replace it. +- 2026-10-24 19:43 — **Correction to the 18:09 hover-list entry: the glass gaps were not the cause.** Inside a ScrollView the gaps + between rows are hit-tested under plain glass too (probe: row and gap reach the panel; only a blank part outside the + scroll view does not), so the contentShape fix never touched this bug, and the list kept closing (19:01:00 to + 19:01:38, three times while Martin scrolled). What closes it is SwiftUI's `.onHover` reporting "left" with the + pointer still on the panel. Fix: the panel's window decides "pointer inside" by geometry (an NSTrackingArea on + `EdgePanelRootView`), and SwiftUI's hover only logs `widget.hover swiftui-left … under it: <view chain>` when it + disagrees. Rule: a window-level "pointer inside" comes from the window's geometry, never from a hit test, and a fix + is not done until the user's own repro stops. +- 2026-10-24 19:43 — **A bug that is a burst of processes was invisible to every watcher.** Genesis Markdown started one + `tools agents blame` per open note on every repository refresh: 51 to 66 at once, about 9,300 an hour, load average + 380-580 and launchservicesd at 69-159%, while its log held a blame line only when one run was slow. Fix: one blame per + repository for every note that asked (`AgentBlameQueue`), and GenesisKit's `SpawnMeter` writes `spawn.storm` to the + perf log when 40 children start within 10 s or 16 run at once; `tools hub dev monitor` reports it and now also + reads Genesis Markdown's `~/.genesis/logs/perf.log`. Rule: work that starts processes per item is queued per shared + resource, and the launcher counts what it starts. +- 2026-10-24 20:29 — **Second correction: the hover list's real cause was alpha passthrough, not SwiftUI's hover.** The 19:43 tracking-area fix still flapped (open and close about once a second, 20:25 to 20:27), because the edge panel's clear window gave its alpha-0 pixels to the window below while the list laid out. `ignoresMouseEvents = false` on the panel (3e80832422) fixed it; the list then stayed open under a resting pointer. Rule: a clear window that must keep the pointer sets `ignoresMouseEvents = false` explicitly; check that the window hides with `orderOut`, so no invisible window keeps taking clicks. diff --git a/.claude/skills/ux/references/live-testing.md b/.claude/skills/ux/references/live-testing.md index ff823a3a43..51fac49dfb 100644 --- a/.claude/skills/ux/references/live-testing.md +++ b/.claude/skills/ux/references/live-testing.md @@ -43,3 +43,9 @@ press and action log, or with the window key while the user agrees to give up focus. - A window whose text changes live (a "saved 1 min ago" status) makes `tools control act` refuse as stale. Pass the row's `--target-key <stableKey> --revalidate-scope element` from the same `see`. +- `tools control click --app <app> --desc <label> --depth 40` presses one named control without taking focus (Martin, + 2026-10-23: "you can control click it wont steal my focus"). Press by label, one control at a time. Never sweep + a grid of coordinates over a real window: a sweep hits the side-effect buttons (Obsidian, Editor) and opens dialogs + and files in the user's apps. +- When a whole window stops reacting, first read its unified log for an exception (`command log show --predicate + 'processID == <pid>' --last 10m | rg -i "exception|raise"`), then theorise. diff --git a/.claude/skills/ux/references/sources.md b/.claude/skills/ux/references/sources.md new file mode 100644 index 0000000000..bb97900590 --- /dev/null +++ b/.claude/skills/ux/references/sources.md @@ -0,0 +1,59 @@ +# Where the rules came from (distilled 2026-10-24) + +On 2026-10-24 about 110 agent skills about UX, macOS design, motion, feedback, tooltips and accessibility were +downloaded and read as data (never installed, never run). Their rules were merged (942 extracted, 226 after merging), +judged against this skill, and the gaps were added to [checklist.md](checklist.md) and [audit.md](audit.md). A rule +was rejected when it conflicts with a repo rule (`.claude/rules/opinionated-settings.md`, `.claude/rules/macos-app.md`), +when it is web-only or iOS-only, or when the skills contradict each other without evidence. The research notes, with +every quote, its file and line, and the hit counts in `src/macos`, are in the notes vault under +`GenesisTools/2026-10-24-UxSkillChanges/` (`Research-UxSkillGaps-MacOSMotion`, `Research-UxSkillGaps-FeedbackA11y`, +and the four `Research-Rules*` notes). + +## The skills that gave most rules + +Counted as the strongest source of a gap or partial rule in the two gap notes. + +- **educlopez/ui-craft** (ui-craft, dense dashboard): feedback on press, failures on the row that failed, focus + return, disabled reasons, empty states, density. +- **seansmithworks/impeccable-swift**: Swift-specific states (busy, disabled, empty), spinners, wording, motion. +- **ehmo/platform-design-skills@macos**: numbered macOS rules for keyboard (Esc, Return, every mouse action by key), + alerts, popovers, windows, VoiceOver order, Increase Contrast, hit sizes. +- **rgmez/apple-accessibility-skills@appkit-accessibility-auditor**: announcements, accessibility actions for + hover-only controls, labels, roles and values. +- **wondelai/skills** (ux-heuristics, microinteractions, refactoring-ui, steve-jobs-design-review): error text, + note timing and escalation, contrast, one primary action. +- **AThevon/genjutsu** (desktop-principles, swiftui-motion): hover timing, target size, bare-letter shortcuts, + scale from 0, drags that swallow taps. +- **dembrandt/dembrandt-skills** (operational-expert-tool-ui, information-architecture, notifications-and-recovery, + loading-states, coordinated-data-views, status-colors-and-errors, ui-density): one word per concept, one name per + place, state colours, recovery, loading. +- **yetone/native-feel-skill**: pressed states, waits that end within 10 s, hover that does not steal the keyboard. +- **charleswiltgen/axiom@axiom-macos**: sibling overlays that swallow clicks, key view loops, selection in non-key + windows, modals that block quit. +- **aviralj02/interface-skills@reduced-motion**, **owl-listener/inclusive-design-skills** (motion-sensitivity, + feedback-and-status): Reduce Motion replacements, flash limits. +- Tooltips: **uxcel-lab/product-skills** (ux-tooltips, notifications-and-toasts), + **thedaviddias/ux-patterns-for-developers@tooltip**, **dragoon0x/everything-design-taste@tooltip-popover-patterns**. +- Smaller sources: tartinerlabs/skills@ux-psychology, chirpyworks/decision-ui, ceorkm/macos-design-skill, + Dimillian/skills (swiftui-ui-patterns), macos-patterns, mastepanoski/claude-skills@ui-design-review, + rhyssullivan/skills@no-ui-flash, millionco/skills@interactive-hit-areas, gbasin/dejank, mxvsh/skills@macos-swiftui, + aladicf/better-web-ui@empty-state, rshankras/claude-code-apple-skills@settings-screen, + fayazara/macos-app-skills@settings-ui. + +## Conflicts and how this skill settles them + +- Hover scale: some skills forbid any scale, others allow up to 1.05. Here: a layout-neutral scale is allowed (the + shared hover styles use 1.04 and 0.97 pressed). +- Spinner delay: 200 ms, 300 ms or 300 to 500 ms. Here: no spinner under about 200 ms. +- Reduce Motion and spinners: a progress indicator keeps running; decorative loops stop. +- Hover reveal: some skills forbid it. Here: allowed, because the app builds hover previews and row-only controls on + purpose, as long as every hover-only action also has a keyboard and VoiceOver route. +- "Prefer a default over a setting" (steve-jobs-design-review): rejected; the repo rule is that an opinionated + behaviour ships as a setting. Fewer switches come from folding per-event toggles into one choice. +- Tooltip delay of one second: rejected; the app's instant tooltips are a deliberate choice. + +## Not used + +- uwussimo/ux-laws: never installed or run. Its installer overwrites itself, runs `rm -f`, and stars a repository on + the user's account. +- Web-only rules (CSS, ARIA, Web Audio numbers) and iOS touch sizes (44 pt). diff --git a/.claude/skills/ux/scripts/scan-engine.ts b/.claude/skills/ux/scripts/scan-engine.ts new file mode 100644 index 0000000000..ca9b561f61 --- /dev/null +++ b/.claude/skills/ux/scripts/scan-engine.ts @@ -0,0 +1,183 @@ +/** + * The engine behind the skills' code scanners (ux/scripts/ux-scan.ts, swift/scripts/swift-scan.ts): it runs a list of + * line checks over Swift files and prints the candidate lines grouped by pass, then facts over the whole set. A line + * is a candidate, not a finding. It only reads files. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +export interface Check { + pass: string; + name: string; + pattern: RegExp; + hint: string; + /** Drop a hit when this matches within `near` lines of it (the case is already handled there). */ + unless?: RegExp; + near?: number; + /** Check only files whose text matches (for example view files, or files on the main actor). */ + onlyIn?: RegExp; + /** A broad check: print the files ranked by hit count instead of every line. */ + byFile?: boolean; +} + +/** A count over every scanned line; zero is reported with what it means. */ +export interface CountFact { + name: string; + pattern: RegExp; + zeroMeans: string; +} + +/** A per-file fact: a label when the file qualifies, otherwise undefined. */ +export interface FileFact { + name: string; + test: (text: string, lines: string[]) => string | undefined; +} + +export interface ScanSet { + title: string; + checks: Check[]; + counts: CountFact[]; + fileFacts: FileFact[]; +} + +interface Hit { + file: string; + line: number; + text: string; +} + +export function swiftFiles(target: string, out: string[]): void { + const info = statSync(target); + if (info.isFile()) { + if (target.endsWith(".swift")) { + out.push(target); + } + + return; + } + + for (const entry of readdirSync(target)) { + if (entry === ".build" || entry === "Tests" || entry.startsWith(".")) { + continue; + } + + swiftFiles(join(target, entry), out); + } +} + +export function scan(set: ScanSet, files: string[], max: number): string { + const hits = new Map<Check, Hit[]>(); + const counts = new Map<string, number>(); + const fileFacts = new Map<string, string[]>(); + const cwd = process.cwd(); + + for (const file of files) { + const text = readFileSync(file, "utf8"); + const lines = text.split("\n"); + const shown = relative(cwd, file); + + for (const check of set.checks) { + if (check.onlyIn && !check.onlyIn.test(text)) { + continue; + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trimStart().startsWith("//") || !check.pattern.test(line)) { + continue; + } + + if (check.unless) { + const near = check.near ?? 0; + const window = lines.slice(Math.max(0, i - near), i + near + 1).join("\n"); + if (check.unless.test(window)) { + continue; + } + } + + const list = hits.get(check) ?? []; + list.push({ file: shown, line: i + 1, text: line.trim().slice(0, 140) }); + hits.set(check, list); + } + } + + for (const fact of set.counts) { + const count = lines.filter((l) => fact.pattern.test(l)).length; + counts.set(fact.name, (counts.get(fact.name) ?? 0) + count); + } + + for (const fact of set.fileFacts) { + const label = fact.test(text, lines); + if (label !== undefined) { + const list = fileFacts.get(fact.name) ?? []; + list.push(label ? `${shown} (${label})` : shown); + fileFacts.set(fact.name, list); + } + } + } + + const out: string[] = [`# ${set.title}: ${files.length} Swift files (candidates, not findings)`, ""]; + let pass = ""; + for (const check of set.checks) { + const list = hits.get(check) ?? []; + if (check.pass !== pass) { + pass = check.pass; + out.push(`## ${pass}`); + } + + out.push(`- ${check.name} [${list.length}]: ${check.hint}`); + if (check.byFile) { + const perFile = new Map<string, number[]>(); + for (const hit of list) { + perFile.set(hit.file, [...(perFile.get(hit.file) ?? []), hit.line]); + } + + const ranked = [...perFile].sort((a, b) => b[1].length - a[1].length); + for (const [file, lines] of ranked.slice(0, max)) { + out.push( + ` ${file} (${lines.length}): lines ${lines.slice(0, 12).join(", ")}${lines.length > 12 ? ", …" : ""}` + ); + } + + continue; + } + + for (const hit of list.slice(0, max)) { + out.push(` ${hit.file}:${hit.line} ${hit.text}`); + } + + if (list.length > max) { + out.push(` … ${list.length - max} more (raise --max)`); + } + } + + out.push("", "## Facts over these files"); + for (const fact of set.counts) { + const count = counts.get(fact.name) ?? 0; + out.push(`- ${fact.name}: ${count}${count === 0 ? ` (0 means: ${fact.zeroMeans})` : ""}`); + } + + for (const fact of set.fileFacts) { + out.push(`- ${fact.name}: ${(fileFacts.get(fact.name) ?? []).join(", ") || "none"}`); + } + + return out.join("\n"); +} + +/** Parses `<file or folder>... [--max <n>]`, scans, prints. */ +export function runCli(set: ScanSet, argv: string[], usage: string): void { + const maxIndex = argv.indexOf("--max"); + const max = maxIndex >= 0 ? Number(argv[maxIndex + 1]) : 60; + const targets = maxIndex >= 0 ? argv.filter((_, i) => i !== maxIndex && i !== maxIndex + 1) : argv; + if (targets.length === 0) { + console.error(usage); + process.exit(1); + } + + const files: string[] = []; + for (const target of targets) { + swiftFiles(target, files); + } + + console.log(scan(set, files, Number.isFinite(max) && max > 0 ? max : 60)); +} diff --git a/.claude/skills/ux/scripts/ux-scan.ts b/.claude/skills/ux/scripts/ux-scan.ts new file mode 100644 index 0000000000..6da36ea1bb --- /dev/null +++ b/.claude/skills/ux/scripts/ux-scan.ts @@ -0,0 +1,347 @@ +#!/usr/bin/env bun +/** + * Lists candidate UX defects in Swift files, grouped by the passes of references/audit.md. A line here is a + * candidate, not a finding: read it in context and check the shared components (audit.md section 0) before you + * report it. The scan only reads files. + * + * Usage: bun .claude/skills/ux/scripts/ux-scan.ts <file or folder>... [--max <hits per check, default 60>] + */ +import { type Check, type CountFact, runCli, type ScanSet } from "./scan-engine.ts"; + +const CHECKS: Check[] = [ + // Pointer and targets + { + pass: "Pointer and targets", + name: "bare-button", + pattern: /\.buttonStyle\(\.(plain|borderless)\)|\.menuStyle\(\.borderlessButton\)/, + hint: "no hover and no hand cursor, unless the label draws its own (open it)", + }, + { + pass: "Pointer and targets", + name: "menu", + pattern: /\bMenu\s*\{|\bMenu\(/, + hint: "a SwiftUI Menu has no shared hover style; MenuButton or a .genHover style gives one", + unless: /genHover|MenuButton/, + near: 8, + }, + { + pass: "Pointer and targets", + name: "tap-gesture", + pattern: /\.onTapGesture(?!\(count: 2)/, + hint: "acts on tap: needs hover, a hand cursor, a key path and an accessibility action", + }, + { + pass: "Pointer and targets", + name: "small-frame", + pattern: /\.frame\(width:\s*(1\d|\d)(\.\d+)?,\s*height:\s*\d/, + hint: "a target under 20 pt wide if it is (or wraps) a control; 22 pt is the floor", + }, + { + pass: "Pointer and targets", + name: "small-glyph-button", + pattern: /\.font\(\.system\(size:\s*(9|10|11|12)(\.\d+)?\)\)/, + hint: "a small glyph that is a button: is its hit area padded to 22 pt?", + unless: /IconButton|frame\(width:\s*(2\d|[3-9]\d)/, + near: 3, + }, + { + pass: "Pointer and targets", + name: "drop-target", + pattern: /\.onDrop\(|\.dropDestination\(/, + hint: "does it highlight while a drag is over it (isTargeted)?", + unless: /isTargeted|targeted/, + near: 4, + }, + // Keyboard + { + pass: "Keyboard", + name: "return-default", + pattern: /keyboardShortcut\(\.defaultAction\)|keyboardShortcut\(\.return|keyEquivalent = "\\r"/, + hint: "Return runs this: is it the safe, reversible choice (never Send, Delete, Approve, Discard)?", + }, + { + pass: "Keyboard", + name: "bare-letter-key", + pattern: /onKeyPress\(characters:|keyboardShortcut\("[a-z0-9]"\)/, + hint: "a shortcut with no modifier: does it fire while a text field has focus?", + }, + { + pass: "Keyboard", + name: "key-handler", + pattern: /onKeyPress|onExitCommand|onMoveCommand|cancelAction/, + hint: "where keys are handled: on a view that keeps focus, or only on the root or one field?", + }, + { + pass: "Keyboard", + name: "context-menu", + pattern: /\.contextMenu/, + hint: "is each action here also a visible control or a key?", + }, + { + pass: "Keyboard", + name: "text-field", + pattern: /\bTextField\(|\bTextEditor\(/, + hint: "focused on open? Esc reverts? a search field has a clear button? the whole box takes the click?", + }, + // VoiceOver + { + pass: "VoiceOver", + name: "hover-only", + pattern: /if\s+(isHovering|hovering|isHovered|hovered|hover|live|isHot|hot)\b/, + hint: "shown only on hover: needs an .accessibilityAction(named:) or a context-menu item", + }, + { + pass: "VoiceOver", + name: "tooltip-only-meaning", + pattern: /\.(instantTooltip|help)\(/, + hint: "a tooltip sets no accessibility label: is this an icon, badge or count with no label?", + unless: /accessibilityLabel|IconButton|Text\(/, + near: 4, + }, + { + pass: "VoiceOver", + name: "ignored-children", + pattern: /accessibilityElement\(children: \.ignore\)/, + hint: "does it re-add every action and state of the children it hides?", + }, + { + pass: "VoiceOver", + name: "selection-look", + pattern: /(isSelected|selected)\s*\?\s*"(checkmark|circle)|isSelected \?|selected \?/, + hint: "is the selection exposed (.accessibilityAddTraits(.isSelected) or a value), not only drawn?", + unless: /isSelected\)|accessibilityValue|accessibilityAddTraits/, + near: 8, + }, + // Feedback and errors + { + pass: "Feedback and errors", + name: "raw-error-text", + pattern: + /Text\([^)]*\b(error|stderr|detail|failure|reason)\b|localizedDescription|stderr|\.suffix\(\d{3}\)|"\\\((error|failure)\)"/, + hint: "machine words on screen? one sentence plus Retry, raw text behind Details", + }, + { + pass: "Feedback and errors", + name: "tone-from-words", + pattern: /\.contains\("(exited|failed|error|Error|denied)"\)/, + hint: "a colour or tone guessed from the text: store the tone with the message", + }, + { + pass: "Feedback and errors", + name: "long-timeout", + pattern: /(timeout|Timeout|deadline)\w*\s*[:=]\s*(1[1-9]|[2-9]\d|\d{3,})(\.\d+)?\b/, + hint: "a wait over 10 s: does the user see a reason or a Cancel before it ends?", + }, + { + pass: "Feedback and errors", + name: "collapse-after-action", + pattern: /;\s*collapse\(\)|^\s*collapse\(\)\s*$/, + hint: "the panel closes right after an action: does a failure still reach the user?", + }, + // Undo and destructive + { + pass: "Undo and destructive actions", + name: "destructive", + pattern: /role:\s*\.destructive|"(Discard|Delete|Remove|Dismiss|Clear|Forget)[^"]*"/, + hint: "Undo, or a confirm that names the object? never on Return", + }, + { + pass: "Undo and destructive actions", + name: "loss-verbs", + pattern: /"[^"]*\b(discard|drop|delete|remove|trash|throw away)\b[^"]*"/i, + hint: "list of loss verbs: one action should have one verb everywhere", + }, + // Empty and loading + { + pass: "Empty and loading states", + name: "empty-text", + pattern: /"(No |Nothing|Empty\b|None yet)[^"]*"|"[^"]*(unavailable|not available)[^"]*"/, + hint: "an empty or unknown state: says why, offers a button, not shown while still loading?", + }, + { + pass: "Empty and loading states", + name: "cli-advice", + pattern: /"[^"]*(\bRun |\brun |`)tools [a-z][^"]*"/, + hint: "tells the user to type a command: a button belongs here", + }, + { + pass: "Empty and loading states", + name: "progress", + pattern: /ProgressView\(|SkeletonRows|\.redacted\(/, + hint: "shown at once for short work? does every loading state end in content, empty or an error?", + }, + // Motion + { + pass: "Motion and Reduce Motion", + name: "reduce-motion-false", + pattern: /reduceMotion:\s*false/, + hint: "a hard-coded false where the Reduce Motion state belongs", + }, + { + pass: "Motion and Reduce Motion", + name: "literal-animation", + pattern: + /withAnimation\(\.(easeInOut|easeOut|easeIn|spring|linear|smooth|snappy|bouncy)|\.animation\(\.(easeInOut|easeOut|easeIn|spring|linear|smooth|snappy|bouncy)|static let animation: Animation/, + hint: "a literal curve: does this path check Reduce Motion?", + unless: /reduceMotion|ReduceMotion|GenMotion/, + near: 3, + }, + { + pass: "Motion and Reduce Motion", + name: "loop-or-window-motion", + pattern: /repeatForever|CABasicAnimation|NSAnimationContext|\.animator\(\)|setFrameOrigin\(/, + hint: "a loop or an AppKit move: stops when hidden, still under Reduce Motion?", + }, + // Attention + { + pass: "Things that appear by themselves", + name: "literal-delay", + pattern: + /asyncAfter\(deadline: \.now\(\) \+ \d|Task\.sleep\(for: \.seconds\(\d|seconds: Double = \d|Timer\.scheduledTimer\(withTimeInterval: \d/, + hint: "a duration the user notices, as a literal: a setting? a popup or note that cannot be closed early?", + }, + { + pass: "Things that appear by themselves", + name: "sound-or-front", + pattern: /NSSound|AudioServicesPlaySystemSound|orderFrontRegardless|activate\(ignoringOtherApps/, + hint: "a sound or a window that comes forward by itself: a setting, a cause, a way to close it", + }, + // Counts and words + { + pass: "Counts, words and text", + name: "summed-count", + pattern: /\b\w*(unread|count|Count|needs\w*|total)\w*\)?\s*\+\s*(max\()?\w/, + hint: "a badge that adds two kinds of thing: does it count only what needs the user?", + }, + { + pass: "Counts, words and text", + name: "all-caps", + pattern: /(Text|label|caption|title)\("[A-Z][A-Z :·-]{4,}"|"[^"]*\b[A-Z]{5,}\b[^"]*"/, + hint: "ALL-CAPS text: sentence case, unless a tiny kicker", + unless: /JSON|HTTP|UUID|ASCII|HTML|CSV|PDF|URL/, + near: 0, + }, + { + pass: "Counts, words and text", + name: "raw-value-in-text", + pattern: + /\.capitalized\b|\.rawValue\b|"[^"]*\\\((\w+\.)*(id|Id|ID|state|status|kind|sourceId|sessionId|ci|detail|busy|reason|error|message)\)[^"]*"/, + hint: "a value from the data shown as words: map it to a sentence", + }, + { + pass: "Counts, words and text", + name: "tooltip-names-command", + pattern: /(instantTooltip|help)\("[^"]*\btools [a-z]/, + hint: "the tooltip says which command runs, not what happens", + }, + { + pass: "Counts, words and text", + name: "dash-in-text", + pattern: /"[^"]*[—–][^"]*"/, + hint: "an em or en dash in a visible string", + }, + { + pass: "Counts, words and text", + name: "small-or-faint-text", + pattern: + /\.font\(\.system\(size:\s*(7|8|8\.5|9|9\.5|10|10\.5)\b|foregroundStyle\([^)]*opacity\(0\.[2-5]\d?\)|\.foregroundStyle\(\.tertiary\)/, + hint: "text under 11 pt or faint: is it something the user must read (4.5:1)?", + }, + { + pass: "Counts, words and text", + name: "letter-badges", + pattern: /Text\(verbatim: \w+\.id\)|"key \\\(|index \+ 1/, + hint: "badges and keys: does the badge show the same symbol the key uses?", + }, + // Layout + { + pass: "Layout", + name: "fixed-height-label", + pattern: /\.frame\(height:\s*\d+\)/, + hint: "text in a fixed height: a line limit, or does a long title wrap or clip?", + unless: /lineLimit/, + near: 4, + }, + { + pass: "Layout", + name: "native-segmented", + pattern: /pickerStyle\(\.segmented\)/, + hint: "the kit has GenSegmentedTabs; a native one greys out in a non-key window", + }, + { + pass: "Layout", + name: "stepper-or-choice", + pattern: /\bStepper\(|cadenceChoices|Picker\(/, + hint: "is the same setting also edited elsewhere with other words or another range?", + }, + // Windows and settings + { + pass: "Windows and settings", + name: "window-flags", + pattern: + /isMovableByWindowBackground = true|NSScreen\.main\b|runModal\(\)|setActivationPolicy|hidesOnDeactivate = true/, + hint: "window rules: no background drag with SwiftUI, no NSScreen.main placement, modals that block quit", + }, + { + pass: "Windows and settings", + name: "refresh-on-appear", + pattern: /\.onAppear\s*\{[^}]*(refresh|reload)\(/, + hint: "read once on appear: is it read again when the app becomes active (didBecomeActive)?", + }, +]; + +/** Facts over the whole scanned set, printed after the passes. */ +const FACTS: CountFact[] = [ + { + name: "VoiceOver announcements", + pattern: /announcementRequested|AccessibilityNotification|NSAccessibility\.post/, + zeroMeans: "nothing that changes by itself is announced", + }, + { + name: "Undo support", + pattern: /undoManager|UndoManager|"Undo"|noticeAction/, + zeroMeans: "no destructive action here can be undone", + }, + { + name: "re-read on activation", + pattern: /didBecomeActiveNotification|didBecomeKeyNotification/, + zeroMeans: "nothing re-reads outside state when the user comes back", + }, + { + name: "arrow-key navigation", + pattern: /\.upArrow|\.downArrow|onMoveCommand|moveUp|moveDown/, + zeroMeans: "no list here moves with Up and Down", + }, + { + name: "search in settings", + pattern: /searchable|SearchField|"Search/, + zeroMeans: "no search field", + }, +]; + +const UX_SET: ScanSet = { + title: "UX scan", + checks: CHECKS, + counts: FACTS, + fileFacts: [ + { + name: "files that animate and never read Reduce Motion", + test: (text) => + /withAnimation\(|\.animation\(|repeatForever|NSAnimationContext|CABasicAnimation/.test(text) && + !/reduceMotion|ReduceMotion|GenMotion|ShouldReduceMotion/.test(text) + ? "" + : undefined, + }, + { + name: "files with more than one reload button", + test: (_text, lines) => { + const reloads = lines.filter((l) => l.includes('"arrow.clockwise"')).length; + return reloads > 1 ? String(reloads) : undefined; + }, + }, + ], +}; + +if (import.meta.main) { + runCli(UX_SET, process.argv.slice(2), "usage: bun ux-scan.ts <file or folder>... [--max <n>]"); +} diff --git a/CLAUDE.md b/CLAUDE.md index 86a98a8be1..f81a868553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,11 @@ The detailed rules live in area files. Claude Code loads an area file by itself - TypeScript under `src/` or `scripts/`: `.claude/rules/tools.md`. Tool structure and CLI patterns, the shared utilities in `src/utils/`, logging and `out`, spawning a tool or calling its library, ports, databases. - Tests (`*.test.ts`), and before you add a test file: `.claude/rules/tests.md`. - AI, accounts, secrets, cost, agent history (`src/utils/ai/`, `src/utils/security/`, `src/utils/agent-sessions/`, `src/ai-proxy/`, `src/ask/`, `src/claude/`, `src/codex/`): `.claude/rules/ai.md`. +- Reading a Claude, Codex or Grok transcript (hooks, the decision log, the hub, cmux, `src/claude`, `src/codex`, plugins): `.claude/rules/agent-transcripts.md`. Every transcript fact is a required `NativeSessionReader` member that all three providers implement. - Models and pricing (the catalog, the proxy ledger, Claude request rules, Codex and Grok model lists, the handoff-to rankings): `.claude/rules/models.md`. Where each model fact lives, and the checklist for a new model. - Web UI (`*.tsx`, `src/*/ui/`, `src/dashboard/`, `src/dev-dashboard/`): `.claude/rules/web-ui.md`. +- A link to genesis.tools, Genesis Markdown or the hub, and plugin code that runs `tools` (`src/**`, `plugins/**`): `.claude/rules/genesis-links.md`. Such a link opens nothing on a Mac without the app, the router and its route; check with `tools genesis-tools probe` (or `routerLink()` / the mods plugin's `genesis.ts`) and write the fallback. +- Any UI, native or web, the widget schema and notifications: `.claude/rules/opinionated-settings.md`. A behavior a reasonable user could want another way (a sound, a popup, a flash, a placement, a hover tooltip, a timing) ships as a setting with your pick as its default. - macOS app, notifications, privacy grants (`src/macos/`, `src/utils/macos/`, `src/notify/`): `.claude/rules/macos.md`. Window rules for the Swift app (`src/macos/GenesisTools/`): `.claude/rules/macos-app.md`. - The personal-data probe and the git hooks: `.claude/rules/placeholder-check.md`. - Browser automation (`src/chrome-devtools/`, `src/utils/chrome-devtools/`, which another repo vendors): `.claude/rules/chrome-devtools.md`. The plugin (`plugins/`, `.claude-plugin/`): `plugins.md`. CI, guards and git hooks (`.github/`, `scripts/ci/`, `.githooks/`): `ci.md`. Dependencies (`package.json`, `bun.lock`): `dependencies.md`. diff --git a/bun.lock b/bun.lock index b175e60f21..94fb344795 100644 --- a/bun.lock +++ b/bun.lock @@ -133,7 +133,6 @@ "sonner": "^2.0.8", "sqlite-vec": "^0.1.9", "streamx": "^2.28.1", - "stripe": "^22.6.0", "tar-stream": "^3.2.1", "telegram": "^2.26.22", "tough-cookie": "^6.0.2", @@ -2703,8 +2702,6 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - "stripe": ["stripe@22.6.0", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Ezk+WYEEwX2DLxFrKfb6coznlCMZvCaCYMNPXNHFSZkBIEzF5Hgm7RLb38q/0h9Ol5jsUJGJRorvhzAvckgfhw=="], - "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], "subsume": ["subsume@4.0.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "unique-string": "^3.0.0" } }, "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg=="], diff --git a/native/ax-tool/SnapshotSupport/ForegroundGuard.swift b/native/ax-tool/SnapshotSupport/ForegroundGuard.swift new file mode 100644 index 0000000000..efac8e93d8 --- /dev/null +++ b/native/ax-tool/SnapshotSupport/ForegroundGuard.swift @@ -0,0 +1,72 @@ +import Foundation + +/// The quiet time, in seconds, an agent waits for before it may bring an app to the front or move the +/// pointer. Less than this since the user's last key, click, move or scroll means the user is working, +/// and an agent that took focus then typed into their window (2026-10-23: "you're taking my focus quite +/// a lot during this testing"). +public let defaultForegroundIdleSeconds: TimeInterval = 5 + +/// `GENESIS_CONTROL_FOREGROUND_GUARD=1` turns the check on. The TypeScript runner sets it when an agent +/// drives the tool; a person typing a command just pressed Return, and must not be refused for it. +public func foregroundGuardEnabled(_ environment: [String: String]) -> Bool { + environment["GENESIS_CONTROL_FOREGROUND_GUARD"]?.trimmingCharacters(in: .whitespaces) == "1" +} + +/// `GENESIS_CONTROL_FOREGROUND_IDLE=<seconds>` sets the quiet time; 0 turns the check off. A value that +/// is not a number keeps the default: a guard that a typo disables is not a guard. +public func foregroundIdleThreshold(_ environment: [String: String]) -> TimeInterval { + guard let raw = environment["GENESIS_CONTROL_FOREGROUND_IDLE"]?.trimmingCharacters(in: .whitespaces), + let value = Double(raw), value.isFinite, value >= 0 else { + return defaultForegroundIdleSeconds + } + + return value +} + +/// `GENESIS_CONTROL_ALLOW_FOREGROUND=1` lets a whole run take focus: a demonstration the user asked for. +public func foregroundAllowedByEnvironment(_ environment: [String: String]) -> Bool { + guard let raw = environment["GENESIS_CONTROL_ALLOW_FOREGROUND"]?.trimmingCharacters(in: .whitespaces).lowercased() else { + return false + } + + return ["1", "true", "yes", "on"].contains(raw) +} + +/// True when the newest key, click, move or scroll is the user's and came within `threshold` seconds. +/// The window server counts this tool's own synthetic events as input too, so an event no newer than its +/// last post (`ownInputAgo`, with 0.3 s of slack for the throttled record) is the agent's, not the user's. +public func userIsActive(idleSeconds: TimeInterval, ownInputAgo: TimeInterval?, threshold: TimeInterval) -> Bool { + guard threshold > 0, idleSeconds.isFinite, idleSeconds >= 0, idleSeconds < threshold else { + return false + } + + if let own = ownInputAgo, idleSeconds >= own - 0.3 { + return false + } + + return true +} + +/// The user is at the keyboard or mouse, so this call did not take focus. Never retried automatically. +public struct UserActiveError: Error, LocalizedError { + public let idleSeconds: TimeInterval + public let threshold: TimeInterval + + public init(idleSeconds: TimeInterval, threshold: TimeInterval) { + self.idleSeconds = idleSeconds + self.threshold = threshold + } + + public var category: SnapshotRefusal { .userActive } + + public var errorDescription: String? { + "the user is working on this Mac (their last key, click or pointer move was " + + String(format: "%.1f", idleSeconds) + " s ago, under the " + String(format: "%g", threshold) + + " s quiet time), so no app was brought to the front and the pointer did not move. Act in the " + + "background instead: click without prepare (AXPress or a window-addressed click), press_key with " + + "activate:false, set_value or fill_form without prepare, launch_app with activate:false, open -g " + + "for a URL. Only for a visible demonstration the user asked for: allow_foreground:true " + + "(--allow-foreground, or GENESIS_CONTROL_ALLOW_FOREGROUND=1 for a whole run). " + + "GENESIS_CONTROL_FOREGROUND_IDLE=0 turns this check off" + } +} diff --git a/native/ax-tool/SnapshotSupport/FrameSampler.swift b/native/ax-tool/SnapshotSupport/FrameSampler.swift index 52becde389..ba918d6f64 100644 --- a/native/ax-tool/SnapshotSupport/FrameSampler.swift +++ b/native/ax-tool/SnapshotSupport/FrameSampler.swift @@ -73,12 +73,14 @@ public struct KeepPolicy { public let reason: String } + /// Past `maxFrames` nothing more is kept, but a frame that WOULD have been kept still answers with reason `cap` + /// (and moves the comparison point), so the frame-rate policy keeps seeing motion. It used to answer `cap` for + /// every frame, which read as a still screen and dropped a long recording's movie to the idle rate after the + /// 800th kept frame. public mutating func consider(_ signature: FrameSignature) -> Decision { - guard keptCount < maxFrames else { - return Decision(keep: false, changePercent: 0, reason: "cap") - } guard let previous = lastKept else { lastKept = signature + guard keptCount < maxFrames else { return Decision(keep: false, changePercent: 100, reason: "cap") } keptCount = 1 return Decision(keep: true, changePercent: 100, reason: "first") } @@ -91,9 +93,17 @@ public struct KeepPolicy { return Decision(keep: false, changePercent: change, reason: "still") } lastKept = signature + guard keptCount < maxFrames else { + return Decision(keep: false, changePercent: change, reason: "cap") + } keptCount += 1 return Decision(keep: true, changePercent: change, reason: "change") } + + /// What the frame-rate policy reads: a kept frame, or one only the cap kept out. + public static func moved(_ decision: Decision) -> Bool { + decision.keep || decision.reason == "cap" + } } /// Which rate the capture stream should ask for. ScreenCaptureKit caps delivery through diff --git a/native/ax-tool/SnapshotSupport/SnapshotToken.swift b/native/ax-tool/SnapshotSupport/SnapshotToken.swift index 96765887c6..ff39c3bfcf 100644 --- a/native/ax-tool/SnapshotSupport/SnapshotToken.swift +++ b/native/ax-tool/SnapshotSupport/SnapshotToken.swift @@ -95,6 +95,8 @@ public enum SnapshotRefusal: String, Codable { case refused /// The user put the pointer in the takeover corner. Terminal: never retried, never recovered. case userTakeover = "user_takeover" + /// The user is at the keyboard or mouse, so an agent's call refused to take focus or the pointer. + case userActive = "user_active" } public enum SnapshotError: Error, LocalizedError { diff --git a/native/ax-tool/SnapshotSupport/UserTakeover.swift b/native/ax-tool/SnapshotSupport/UserTakeover.swift index 686f084994..bf927283d7 100644 --- a/native/ax-tool/SnapshotSupport/UserTakeover.swift +++ b/native/ax-tool/SnapshotSupport/UserTakeover.swift @@ -60,15 +60,20 @@ public final class SyntheticInputGate { private let tookOver: () -> Bool private let sleeper: (TimeInterval) -> Void + /// Runs after every event this gate sends, so the foreground guard can tell the agent's input + /// from the user's. + private let onPost: () -> Void private var held: [(id: Int, release: () -> Void)] = [] private var nextID = 0 /// Events posted through this gate, releases included. public private(set) var posted = 0 public init(tookOver: @escaping () -> Bool, - sleeper: @escaping (TimeInterval) -> Void = { Thread.sleep(forTimeInterval: $0) }) { + sleeper: @escaping (TimeInterval) -> Void = { Thread.sleep(forTimeInterval: $0) }, + onPost: @escaping () -> Void = {}) { self.tookOver = tookOver self.sleeper = sleeper + self.onPost = onPost } /// Stop here when the user took over, after releasing anything still held (latest first). @@ -80,6 +85,7 @@ public final class SyntheticInputGate { while let entry = held.popLast() { entry.release() posted += 1 + onPost() } throw UserTakeoverError(dispatched: posted > 0) } @@ -89,6 +95,7 @@ public final class SyntheticInputGate { try check() send() posted += 1 + onPost() } /// A down event. Its release runs through `release(_:)`, or through `check()` if the user takes @@ -98,6 +105,7 @@ public final class SyntheticInputGate { try check() send() posted += 1 + onPost() let id = nextID nextID += 1 held.append((id, release)) @@ -113,6 +121,7 @@ public final class SyntheticInputGate { let entry = held.remove(at: index) entry.release() posted += 1 + onPost() } /// An event whose check already ran in the caller's own verify step, such as a drag's moves and @@ -120,6 +129,7 @@ public final class SyntheticInputGate { public func deliver(_ send: () -> Void) { send() posted += 1 + onPost() } /// Sleep in slices of at most 0.1 s and check between them, so a long hold or dwell still stops diff --git a/native/ax-tool/SnapshotSupport/WorkflowArguments.swift b/native/ax-tool/SnapshotSupport/WorkflowArguments.swift index ea01ea6f63..12982b01d0 100644 --- a/native/ax-tool/SnapshotSupport/WorkflowArguments.swift +++ b/native/ax-tool/SnapshotSupport/WorkflowArguments.swift @@ -37,7 +37,7 @@ public struct WorkflowArguments { "--revalidate-scope", "--frame", "--by-identifier", "--window-index", "--depth", "--modifiers", "--budget-ms", "--target-ordinal", "--expect-value-sha256", ] - flagOptions = ["--background", "--allow-outside", "--double", "--refresh", "--no-cursor", "--no-image", "--prepare", "--replace", "--hold", "--no-activate"] + flagOptions = ["--background", "--allow-outside", "--double", "--refresh", "--no-cursor", "--no-image", "--prepare", "--replace", "--hold", "--no-activate", "--allow-foreground"] default: throw WorkflowArgumentError.invalid("unknown workflow command \(command)") } diff --git a/native/ax-tool/Sources/AppLifecycle.swift b/native/ax-tool/Sources/AppLifecycle.swift index 3bea837d46..94c32e6750 100644 --- a/native/ax-tool/Sources/AppLifecycle.swift +++ b/native/ax-tool/Sources/AppLifecycle.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import SnapshotSupport func cmdInstalledApps() { let roots = ["/Applications", "/System/Applications", "/System/Library/CoreServices", @@ -60,6 +61,15 @@ func cmdLaunchApp() { "processLaunch":launch,"frontmost":app.isActive,"alreadyRunning":existing, "dispatchState":"dispatched"]) } + if !args.contains("--background") && !(matches.first?.isActive ?? false) { + do { + try checkForegroundAllowed() + } catch let refusal as UserActiveError { + userActiveExit(refusal) + } catch { + fputs("foreground check failed: \(error.localizedDescription)\n", stderr) + } + } if let existing = matches.first { if !args.contains("--background") && !existing.activate(options: [.activateIgnoringOtherApps]) { jsonOutput(["ok":false,"error":"existing app activation was not accepted; inspect before retrying", diff --git a/native/ax-tool/Sources/IsolatedCapture.swift b/native/ax-tool/Sources/IsolatedCapture.swift index 767c4a1175..ffc3d05634 100644 --- a/native/ax-tool/Sources/IsolatedCapture.swift +++ b/native/ax-tool/Sources/IsolatedCapture.swift @@ -17,6 +17,10 @@ struct CaptureSelectionOptions { let transparent: Bool let codec: String let indicator: Bool + /// Windows the movie must never show, such as the host's own recording controls: a display capture leaves them + /// out of its content filter and an isolated capture never selects them. `NSWindow.sharingType = .none` does not + /// do it, because ScreenCaptureKit ignores it on macOS 15 and later. + let excludedWindowIDs: Set<CGWindowID> func dimensions(points: CGSize, nativeScale: CGFloat, video: Bool) -> CGSize { let requested = outputSize ?? CGSize(width: (points.width * (outputScale ?? nativeScale)).rounded(), @@ -34,15 +38,19 @@ struct CaptureSelectionOptions { } } +/// A comma-separated list of positive CG window ids, or nil when the flag is absent. +func windowIDList(_ flag: String) -> [CGWindowID]? { + guard let raw = argValue(flag) else { return nil } + let parts = raw.split(separator: ",", omittingEmptySubsequences: false) + let ids = parts.compactMap { UInt32($0) } + guard ids.count == parts.count, ids.allSatisfy({ $0 > 0 }) else { + errorExit("\(flag) requires positive CG window IDs separated by commas") + } + return ids +} + func captureSelectionOptions(mode: String) -> CaptureSelectionOptions { - var ids: [CGWindowID] = [] - if let raw = argValue("--window-ids") { - let parts = raw.split(separator: ",", omittingEmptySubsequences: false) - ids = parts.compactMap { UInt32($0) } - guard ids.count == parts.count, ids.allSatisfy({ $0 > 0 }) else { - errorExit("--window-ids requires positive CG window IDs separated by commas") - } - } + let ids = windowIDList("--window-ids") ?? [] let args = CommandLine.arguments var apps: [String] = [] for (index, value) in args.enumerated() where value == "--include-app" { @@ -94,7 +102,8 @@ func captureSelectionOptions(mode: String) -> CaptureSelectionOptions { } return CaptureSelectionOptions(windowIDs: Array(Set(ids)).sorted(), apps: apps, canvas: canvas, outputSize: size, outputScale: scale, transparent: transparent, - codec: codec, indicator: !args.contains("--no-indicator")) + codec: codec, indicator: !args.contains("--no-indicator"), + excludedWindowIDs: Set(windowIDList("--exclude-window-ids") ?? [])) } /// Same nonactivating panel contract as CursorOverlay. Core Animation supplies the pulse; @@ -478,7 +487,7 @@ func captureIsolatedWindows(_ options: RecordOptions) { let rows = captureWindowRows() let selected = rows.compactMap { row -> SelectedWindow? in guard let id = row[kCGWindowNumber] as? CGWindowID, let pid = row[kCGWindowOwnerPID] as? pid_t, - explicit.contains(id) || pids.contains(pid), + explicit.contains(id) || pids.contains(pid), !options.selection.excludedWindowIDs.contains(id), let bounds = captureWindowBounds(row), bounds.width > 0, bounds.height > 0 else { return nil } let shareable = content.windows.first { $0.windowID == id } guard let shareable else { return nil } diff --git a/native/ax-tool/Sources/Record.swift b/native/ax-tool/Sources/Record.swift index b1d5b2cd14..40f2baffbc 100644 --- a/native/ax-tool/Sources/Record.swift +++ b/native/ax-tool/Sources/Record.swift @@ -349,7 +349,7 @@ final class NativeRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let signature = FrameSignature(bgra: UnsafeRawPointer(base), width: width, height: height, bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), stride: 4) let decision = policy.consider(signature) - if let fps = rate.observe(kept: decision.keep) { + if let fps = rate.observe(kept: KeepPolicy.moved(decision)) { applyRate(fps) } @@ -557,7 +557,8 @@ func cmdCaptureScreen() { guard let scDisplay = content.displays.first(where: { $0.displayID == display.id }) else { errorExit("display \(display.id) is not shareable") } - filter = SCContentFilter(display: scDisplay, excludingWindows: content.windows.filter { indicator.windowIDs.contains($0.windowID) }) + let excluded = indicator.windowIDs.union(options.selection.excludedWindowIDs) + filter = SCContentFilter(display: scDisplay, excludingWindows: content.windows.filter { excluded.contains($0.windowID) }) pointSize = sourceRect?.size ?? display.bounds.size } let configuration = SCStreamConfiguration() diff --git a/native/ax-tool/Sources/SnapshotWorkflow.swift b/native/ax-tool/Sources/SnapshotWorkflow.swift index a9ec87a898..befc82d5a5 100644 --- a/native/ax-tool/Sources/SnapshotWorkflow.swift +++ b/native/ax-tool/Sources/SnapshotWorkflow.swift @@ -72,6 +72,10 @@ private func workflowFailure(_ message: String, category: SnapshotRefusal = .ref } private func workflowFailure(_ error: Error, extras: [String: Any] = [:]) -> Never { + if let active = error as? UserActiveError { + workflowDispatchState = inputGate.posted > 0 ? "uncertain" : "not_started" + workflowFailure(active.localizedDescription, category: active.category, extras: extras) + } if let takeover = error as? UserTakeoverError { workflowDispatchState = takeover.dispatched ? "uncertain" : "not_started" workflowFailure(takeover.localizedDescription, category: takeover.category, extras: extras) @@ -1657,7 +1661,8 @@ func cmdAct(appName _: String) { tree.rows.compactMap { $0["AXDescription"] as? String }) ActionCursor.emit("move", point: point, background: false, target: rawCoords == nil ? "ax" : "pixel") // Checked before the warp: once it runs, the pointer is ours and no longer says - // where the user put it. + // where the user put it. A hover takes the user's pointer, so it waits for them too. + try checkForegroundAllowed() try inputGate.post { _ = CGWarpMouseCursorPosition(point) } if let moved = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) { @@ -2077,6 +2082,14 @@ func cmdAct(appName _: String) { // why, rather than wondering whether it was silently skipped. payload["nonActivatingPanel"] = true } + // A window-addressed left click into an app that is not in front lands in a window that is not key. + // A SwiftUI control there takes it as the activation click and does nothing, while the click itself + // was delivered (a sheet's "Cancel" stayed open, 2026-10-23). Say so, so the caller checks the effect. + if action == "click", workflowFlag("--background"), !secondaryClick, !nonActivatingPanel, startingFrontmost != pid { + payload["inactiveWindowClick"] = true + payload["effectNote"] = "delivered to a window that is not key: a SwiftUI control may take it as the activation " + + "click and do nothing. Check the postcondition; AXPress (a click without physical) acts without focus" + } payload.merge(actionExtras) { _, new in new } if workflowFlag("--refresh") { diff --git a/native/ax-tool/Sources/commands/Input.swift b/native/ax-tool/Sources/commands/Input.swift index 543343a4e0..fda66d80ed 100644 --- a/native/ax-tool/Sources/commands/Input.swift +++ b/native/ax-tool/Sources/commands/Input.swift @@ -209,7 +209,7 @@ func secondaryClickFrontGuard(before: pid_t?, target: pid_t) -> [String: Any] { return ["frontmostRestored": false, "menuHoldsKeyboard": frontmostPid() == target] } - let restored = bringFrontmost(before) + let restored = bringFrontmost(before, restoring: true) let name = NSRunningApplication(processIdentifier: before)?.localizedName ?? "pid \(before)" return ["frontmostRestored": restored, "focusNote": restored diff --git a/native/ax-tool/Sources/main.swift b/native/ax-tool/Sources/main.swift index e94751b892..07fba1646e 100644 --- a/native/ax-tool/Sources/main.swift +++ b/native/ax-tool/Sources/main.swift @@ -78,6 +78,7 @@ if args.count < 2 || args[1] == "--help" || args[1] == "-h" { isolated: --window-ids ID,ID and/or repeated --include-app NAME|PID; --canvas crop|display [--output-size WxH | --output-scale N] [--transparent --codec prores4444 --video-out f.mov] [--no-indicator] default pulsing border is excluded from capture + [--exclude-window-ids ID,ID] windows the movie never shows (a host's own controls) [--screen-index N] [--region x,y,w,h] --duration <seconds> [--active-fps 8] [--idle-fps 2] [--threshold 2.5] [--video-out f.mp4] [--out DIR] ScreenCaptureKit recording: change-sampled keep-NNNN.png frames, contact.png, metadata.json; JSON result in the capture-runner shape diff --git a/native/ax-tool/Sources/utils/InputGate.swift b/native/ax-tool/Sources/utils/InputGate.swift index f0d2eeb3ad..1523d80ddf 100644 --- a/native/ax-tool/Sources/utils/InputGate.swift +++ b/native/ax-tool/Sources/utils/InputGate.swift @@ -31,7 +31,82 @@ private func pointerTakeover() -> Bool { } /// The gate every synthetic event of this process passes through. -let inputGate = SyntheticInputGate(tookOver: pointerTakeover) +let inputGate = SyntheticInputGate(tookOver: pointerTakeover, onPost: recordOwnInput) + +private let foregroundEnvironment = ProcessInfo.processInfo.environment +/// Read once per process: the agent's run cannot change these under a running command. +private let foregroundCheckEnabled = foregroundGuardEnabled(foregroundEnvironment) +private let foregroundThreshold = foregroundIdleThreshold(foregroundEnvironment) +private let foregroundAllowed = CommandLine.arguments.contains("--allow-foreground") + || foregroundAllowedByEnvironment(foregroundEnvironment) + +/// Seconds since the last key, click, pointer move or scroll the window server saw. +private func inputIdleSeconds() -> TimeInterval { + let types: [CGEventType] = [.keyDown, .flagsChanged, .mouseMoved, .leftMouseDown, .rightMouseDown, + .otherMouseDown, .leftMouseDragged, .scrollWheel] + return types.map { CGEventSource.secondsSinceLastEventType(.hidSystemState, eventType: $0) }.min() ?? .infinity +} + +/// When any process of this tool last posted a synthetic event. The window server counts those as +/// input, and an agent's own previous click must not read as the user working. +private let ownInputFile: URL = { + let home = foregroundEnvironment["GENESIS_TOOLS_HOME"].map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".genesis-tools") + return home.appendingPathComponent("control").appendingPathComponent("last-synthetic-input") +}() +private var ownInputWrittenAt: TimeInterval = 0 + +/// At most one write per quarter second; the guard's 0.3 s slack covers the posts in between. +private func recordOwnInput() { + guard foregroundCheckEnabled else { + return + } + + let now = Date().timeIntervalSince1970 + guard now - ownInputWrittenAt >= 0.25 else { + return + } + + ownInputWrittenAt = now + do { + try FileManager.default.createDirectory(at: ownInputFile.deletingLastPathComponent(), + withIntermediateDirectories: true) + try String(now).write(to: ownInputFile, atomically: true, encoding: .utf8) + } catch { + fputs("could not record the time of this tool's own input: \(error.localizedDescription)\n", stderr) + } +} + +private func ownInputAgo() -> TimeInterval? { + guard let text = try? String(contentsOf: ownInputFile, encoding: .utf8), + let at = Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return nil + } + + return Date().timeIntervalSince1970 - at +} + +/// Refuses to bring an app to the front or move the pointer while the user is at the keyboard or mouse, +/// when an agent drives this tool. Call it before the first change, so a refusal sent nothing. +func checkForegroundAllowed() throws { + guard foregroundCheckEnabled, !foregroundAllowed else { + return + } + + let idle = inputIdleSeconds() + guard userIsActive(idleSeconds: idle, ownInputAgo: ownInputAgo(), threshold: foregroundThreshold) else { + return + } + + throw UserActiveError(idleSeconds: idle, threshold: foregroundThreshold) +} + +/// The refusal for commands that have no error channel of their own: print it and stop. +func userActiveExit(_ error: UserActiveError) -> Never { + jsonOutput(["ok": false, "error": error.localizedDescription, "refusal": error.category.rawValue, + "dispatchState": inputGate.posted > 0 ? "uncertain" : "not_started"]) + exit(1) +} /// The refusal for commands that have no error channel of their own: print it and stop. func takeoverExit(_ takeover: UserTakeoverError) -> Never { diff --git a/native/ax-tool/Sources/utils/Output.swift b/native/ax-tool/Sources/utils/Output.swift index 0ededf2ae2..c0a03ee0a2 100644 --- a/native/ax-tool/Sources/utils/Output.swift +++ b/native/ax-tool/Sources/utils/Output.swift @@ -40,7 +40,18 @@ func frontmostPid() -> pid_t? { /// call + fixed sleep races when another activation is still in flight — /// re-request each poll (up to ~3s), pumping the runloop so NSWorkspace /// state can update too. -func bringFrontmost(_ pid: pid_t) -> Bool { +/// +/// `restoring`: putting the user's own app back in front, which the foreground guard never refuses. +func bringFrontmost(_ pid: pid_t, restoring: Bool = false) -> Bool { + if !restoring && frontmostPid() != pid { + do { + try checkForegroundAllowed() + } catch let refusal as UserActiveError { + userActiveExit(refusal) + } catch { + fputs("foreground check failed: \(error.localizedDescription)\n", stderr) + } + } let runningApp = NSWorkspace.shared.runningApplications.first { $0.processIdentifier == pid } return activateFrontmost( isFrontmost: { frontmostPid() == pid }, diff --git a/native/ax-tool/Tests/ForegroundGuardTests.swift b/native/ax-tool/Tests/ForegroundGuardTests.swift new file mode 100644 index 0000000000..505b220dfa --- /dev/null +++ b/native/ax-tool/Tests/ForegroundGuardTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import SnapshotSupport + +/// An agent must not take focus or the pointer while the user works (2026-10-23: a test run took the +/// user's focus again and again while they typed in another app). +final class ForegroundGuardTests: XCTestCase { + func testRecentUserInputMeansTheUserIsActive() { + XCTAssertTrue(userIsActive(idleSeconds: 0.4, ownInputAgo: nil, threshold: 5)) + XCTAssertTrue(userIsActive(idleSeconds: 4.9, ownInputAgo: 30, threshold: 5)) + } + + func testAQuietUserOrATurnedOffCheckIsNotActive() { + XCTAssertFalse(userIsActive(idleSeconds: 5, ownInputAgo: nil, threshold: 5)) + XCTAssertFalse(userIsActive(idleSeconds: 60, ownInputAgo: nil, threshold: 5)) + XCTAssertFalse(userIsActive(idleSeconds: 0.1, ownInputAgo: nil, threshold: 0)) + XCTAssertFalse(userIsActive(idleSeconds: .infinity, ownInputAgo: nil, threshold: 5)) + } + + /// The window server counts the agent's own synthetic click as input; it must not read as the user. + func testTheAgentsOwnLastPostIsNotTheUser() { + XCTAssertFalse(userIsActive(idleSeconds: 1.0, ownInputAgo: 1.0, threshold: 5)) + XCTAssertFalse(userIsActive(idleSeconds: 0.9, ownInputAgo: 1.1, threshold: 5), "within the 0.3 s slack") + XCTAssertTrue(userIsActive(idleSeconds: 0.5, ownInputAgo: 2.0, threshold: 5), "the user typed after the agent") + } + + func testOnlyTheRunnerTurnsTheGuardOnAndATypoKeepsTheDefault() { + XCTAssertTrue(foregroundGuardEnabled(["GENESIS_CONTROL_FOREGROUND_GUARD": "1"])) + XCTAssertFalse(foregroundGuardEnabled([:]), "a person running the command is never refused") + XCTAssertEqual(foregroundIdleThreshold([:]), defaultForegroundIdleSeconds) + XCTAssertEqual(foregroundIdleThreshold(["GENESIS_CONTROL_FOREGROUND_IDLE": "0"]), 0) + XCTAssertEqual(foregroundIdleThreshold(["GENESIS_CONTROL_FOREGROUND_IDLE": "2.5"]), 2.5) + XCTAssertEqual(foregroundIdleThreshold(["GENESIS_CONTROL_FOREGROUND_IDLE": "five"]), defaultForegroundIdleSeconds) + XCTAssertTrue(foregroundAllowedByEnvironment(["GENESIS_CONTROL_ALLOW_FOREGROUND": "yes"])) + XCTAssertFalse(foregroundAllowedByEnvironment(["GENESIS_CONTROL_ALLOW_FOREGROUND": "0"])) + } + + func testTheRefusalNamesTheBackgroundPathsAndTheOverride() { + let message = UserActiveError(idleSeconds: 1.24, threshold: 5).localizedDescription + for expected in ["1.2 s ago", "press_key with activate:false", "launch_app with activate:false", + "allow_foreground:true", "GENESIS_CONTROL_FOREGROUND_IDLE=0"] { + XCTAssertTrue(message.contains(expected), "\(expected) in: \(message)") + } + XCTAssertEqual(UserActiveError(idleSeconds: 1, threshold: 5).category.rawValue, "user_active") + } + + func testTheGateReportsEveryPostSoTheAgentsInputIsKnown() throws { + var reports = 0 + let gate = SyntheticInputGate(tookOver: { false }, onPost: { reports += 1 }) + try gate.post {} + let held = try gate.press({}, release: {}) + gate.release(held) + gate.deliver {} + XCTAssertEqual(reports, 4) + XCTAssertEqual(gate.posted, 4) + } +} diff --git a/native/ax-tool/Tests/FrameSamplerTests.swift b/native/ax-tool/Tests/FrameSamplerTests.swift index 15394a20e7..4df70360cb 100644 --- a/native/ax-tool/Tests/FrameSamplerTests.swift +++ b/native/ax-tool/Tests/FrameSamplerTests.swift @@ -87,6 +87,24 @@ final class FrameSamplerTests: XCTestCase { XCTAssertEqual(again.reason, "cap") } + func testPastTheCapMotionStillKeepsTheActiveRate() { + var policy = KeepPolicy(thresholdPercent: 0, maxFrames: 1) + var rate = FrameRatePolicy(activeFps: 15, idleFps: 2, stillFramesBeforeIdle: 2) + let shades: [UInt8] = [0, 60, 120, 180, 240, 30] + for shade in shades { + let decision = policy.consider(signature(frame(width: 16, height: 16) { _, _ in (shade, shade, shade) }, width: 16, height: 16)) + XCTAssertTrue(KeepPolicy.moved(decision), "shade \(shade) changed the frame") + _ = rate.observe(kept: KeepPolicy.moved(decision)) + } + XCTAssertEqual(policy.keptCount, 1, "only the first frame is written") + XCTAssertEqual(rate.currentFps, 15, "a moving recording past the cap stays at the active rate") + let still = signature(frame(width: 16, height: 16) { _, _ in (30, 30, 30) }, width: 16, height: 16) + XCTAssertFalse(KeepPolicy.moved(policy.consider(still))) + _ = rate.observe(kept: false) + _ = rate.observe(kept: KeepPolicy.moved(policy.consider(still))) + XCTAssertEqual(rate.currentFps, 2, "a still screen past the cap still drops to the idle rate") + } + func testTheRatePolicyDropsToIdleOnlyAfterARunOfStillFrames() { var rate = FrameRatePolicy(activeFps: 8, idleFps: 2, stillFramesBeforeIdle: 3) XCTAssertEqual(rate.currentFps, 8) diff --git a/package.json b/package.json index 874011d37e..dfac8c3f21 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,6 @@ "sonner": "^2.0.8", "sqlite-vec": "^0.1.9", "streamx": "^2.28.1", - "stripe": "^22.6.0", "tar-stream": "^3.2.1", "telegram": "^2.26.22", "tough-cookie": "^6.0.2", diff --git a/plugins/genesis-tools-mods/.claude-plugin/plugin.json b/plugins/genesis-tools-mods/.claude-plugin/plugin.json index 9f3fc1a885..018fd6df16 100644 --- a/plugins/genesis-tools-mods/.claude-plugin/plugin.json +++ b/plugins/genesis-tools-mods/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "genesis-tools-mods", - "version": "1.5.2", - "description": "Claude Code mods for GenesisTools: draws the whole diff of a Bash call whose diff-hook message was cut, with Claude Code's own diff element, under the call's row, and compacts an idle session before its 1-hour prompt cache expires. Configured in the hooks file: tools agents hooks config set mods.fullDiff|mods.idleCompact true|false.", + "version": "1.16.0", + "description": "Claude Code mods for GenesisTools, each switched by mods.<feature> in the hooks file: the whole diff of a cut diff-hook message under the call's row, the subagent's model on its rows, idle compaction before the prompt cache expires, /decisions, links in the streamed reply, a git and PR band with /mod-git, [ask] on tool rows, live spinner progress, a prompt cache countdown across sessions, /tmp usage, a live agents band, and a ledger of what hooks inject with repeat dedupe; the git, agents and hooks parts open sidebars (/mod-git, /mod-agents, /mod-hooks). Every mod but the full diff and idle compaction starts off: tools agents hooks mods enable <name|all> --write. Configured with: tools agents hooks config set mods.<feature>.<field> <value> --write.", "author": { "name": "GenesisTools" }, diff --git a/plugins/genesis-tools-mods/hooks/agent-model.tsx b/plugins/genesis-tools-mods/hooks/agent-model.tsx new file mode 100644 index 0000000000..4ea654208a --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/agent-model.tsx @@ -0,0 +1,109 @@ +// The model a subagent runs on, put in front of its description, so the agents panel row reads +// `general-purpose sonnet · Pair one`: the model right after the agent's type, which no hook can rename (a +// `subagentType` rewrite starts another agent). The row itself is Claude Code's `subagentStatusLine` setting, which +// `mods.agentRow` uses to put the model beside the type (`agent-row.ts`); the engine also replaces a working agent's +// description there with what it does now. Two hooks, because the places that draw a description read it at two +// different moments (live probes, 2026-10-22): +// - The `N background agents launched` block (`├ sonnet · Pair one`) and the Agent row draw the arguments the +// model's response recorded. A `tool.call` or `agent.spawn` rewrite comes too late for them, so `turn.step` +// rewrites the Agent call's argument JSON as it streams: what leaves the outermost hook is shown and recorded. +// - The agents panel row and the `Agent "…" finished` line draw the description `agent.spawn` passes on, which +// also covers a fork (its model is the parent's, known only at the spawn) and a call a plugin made. +// +// Named only when the model is known before the spawn: the call's `model` parameter, or the parent's model for a +// fork. Without either, the agent's own definition or the default subagent model decides later, so the +// description stays as it was rather than guess. +import type { AgentSpawnInput, HookStream, TurnStepChunk, TurnStepResult } from "claude-code"; +import { type Format, modelLabel, modelPrefix } from "./model-label"; +import { isRecord } from "./shared"; + +/** `sonnet · Pair one`, or `(S) Pair one` in the short form; a description that already has it stays. */ +function withModel(description: string, label: string, format: Format): string { + const prefix = modelPrefix(label, format); + + return description.startsWith(prefix) ? description : `${prefix}${description}`; +} + +/** Agent arguments with the model in the description, or `undefined` when they stay as the model wrote them. */ +function labelled(input: unknown, format: Format): Record<string, unknown> | undefined { + if (!isRecord(input) || typeof input.description !== "string" || typeof input.model !== "string") { + return undefined; + } + + if (input.subagent_type === "fork") { + return undefined; + } + + return { ...input, description: withModel(input.description, modelLabel(input.model, format), format) }; +} + +/** The argument JSON of one Agent block, rewritten when it parses and names a model; else as streamed. */ +function rewrittenJson(json: string, format: Format): string { + try { + const next = labelled(JSON.parse(json), format); + + return next ? JSON.stringify(next) : json; + } catch { + // Broken JSON stays broken: the engine reports it as the model wrote it. + return json; + } +} + +/** The turn.step stage: each Agent call's argument JSON is held until it ends, then passed on with the model in it. */ +export async function* agentModelStep( + stream: HookStream<TurnStepChunk, TurnStepResult | undefined>, + format: Format +): AsyncGenerator<TurnStepChunk, TurnStepResult | undefined> { + const agentBlocks = new Set<number>(); + const held = new Map<number, string>(); + + function* release(): Generator<TurnStepChunk> { + for (const [index, json] of held) { + yield { kind: "input", index, json: rewrittenJson(json, format) }; + } + + held.clear(); + } + + for await (const chunk of stream) { + if (chunk.kind === "tool" && chunk.name === "Agent") { + yield* release(); + agentBlocks.add(chunk.index); + held.set(chunk.index, ""); + yield chunk; + continue; + } + + if (chunk.kind === "input" && agentBlocks.has(chunk.index)) { + held.set(chunk.index, (held.get(chunk.index) ?? "") + chunk.json); + continue; + } + + // The first chunk after an Agent block's arguments (its block end, the next block, the stop) releases them. + yield* release(); + yield chunk; + } + + yield* release(); + + // Patched too, so a hook above reads the arguments as recorded. A test stub at the bottom returns none. + const result: TurnStepResult | undefined = await stream.result; + + if (!result) { + return result; + } + + return { + ...result, + toolUses: result.toolUses.map((use) => + use.name === "Agent" ? { ...use, input: labelled(use.input, format) ?? use.input } : use + ), + }; +} + +/** The agent.spawn input with the model in front of its description, when the model is known before the spawn. */ +export function spawnWithModel(e: AgentSpawnInput, format: Format): AgentSpawnInput { + const model = e.fork ? e.parentModel : e.model; + + return model ? { ...e, description: withModel(e.description, modelLabel(model, format), format) } : e; +} diff --git a/plugins/genesis-tools-mods/hooks/agent-row.ts b/plugins/genesis-tools-mods/hooks/agent-row.ts new file mode 100644 index 0000000000..86c0612177 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/agent-row.ts @@ -0,0 +1,255 @@ +// The rows of the agents panel under the prompt (`◯ general-purpose Reading foo.ts 4s · ↓ 58k tokens`). The +// engine draws them itself, with no `ui.render` component, and puts the agent's type on the left, so a mod cannot +// reach them. Claude Code's `subagentStatusLine` setting can: its command gets every listed subagent as JSON on stdin +// and prints one `{"id","content"}` line per row. `tools agents hooks install` wires +// `src/agents/bin/hook-subagent-line.ts` there while `mods.agentRow.enabled` is on; this file is what it prints: +// `general-purpose · sonnet-5-5 Reading foo.ts 4s · ↓ 58k tokens`. +// +// Engine facts (Claude Code 2.1.296, read from its bundle and live-probed 2026-10-25): +// - The command runs 300 ms after the set of listed subagents changes, then every 5 s, with a 5 s timeout. So the +// time on a row moves in 5 s steps, where the engine's own row counts each second. +// - A row with a line draws ONLY that content after its glyph: the engine's time and token column goes too, so the +// content carries them. A row without a line keeps the engine's row, and an empty content hides the row. +// - The engine dims or bolds the whole row for focus and selection, so the content is plain text: an ANSI reset in +// it would undo that. +// - The payload has no end time: a finished agent's time ends when this command first saw it finished. +// No engine types here: the CLI's command imports this file. + +import { bareDescription, type Format, modelLabel, withEffort } from "./model-label"; + +export interface RowTask { + id: string; + /** The name a named agent or teammate got; the engine shows it in place of the type. */ + name?: string; + agentType?: string; + status?: string; + description?: string; + /** What the agent does now (the engine's activity summary), else its description. */ + label?: string; + startTime?: number; + model?: string; + /** The reasoning effort the agent runs at (`xhigh`), when the engine names one. */ + effort?: string; + tokenCount?: number; +} + +export interface RowInput { + sessionId?: string; + /** The width the engine leaves for a row's content, past its glyph. */ + columns: number; + tasks: RowTask[]; +} + +export interface RowOptions { + format: Format; + /** Name the effort beside the model: `O5.5(xhigh)` (`mods.agentRow.effort`). */ + effort: boolean; + now: number; + /** When each finished agent was first seen finished (`finishedAt`). */ + ends: Record<string, number>; + /** Terminal cells of a string: `Bun.stringWidth`. */ + width: (text: string) => number; +} + +const FINISHED = new Set(["completed", "failed", "killed"]); +const GAP = " "; + +function text(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function count(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** The payload the engine sends, narrowed; `undefined` when it is not one. */ +export function rowInput(payload: unknown): RowInput | undefined { + if (typeof payload !== "object" || payload === null || !("tasks" in payload) || !Array.isArray(payload.tasks)) { + return undefined; + } + + const tasks: RowTask[] = []; + + for (const raw of payload.tasks) { + if (typeof raw !== "object" || raw === null) { + continue; + } + + const task = new Map<string, unknown>(Object.entries(raw)); + const id = text(task.get("id")); + + if (!id) { + continue; + } + + tasks.push({ + id, + name: text(task.get("name")), + agentType: text(task.get("agentType")), + status: text(task.get("status")), + description: text(task.get("description")), + label: text(task.get("label")), + startTime: count(task.get("startTime")), + model: text(task.get("model")), + effort: text(task.get("effort")), + tokenCount: count(task.get("tokenCount")), + }); + } + + const columns = "columns" in payload ? count(payload.columns) : undefined; + const sessionId = "session_id" in payload ? text(payload.session_id) : undefined; + + return { sessionId, columns: columns ?? 80, tasks }; +} + +/** The finished agents of `input` with the time each was first seen finished; the agents no longer listed go. */ +export function finishedAt(input: RowInput, held: Record<string, number>, now: number): Record<string, number> { + const ends: Record<string, number> = {}; + + for (const task of input.tasks) { + if (task.status && FINISHED.has(task.status)) { + ends[task.id] = held[task.id] ?? now; + } + } + + return ends; +} + +/** The engine's own duration form: `4s`, `1m 5s`, `1h 2m 3s`, `1d 2h 3m`. */ +export function duration(ms: number): string { + if (ms < 60_000) { + return `${Math.floor(Math.max(0, ms) / 1000)}s`; + } + + let days = Math.floor(ms / 86_400_000); + let hours = Math.floor((ms % 86_400_000) / 3_600_000); + let minutes = Math.floor((ms % 3_600_000) / 60_000); + let seconds = Math.round((ms % 60_000) / 1000); + + if (seconds === 60) { + seconds = 0; + minutes++; + } + + if (minutes === 60) { + minutes = 0; + hours++; + } + + if (hours === 24) { + hours = 0; + days++; + } + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } + + return `${minutes}m ${seconds}s`; +} + +const COMPACT = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); + +/** `↓ 58.2k tokens`, as the engine writes it; nothing before the first token. */ +function tokens(n: number | undefined): string { + return n && n > 0 ? `↓ ${COMPACT.format(n).toLowerCase()} tokens` : ""; +} + +/** The right column: the time the agent ran (to its end once finished) and its tokens. */ +function statusOf(task: RowTask, options: RowOptions): string { + const end = task.status && FINISHED.has(task.status) ? options.ends[task.id] : options.now; + const time = task.startTime !== undefined && end !== undefined ? duration(end - task.startTime) : ""; + + return [time, tokens(task.tokenCount)].filter(Boolean).join(" · "); +} + +/** The left column: the name or type, then the model and its effort. */ +function labelOf(task: RowTask, options: RowOptions): string { + const who = task.name || task.agentType || "agent"; + + if (!task.model) { + return who; + } + + const model = modelLabel(task.model, options.format); + + return `${who} · ${withEffort(model, options.effort ? task.effort : undefined, options.format)}`; +} + +/** One line of plain text: control characters and runs of white space become one space. */ +function oneLine(value: string): string { + return value + .replace(/[\p{Cc}\p{Cf}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** `value` cut to `cells` terminal cells, with `…` where it was cut. */ +function fit(value: string, cells: number, width: (text: string) => number): string { + if (cells <= 0) { + return ""; + } + + if (width(value) <= cells) { + return value; + } + + let out = ""; + let used = 0; + + for (const char of value) { + const w = width(char); + + if (used + w > cells - 1) { + break; + } + + out += char; + used += w; + } + + return `${out}…`; +} + +function padEnd(value: string, cells: number, width: (text: string) => number): string { + return value + " ".repeat(Math.max(0, cells - width(value))); +} + +/** + * The content of each row: the label column as wide as the widest label (at most half the row), the activity, + * and the time and tokens at the right edge, as the engine lays out its own row. + */ +export function agentRows(input: RowInput, options: RowOptions): { id: string; content: string }[] { + const { width } = options; + // One cell spare: the engine's width is its own estimate, and a nested agent's tree connector takes more. + const columns = Math.max(0, input.columns - 1); + const rows = input.tasks.map((task) => ({ + id: task.id, + label: oneLine(labelOf(task, options)), + activity: oneLine(bareDescription(task.label || task.description || "")), + status: statusOf(task, options), + })); + const labelCells = Math.min( + Math.max(0, ...rows.map((row) => width(row.label))), + Math.max(8, Math.floor(columns / 2)) + ); + const statusCells = Math.max(0, ...rows.map((row) => width(row.status))); + + return rows.map((row) => { + const label = padEnd(fit(row.label, labelCells, width), labelCells, width); + const status = " ".repeat(Math.max(0, statusCells - width(row.status))) + row.status; + const room = columns - labelCells - GAP.length - (statusCells > 0 ? statusCells + 1 : 0); + + if (room <= 0) { + return { id: row.id, content: `${label} ${row.status}`.trimEnd() }; + } + + const activity = padEnd(fit(row.activity, room, width), room, width); + + return { id: row.id, content: `${label}${GAP}${activity}${statusCells > 0 ? ` ${status}` : ""}`.trimEnd() }; + }); +} diff --git a/plugins/genesis-tools-mods/hooks/agents-band.tsx b/plugins/genesis-tools-mods/hooks/agents-band.tsx new file mode 100644 index 0000000000..1a6ed0c9f2 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/agents-band.tsx @@ -0,0 +1,480 @@ +// The live agents band: the working subagents with their model, what they are for, how long they have run, how long +// since their last tool call, and how many tool calls they made. The engine's agents panel shows none of the first +// three, and a subagent past `mods.agentsBand.warnMinutes` turns red. Up to three agents show as rows; more fold +// into one `▸ 8 agents` line that a press opens. A press on an agent opens the Agents sidebar on it: its recent tool +// calls, what it said last, and actions (message it, ask about it, stop it, copy its id or transcript path). +// +// The model and start come from `agent.spawn`'s answer; for an agent spawned before this module loaded (a reload, or +// a resumed session) from the `agent-<id>.meta.json` Claude Code writes beside its transcript. Activity comes from +// the subagent's own `tool.call`s. +import type { AgentInfo, AgentSpawnInput, AgentSpawnResult, ElementTable, RenderElement } from "claude-code"; +import type { SpawnedAgents } from "../types"; +import { liveAgo, liveSince } from "./live"; +import { bareDescription, modelLabel } from "./model-label"; +import { type Feature, type Host, isRecord, settingsOf } from "./shared"; +import { + actionRow, + dimNote, + divider, + headerRow, + type Kit, + listRow, + plural, + sectionTitle, + sidebarFrame, +} from "./sidebar"; +import { toolLabel } from "./spinner"; + +export const AGENTS_PANE = "mods-agents"; + +let spawned: SpawnedAgents = {}; +const activity = new Map<string, { lastAt: number; lastTool: string; tools: number; recent: Recent[] }>(); +/** What `agent-<id>.meta.json` said; null once read and found without a model. */ +const metas = new Map<string, { model: string; at?: number } | null>(); +/** The end of each agent's transcript: its last words, read at most every 5 s. */ +const tails = new Map<string, { at: number; said: string }>(); +const BUSY = new Set(["pending", "running", "waiting"]); +const MAX_ROWS = 6; +const FOLD_OVER = 3; +/** The session's transcript folder, `<projects>/<encoded cwd>/<session id>`, from a classic hook's `transcript_path`. */ +let sessionDir: string | undefined; +/** The person's fold of the band; null folds it past `FOLD_OVER` agents. */ +let expanded: boolean | null = null; +let selected: string | undefined; + +interface Recent { + label: string; + at: number; +} + +async function beat(host: Host): Promise<void> { + if (!(await settingsOf(host)).agentsBand.enabled) { + return; + } + + const list = await host.agents().catch(() => []); + + if (list.some((agent) => BUSY.has(agent.status))) { + host.redraw(); + } +} + +export const agentsBand: Feature = { + async start(host) { + host.every(10_000, () => { + void beat(host); + }); + }, + beforeTool(_host, e) { + if (e.agentId !== undefined) { + const before = activity.get(e.agentId); + const now = Date.now(); + activity.set(e.agentId, { + lastAt: now, + lastTool: e.tool, + tools: (before?.tools ?? 0) + 1, + recent: [...(before?.recent ?? []), { label: toolLabel(e.tool, e), at: now }].slice(-12), + }); + } + + return undefined; + }, + sidebars: [{ id: AGENTS_PANE, title: "Agents", draw: agentsPane }], + commands: [ + { + name: "mod-agents", + description: "The Agents sidebar: every subagent with its model, recent tools, last words and actions", + async run(host) { + await host.togglePane(AGENTS_PANE, "Agents", "command"); + + return {}; + }, + }, + ], +}; + +/** Keeps the model and start of a spawned subagent, as `agent.spawn` answered. */ +export function recordSpawn(host: Host, e: AgentSpawnInput, result: AgentSpawnResult): void { + const agentId = result.agentId; + + if (!agentId) { + return; + } + + const model = result.model || e.model || e.parentModel; + spawned = { ...spawned, [agentId]: { model, description: bareDescription(e.description), at: Date.now() } }; + host.redraw(); +} + +/** Remembers where the session's transcripts are, from any classic hook input. */ +export function noteTranscript(input: unknown): void { + if (isRecord(input) && typeof input.transcript_path === "string" && !input.agent_id) { + sessionDir = input.transcript_path.replace(/\.jsonl$/, ""); + } +} + +export async function sessionDirOf(host: Host): Promise<string | undefined> { + if (sessionDir) { + return sessionDir; + } + + const home = await host.env("HOME"); + + if (!home) { + return undefined; + } + + const cwd = await host.cwd(); + + return `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, "-")}/${await host.sessionId()}`; +} + +async function transcriptOf(host: Host, agentId: string): Promise<string | undefined> { + const dir = await sessionDirOf(host); + + return dir ? `${dir}/subagents/agent-${agentId}.jsonl` : undefined; +} + +/** Reads an agent's meta file once, in the background; the band draws again when it knows more. */ +function loadMeta(host: Host, agentId: string): void { + if (metas.has(agentId)) { + return; + } + + metas.set(agentId, null); + void (async () => { + const transcript = await transcriptOf(host, agentId); + + if (!transcript) { + return; + } + + const path = transcript.replace(/\.jsonl$/, ".meta.json"); + const text = await host.read(path).catch(() => ""); + const parsed: unknown = text ? JSON.parse(text) : undefined; + + if (isRecord(parsed) && typeof parsed.model === "string") { + const stat = await host.stat(path).catch(() => undefined); + metas.set(agentId, { model: parsed.model, at: stat?.mtimeMs }); + host.redraw(); + } + })().catch(() => undefined); +} + +/** The text of the last assistant message in a transcript's end, or "" without one. */ +export function lastSaidOf(tail: string): string { + const lines = tail.split("\n").reverse(); + + for (const line of lines) { + if (!line.includes('"assistant"')) { + continue; + } + + try { + const row: unknown = JSON.parse(line); + const message = isRecord(row) && isRecord(row.message) ? row.message : undefined; + const content = message && Array.isArray(message.content) ? message.content : []; + const text = content + .flatMap((part) => + isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [] + ) + .join("\n") + .trim(); + + if (text) { + return text; + } + } catch { + // The first line of a byte-cut tail is half a row. + } + } + + return ""; +} + +function loadTail(host: Host, agentId: string): void { + const held = tails.get(agentId); + + if (held && Date.now() - held.at < 5_000) { + return; + } + + tails.set(agentId, { at: Date.now(), said: held?.said ?? "" }); + void (async () => { + const path = await transcriptOf(host, agentId); + const done = path ? await host.run(["tail", "-c", "131072", path], { timeoutMs: 5_000 }) : undefined; + const said = done?.exitCode === 0 ? lastSaidOf(done.stdout) : ""; + + if (said !== held?.said) { + tails.set(agentId, { at: Date.now(), said }); + host.redraw(); + } + })().catch(() => undefined); +} + +interface AgentFacts { + agent: AgentInfo; + model: string | undefined; + description: string; + age: number | undefined; + /** When it started, for its live run time. */ + startedAt: number | undefined; + late: boolean; + seen: ReturnType<typeof activity.get>; +} + +async function factsOf(host: Host, list: readonly AgentInfo[]): Promise<AgentFacts[]> { + const all = await settingsOf(host); + const now = Date.now(); + + return list.map((agent) => { + const known = spawned[agent.id]; + + if (!known) { + loadMeta(host, agent.id); + } + + const meta = metas.get(agent.id); + const model = known?.model ?? meta?.model; + const at = known?.at ?? meta?.at; + const age = at === undefined ? undefined : now - at; + + return { + agent, + model: model ? modelLabel(model, all.agentModel.format) : undefined, + description: known?.description ?? bareDescription(agent.description), + age, + startedAt: at, + late: BUSY.has(agent.status) && age !== undefined && age > all.agentsBand.warnMinutes * 60_000, + seen: activity.get(agent.id), + }; + }); +} + +function nameOf(facts: AgentFacts): string { + return `${facts.model ? `(${facts.model}) ` : ""}${facts.description}`; +} + +function tailOf(facts: AgentFacts, now: number): string { + const parts = [facts.startedAt === undefined ? "" : liveSince(facts.startedAt, now)]; + + if (facts.seen) { + parts.push(`idle ${liveSince(facts.seen.lastAt, now)}`, plural(facts.seen.tools, "tool")); + } + + return parts.filter(Boolean).join(" · "); +} + +function cut(text: string, width: number): string { + return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text; +} + +function openOn(host: Host, agentId: string | undefined): void { + selected = agentId; + void host.openPane(AGENTS_PANE, "Agents"); +} + +/** The band's agents rows, or null when no subagent works. */ +export async function agentsRow(host: Host, ui: ElementTable, columns: number): Promise<RenderElement | null> { + const settings = (await settingsOf(host)).agentsBand; + + if (!settings.enabled) { + return null; + } + + const list = (await host.agents().catch(() => [])).filter((agent) => BUSY.has(agent.status) && !agent.teammateId); + + if (list.length === 0) { + return null; + } + + const { Box, Button, Text } = ui; + const now = Date.now(); + const all = await factsOf(host, list); + const isOpen = expanded ?? list.length <= FOLD_OVER; + const late = all.filter((facts) => facts.late).length; + const toggle = () => { + expanded = !isOpen; + host.redraw(); + }; + const row = (facts: AgentFacts) => { + const tail = tailOf(facts, now); + const label = cut(`${facts.late ? "⚠" : "●"} ${nameOf(facts)}`, Math.max(20, columns - tail.length - 3)); + + return ( + <Box key={`agent-${facts.agent.id}`} flexDirection="row"> + <Button key={`agent-${facts.agent.id}`} plain onPress={() => openOn(host, facts.agent.id)}> + {facts.late ? <Text color="red">{label}</Text> : <Text dimColor>{label}</Text>} + </Button> + <Box flexGrow={1} /> + <Text dimColor>{tail}</Text> + </Box> + ); + }; + const header = + list.length > FOLD_OVER || expanded === true ? ( + <Box key="agents-head" flexDirection="row" gap={1}> + <Button key="agents-toggle" plain onPress={toggle}> + <Text bold>{`${isOpen ? "▾" : "▸"} ${list.length} agents`}</Text> + </Button> + {late ? <Text color="red">{`${late} past ${settings.warnMinutes}m`}</Text> : null} + {isOpen ? null : ( + <Text dimColor wrap="truncate-end"> + {all.map(nameOf).join(" · ")} + </Text> + )} + <Box flexGrow={1} /> + <Button key="agents-open" plain dimColor onPress={() => openOn(host, selected)}> + details + </Button> + </Box> + ) : null; + const shown = isOpen ? all.slice(0, MAX_ROWS) : []; + + return ( + <Box key="agents-band" flexDirection="column"> + {header} + {shown.map(row)} + {isOpen && all.length > MAX_ROWS ? ( + <Button key="agents-more" plain dimColor onPress={() => openOn(host, all[MAX_ROWS]?.agent.id)}> + {`+${all.length - MAX_ROWS} more in the sidebar`} + </Button> + ) : null} + </Box> + ); +} + +/** The Agents sidebar: every agent of the session, the working ones first, and the chosen one in detail. */ +async function agentsPane(host: Host, kit: Kit): Promise<RenderElement> { + const { Box, Text } = kit.ui; + const list = (await host.agents().catch(() => [])).filter((agent) => !agent.teammateId); + const ordered = [...list.filter((agent) => BUSY.has(agent.status)), ...list.filter((a) => !BUSY.has(a.status))]; + const all = await factsOf(host, ordered); + const now = Date.now(); + const chosen = all.find((facts) => facts.agent.id === selected) ?? all[0]; + const busy = all.filter((facts) => BUSY.has(facts.agent.status)).length; + const refresh = { key: "agents-refresh", label: "refresh", isDim: true, onPress: () => host.redraw() }; + + if (all.length === 0) { + return sidebarFrame(kit, dimNote(kit, "No agents in this session yet.")); + } + + const rows = all.map((facts) => { + const isChosen = facts === chosen; + const status = BUSY.has(facts.agent.status) ? tailOf(facts, now) : facts.agent.status; + + return listRow( + kit, + { + key: `pick-${facts.agent.id}`, + label: cut( + `${isChosen ? "▸" : facts.late ? "⚠" : " "} ${nameOf(facts)}`, + kit.columns - status.length - 2 + ), + isDim: !isChosen, + onPress: () => { + selected = facts.agent.id; + host.redraw(); + }, + }, + <Text dimColor={!facts.late} color={facts.late ? "red" : undefined}> + {status} + </Text> + ); + }); + + return sidebarFrame( + kit, + <Box flexDirection="column"> + {headerRow( + kit, + <Text wrap="truncate-end"> + <Text bold>{`${all.length} agents`}</Text> + <Text dimColor>{` · ${busy} working`}</Text> + </Text>, + [refresh] + )} + <Box flexDirection="column" marginTop={1} marginBottom={1}> + {rows} + </Box> + {chosen ? await detailOf(host, kit, chosen, now) : null} + </Box> + ); +} + +async function detailOf(host: Host, kit: Kit, facts: AgentFacts, now: number): Promise<RenderElement> { + const { Box, Text } = kit.ui; + const { agent, seen } = facts; + const transcript = (await transcriptOf(host, agent.id)) ?? ""; + loadTail(host, agent.id); + const said = tails.get(agent.id)?.said ?? ""; + const address = agent.name ?? agent.id; + const quoted = `"${facts.description}" (${agent.id})`; + const facts1 = [agent.type, agent.status, agent.name, agent.id].filter(Boolean).join(" · "); + const facts2 = [ + facts.startedAt === undefined ? "" : `ran ${liveSince(facts.startedAt, now)}`, + seen ? plural(seen.tools, "tool call") : "", + seen ? `last ${seen.lastTool} ${liveAgo(seen.lastAt, { now })}` : "", + ] + .filter(Boolean) + .join(" · "); + const fill = (text: string) => void host.fill({ text, mode: "replace" }); + + return ( + <Box flexDirection="column"> + {divider(kit)} + {sectionTitle(kit, nameOf(facts))} + {dimNote(kit, facts1, "facts1")} + {facts2 ? dimNote(kit, facts2, "facts2") : null} + <Box marginTop={1}> + {actionRow(kit, [ + { + key: "agent-message", + label: "message", + isPrimary: true, + onPress: () => fill(`SendMessage to ${address}: `), + }, + { + key: "agent-ask", + label: "ask about it", + onPress: () => fill(`What is the agent ${quoted} doing now? Its transcript: ${transcript}`), + }, + { + key: "agent-stop", + label: "stop", + onPress: () => fill(`Stop the agent ${quoted} with TaskStop, then tell me what it finished.`), + }, + { key: "agent-copy-id", label: "copy id", isDim: true, onPress: () => void host.copy(agent.id) }, + { + key: "agent-copy-path", + label: "copy transcript path", + isDim: true, + onPress: () => void host.copy(transcript), + }, + ])} + </Box> + {seen?.recent.length ? ( + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Recent tools")} + {[...seen.recent].reverse().map((call, index) => ( + <Box key={`recent-${index}`} flexDirection="row"> + <Text dimColor wrap="truncate-end"> + {call.label} + </Text> + <Box flexGrow={1} /> + <Text dimColor>{liveSince(call.at, now)}</Text> + </Box> + ))} + </Box> + ) : null} + {said ? ( + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Said last")} + <Text dimColor italic wrap="wrap"> + {said.split("\n").slice(-8).join("\n").slice(-900)} + </Text> + </Box> + ) : null} + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/answers.ts b/plugins/genesis-tools-mods/hooks/answers.ts new file mode 100644 index 0000000000..56519891cc --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/answers.ts @@ -0,0 +1,664 @@ +// What the answer MCP (`tools question`) holds for this session: its decisions in every state, its todos, the +// messages and answers it recorded, and the forms it left pending. One `tools question inbox --session <id> --json +// --all-items` call reads them all, and it runs only when the files behind it changed, a sidebar asks, or this +// session just called a question tool: every running session reads it. +// +// Also where each item was asked, for "jump to": the transcript row of a decision's first ask (an assistant text +// with `❓ DECISION N` that is not a link) and the tool call that recorded an entry. A row's `requestId` is the +// transcript entry's `uuid` (a text row) or the tool_use id (a tool row); `grep` finds them in the transcript. +import type { ToolCallInput } from "claude-code"; +import { sessionDirOf } from "./agents-band"; +import { decisionLink, genesisProbe } from "./genesis"; +import { type Host, isRecord, logMod } from "./shared"; +import { pressed } from "./sidebar"; + +export type DecisionStatus = + | "waiting" + | "drafted" + | "answered" + | "sent" + | "acknowledged" + | "implemented" + | "dismissed" + | "superseded"; + +/** One decision of the session as the decision log has it. */ +export interface LoggedDecision { + n: number; + title: string; + prompt: string; + status: DecisionStatus; + options: { letter: string; text: string }[]; + /** The letter the asker recommended. */ + recommended?: string; + /** The letter picked, and the answer in words. */ + option?: string; + answer?: string; + /** A staged answer not sent yet (state `drafted`): the picked letters and a note. The store owns staging. */ + draftOption?: string; + draft?: string; + at: number; +} + +/** A message the session sent to the inbox, or a question and answer it recorded. */ +export interface LoggedEntry { + id: string; + at: number; + tag: "question" | "action" | "directive" | "message"; + title: string; + text: string; + isUnread: boolean; + images: string[]; +} + +export interface LoggedTodo { + n: number; + title: string; + state: string; + at: number; +} + +export interface LoggedForm { + id: string; + at: number; + prompts: string[]; +} + +export interface SessionAnswers { + decisions: LoggedDecision[]; + todos: LoggedTodo[]; + entries: LoggedEntry[]; + forms: LoggedForm[]; + /** The message `[send]` submits now, as the CLI's `stagedSendText` builds it; absent when nothing is staged. */ + staged?: string; + /** When this read finished. */ + at: number; +} + +const QUESTION_TOOLS = new Set([ + "mcp__genesis-tools__question_post", + "mcp__genesis-tools__question_update", + "mcp__genesis-tools__question_answer", + "mcp__genesis-tools__inbox_send", +]); +const STATUSES: ReadonlySet<string> = new Set<DecisionStatus>([ + "waiting", + "drafted", + "answered", + "sent", + "acknowledged", + "implemented", + "dismissed", + "superseded", +]); +const TAGS: ReadonlySet<string> = new Set<LoggedEntry["tag"]>(["question", "action", "directive", "message"]); +/** How often the files behind the items are checked; the CLI runs only when this session's part of them changed. */ +const TICK_MS = 10_000; +/** + * What the logs cannot show (a read mark in the widget, a form's answer) is read again this often, and only in a + * session that has entries or forms. Every running session shares the store, so reading on each of its changes would + * start a `bun` in every session at once. + */ +const SLOW_MS = 5 * 60_000; +/** A tool call more than this before its entry did not record it. */ +const ENTRY_WINDOW_MS = 10 * 60_000; +const FIRST_ASK = /(^|[^[])❓\s*\**\s*DECISION\s+(\d+)/gm; + +let held: SessionAnswers | undefined; +/** What the last read saw: the hash of this session's lines in the shared logs. */ +let heldStamp: string | undefined; +let lastReadAt = 0; +/** Each shared file's size and time at the last check, with the hash of this session's lines in it. */ +const seenFiles = new Map<string, { mark: string; own: string; lines: number }>(); +let reading: Promise<SessionAnswers | undefined> | undefined; +let started = false; +/** Decision number to the uuid of its first ask; entry id to the tool call that recorded it. Found once, kept. */ +const askRows = new Map<number, string>(); +const entryRows = new Map<string, string>(); + +/** The last read, or undefined before the first one finished. */ +export function answersOf(): SessionAnswers | undefined { + return held; +} + +/** The transcript row a jump scrolls to: a decision's first ask, or an entry's tool call. */ +export function jumpRowOf(target: { decision: number } | { entry: string }): string | undefined { + return "decision" in target ? askRows.get(target.decision) : entryRows.get(target.entry); +} + +/** Drops what was read: a test starts another session. */ +export function forgetAnswers(): void { + held = undefined; + heldStamp = undefined; + lastReadAt = 0; + seenFiles.clear(); + reading = undefined; + started = false; + askRows.clear(); + entryRows.clear(); + searchedSize = -1; +} + +function isStatus(value: string): value is DecisionStatus { + return STATUSES.has(value); +} + +function isTag(value: string): value is LoggedEntry["tag"] { + return TAGS.has(value); +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function timeOf(value: unknown): number { + const at = typeof value === "number" ? value : Date.parse(text(value)); + + return Number.isFinite(at) ? at : 0; +} + +/** `a) Keep it` with the letter `a` gives `Keep it`. */ +function withoutLetter(label: string, letter: string): string { + return label.replace(new RegExp(`^\\s*\\(?${letter}\\)\\s*`), "").trim(); +} + +/** The JSON `inbox --session <id> --json --all-items` prints, as this plugin keeps it; undefined when it is not that. */ +export function answersFrom(stdout: string, at = Date.now()): SessionAnswers | undefined { + let parsed: unknown; + + try { + parsed = JSON.parse(stdout); + } catch { + return undefined; + } + + if (!isRecord(parsed) || !Array.isArray(parsed.decisions)) { + return undefined; + } + + const list = (value: unknown): Record<string, unknown>[] => + Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => isRecord(item)) : []; + + return { + decisions: list(parsed.decisions).map((item) => { + const status = text(item.status); + + return { + n: Number(item.number), + title: text(item.title) || text(item.prompt).split("\n")[0] || "", + prompt: text(item.prompt), + status: isStatus(status) ? status : "waiting", + options: list(item.choices).map((choice) => { + const letter = text(choice.id); + + return { letter, text: withoutLetter(text(choice.label), letter) }; + }), + ...(text(item.recommended) ? { recommended: text(item.recommended) } : {}), + ...(text(item.option) ? { option: text(item.option) } : {}), + ...(text(item.answer) ? { answer: text(item.answer) } : {}), + ...(text(item.draftOption) ? { draftOption: text(item.draftOption) } : {}), + ...(text(item.draft) ? { draft: text(item.draft) } : {}), + at: timeOf(item.at), + }; + }), + todos: list(parsed.todos).map((item) => ({ + n: Number(item.number), + title: text(item.title), + state: text(item.state), + at: timeOf(item.at), + })), + entries: list(parsed.entries) + .map((item) => { + const tag = text(item.tag); + + return { + id: text(item.id), + at: timeOf(item.ts), + tag: isTag(tag) ? tag : "message", + title: text(item.question), + text: text(item.answerMd), + isUnread: item.readAt === null || item.readAt === undefined, + images: list(item.attachments) + .filter((file) => file.type === "image") + .map((file) => text(file.path)) + .filter(Boolean), + }; + }) + .sort((a, b) => b.at - a.at), + forms: list(parsed.forms).map((item) => ({ + id: text(item.id), + at: timeOf(item.at), + prompts: list(item.questions).map((question) => text(question.prompt)), + })), + ...(text(parsed.staged) ? { staged: text(parsed.staged) } : {}), + at, + }; +} + +/** Each decision's first ask in transcript lines: the first assistant entry whose text asks it without a link. */ +export function firstAsks(lines: readonly string[]): Map<number, string> { + const found = new Map<number, string>(); + + for (const line of lines) { + const entry = parseLine(line); + + if (entry?.type !== "assistant" || typeof entry.uuid !== "string") { + continue; + } + + for (const block of blocksOf(entry)) { + if (block.type !== "text") { + continue; + } + + for (const match of text(block.text).matchAll(FIRST_ASK)) { + const n = Number(match[2]); + + if (!found.has(n)) { + found.set(n, entry.uuid); + } + } + } + } + + return found; +} + +/** The question tool calls in transcript lines: an MCP question tool, or a Bash command that runs `tools question`. */ +export function questionCalls(lines: readonly string[]): { id: string; at: number }[] { + const calls: { id: string; at: number }[] = []; + + for (const line of lines) { + const entry = parseLine(line); + + if (entry?.type !== "assistant") { + continue; + } + + for (const block of blocksOf(entry)) { + const input = isRecord(block.input) ? block.input : {}; + const isQuestion = + QUESTION_TOOLS.has(text(block.name)) || + (block.name === "Bash" && /\btools question\b/.test(text(input.command))); + + if (block.type === "tool_use" && isQuestion && text(block.id)) { + calls.push({ id: text(block.id), at: timeOf(entry.timestamp) }); + } + } + } + + return calls; +} + +/** The call that recorded an entry: the latest question call before it (a few seconds of clock skew allowed). */ +export function callFor(at: number, calls: readonly { id: string; at: number }[]): string | undefined { + let best: { id: string; at: number } | undefined; + + for (const call of calls) { + if (call.at <= at + 5_000 && call.at >= at - ENTRY_WINDOW_MS && (!best || call.at > best.at)) { + best = call; + } + } + + return best?.id; +} + +function parseLine(line: string): Record<string, unknown> | undefined { + try { + const value: unknown = JSON.parse(line); + + return isRecord(value) ? value : undefined; + } catch { + return undefined; + } +} + +function blocksOf(entry: Record<string, unknown>): Record<string, unknown>[] { + const message = isRecord(entry.message) ? entry.message : {}; + + return Array.isArray(message.content) + ? message.content.filter((block): block is Record<string, unknown> => isRecord(block)) + : []; +} + +/** A short hash (FNV-1a) of a text. */ +function hashOf(value: string): string { + let hash = 0x811c9dc5; + + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash.toString(36); +} + +/** + * The hash and count of the lines of a shared file that name this session; the file is read only after its size or + * time moved. + */ +async function ownLines(host: Host, file: string, session: string): Promise<{ own: string; lines: number }> { + const stat = await host.stat(file).catch(() => undefined); + const mark = stat ? `${stat.mtimeMs}:${stat.size}` : "-"; + const seen = seenFiles.get(file); + + if (seen?.mark === mark) { + return seen; + } + + const content = stat ? await host.read(file).catch(() => "") : ""; + const mine = content.split("\n").filter((line) => line.includes(session)); + const found = { mark, own: hashOf(mine.join("\n")), lines: mine.length }; + seenFiles.set(file, found); + + return found; +} + +/** Where a change of this session's items shows: its lines in the decision log and in today's Q→A log. */ +async function stampOf(host: Host): Promise<{ own: string; lines: number }> { + const home = (await host.env("GENESIS_TOOLS_HOME")) ?? (await host.env("HOME")); + + if (!home) { + return { own: "", lines: 0 }; + } + + const question = `${home}/.genesis-tools/question`; + const session = await host.sessionId(); + const now = new Date(); + const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + const own = await Promise.all([ + ownLines(host, `${question}/decisions/decisions.jsonl`, session), + ownLines(host, `${question}/log/${day}.jsonl`, session), + ]); + + return { own: own.map((file) => file.own).join("|"), lines: own.reduce((sum, file) => sum + file.lines, 0) }; +} + +/** The transcript's size at the last search: a search that found nothing new waits for the transcript to grow. */ +let searchedSize = -1; + +/** + * Finds the rows not found yet, for these decision numbers (the log's and the chat's) and entries: one `grep` over + * the transcript for the assistant lines that could hold them. + */ +async function findRows(host: Host, numbers: readonly number[], entries: readonly LoggedEntry[]): Promise<void> { + const missing = numbers.some((n) => !askRows.has(n)) || entries.some((entry) => !entryRows.has(entry.id)); + const dir = await sessionDirOf(host); + + if (!missing || !dir) { + return; + } + + const transcript = `${dir}.jsonl`; + const size = (await host.stat(transcript).catch(() => undefined))?.size ?? -1; + + if (size === searchedSize) { + return; + } + + const found = await host + .run( + [ + "/bin/sh", + "-c", + 'grep -F -e "DECISION" -e "mcp__genesis-tools__" -e "tools question" "$1" | grep -F \'"type":"assistant"\'', + "sh", + transcript, + ], + { timeoutMs: 15_000 } + ) + .catch(() => undefined); + + if (!found || found.exitCode > 1) { + return; + } + + searchedSize = size; + const lines = found.stdout.split("\n").filter(Boolean); + + for (const [n, uuid] of firstAsks(lines)) { + if (!askRows.has(n)) { + askRows.set(n, uuid); + } + } + + const calls = questionCalls(lines); + + for (const entry of entries) { + const call = entryRows.has(entry.id) ? undefined : callFor(entry.at, calls); + + if (call) { + entryRows.set(entry.id, call); + } + } +} + +/** Finds the first asks of decisions the chat knows and the log may not: called after the chat was read. */ +export async function findAskRows(host: Host, numbers: readonly number[]): Promise<void> { + await findRows(host, numbers, held?.entries ?? []); +} + +/** + * Reads the session's items again when `force`, or when the files behind them changed since the last read. Callers + * share a read in flight. Without the `tools` CLI there is nothing to read, and the last read stays. + */ +export function refreshAnswers(host: Host, why: string, force = false): Promise<SessionAnswers | undefined> { + if (reading) { + return reading; + } + + const work = (async () => { + const stamp = await stampOf(host); + const ownMoved = stamp.own !== heldStamp; + const hasItems = Boolean(held && held.entries.length + held.forms.length > 0); + const slow = hasItems && Date.now() - lastReadAt >= SLOW_MS; + + // A session the logs do not name has nothing to read: no `bun` starts for it. + if (!force && !held && stamp.lines === 0) { + held = { decisions: [], todos: [], entries: [], forms: [], at: Date.now() }; + heldStamp = stamp.own; + host.redraw(); + return held; + } + + if (!force && held && !ownMoved && !slow) { + return held; + } + + if (!(await genesisProbe(host)).tools) { + return held; + } + + const session = await host.sessionId(); + const done = await host + .run(["tools", "question", "inbox", "--session", session, "--json", "--all-items"], { timeoutMs: 20_000 }) + .catch((error: unknown) => ({ exitCode: -1, stdout: "", stderr: String(error) })); + const read = done.exitCode === 0 ? answersFrom(done.stdout) : undefined; + + if (!read) { + await logMod(host, "answers", { + event: "read-failed", + why, + exit: done.exitCode, + stderr: done.stderr.slice(-300), + }); + return held; + } + + held = read; + heldStamp = stamp.own; + lastReadAt = Date.now(); + await logMod(host, "answers", { event: "read", why, own: ownMoved, slow }); + await findRows( + host, + read.decisions.map((decision) => decision.n), + read.entries + ); + host.redraw(); + + return read; + })(); + + reading = work.finally(() => { + reading = undefined; + }); + + return reading; +} + +/** Starts the checks once per module load: the first now, then each tick; a read runs only when this session changed. */ +export function startAnswers(host: Host): void { + if (started) { + return; + } + + started = true; + // Not forced: a session the logs do not name reads nothing, so a reload of the mods starts no `bun` there. + void refreshAnswers(host, "start"); + host.every(TICK_MS, () => { + void refreshAnswers(host, "tick"); + }); +} + +/** After this session called a question tool: its items changed, so read them again now. */ +export function afterQuestionTool(host: Host, e: ToolCallInput): void { + const command = e.tool === "Bash" ? e.command : ""; + + if (QUESTION_TOOLS.has(e.tool) || /\btools question\b/.test(command)) { + void refreshAnswers(host, e.tool, true); + } +} + +/** + * Scrolls the transcript to where an item was asked. The scroll is the first call of the press: the engine moves a + * transcript row only while the call answers the person's own input. A row it cannot show (one from before a + * compaction is not drawn) falls back, for a decision, to its find link: the transcript search (ctrl+o) at its first + * ask, typed into this session's terminal by `tools claude cmux find`. + */ +export async function jumpTo(host: Host, key: string, target: { decision: number } | { entry: string }): Promise<void> { + const row = jumpRowOf(target); + const moved = row ? await host.scroll(row).catch((error: unknown) => ({ deny: String(error) })) : undefined; + const what = "decision" in target ? `DECISION ${target.decision}` : "the entry"; + + if (moved && !moved.deny) { + await logMod(host, "answers", { event: "jump", target, row, how: "scroll" }); + await pressed(host, key, `Scrolled to ${what}.`); + return; + } + + const reason = moved?.deny ?? "the transcript does not have its row"; + const link = + "decision" in target + ? decisionLink(await genesisProbe(host), await host.sessionId(), target.decision) + : undefined; + + await logMod(host, "answers", { event: "jump", target, row, how: link ? "find" : "none", reason }); + + if (!link) { + await pressed(host, key, `Cannot scroll to ${what}: ${reason}.`, "error"); + return; + } + + await pressed(host, key, `Not drawn here (${reason}); searching the transcript…`, "busy"); + await host.openUrl(link); + await pressed(host, key, `Opened the transcript search at ${what}. q or Esc leaves it.`); +} + +/** What a store command answered: its JSON, or the error it printed. */ +async function questionCommand(host: Host, args: readonly string[]): Promise<Record<string, unknown>> { + if (!(await genesisProbe(host)).tools) { + return { error: "the tools CLI is not installed" }; + } + + const session = await host.sessionId(); + const done = await host + .run(["tools", "question", "inbox", "--session", session, ...args], { timeoutMs: 20_000 }) + .catch((error: unknown) => ({ exitCode: -1, stdout: "", stderr: String(error) })); + let parsed: unknown; + + try { + parsed = JSON.parse(done.stdout); + } catch { + parsed = undefined; + } + + if (!isRecord(parsed)) { + return { error: done.stderr.trim().split("\n").at(-1) || `tools question exited ${done.exitCode}` }; + } + + return parsed; +} + +/** + * Stages an answer in the decision store, where the widget sees it too: `option` the picked letter ("" clears it), + * `text` a note in words ("" clears it). The sidebar shows the store's answer at once, then reads everything again. + * A decision the store does not know and cannot take from the last reply answers `{ error }`. + */ +export async function stageAnswer( + host: Host, + n: number, + answer: { option?: string; text?: string }, + { refresh = true }: { refresh?: boolean } = {} +): Promise<{ error?: string }> { + const args = ["draft", "--decision", String(n)]; + + if (answer.option !== undefined) { + args.push("--option", answer.option); + } + + if (answer.text !== undefined) { + args.push("--text", answer.text); + } + + const row = await questionCommand(host, args); + await logMod(host, "decisions", { event: "stage", n, ...answer, error: row.error }); + + if (typeof row.error === "string") { + return { error: row.error }; + } + + if (held) { + const draftOption = text(row.draftOption); + const draft = text(row.draft); + const state = text(row.state); + held = { + ...held, + decisions: held.decisions.map((decision) => + decision.n === n + ? { + ...decision, + status: isStatus(state) ? state : decision.status, + draftOption: draftOption || undefined, + draft: draft || undefined, + } + : decision + ), + }; + host.redraw(); + } + + if (refresh) { + void refreshAnswers(host, "stage", true); + } + + return {}; +} + +/** + * Sends the session's staged answers through its own prompt: the store marks them sent (route `prompt`) and gives + * the message, which the caller submits. Nothing is typed into the terminal, so a sidebar holding the keys is safe. + */ +export async function sendStaged(host: Host): Promise<{ text?: string; numbers?: number[]; error?: string }> { + const sent = await questionCommand(host, ["send", "--in-session"]); + await logMod(host, "decisions", { event: "send", promoted: sent.promoted, error: sent.error }); + void refreshAnswers(host, "send", true); + + if (typeof sent.error === "string") { + return { error: sent.error }; + } + + return { + text: text(sent.text), + numbers: Array.isArray(sent.numbers) ? sent.numbers.filter((n): n is number => typeof n === "number") : [], + }; +} diff --git a/plugins/genesis-tools-mods/hooks/ask-row.tsx b/plugins/genesis-tools-mods/hooks/ask-row.tsx new file mode 100644 index 0000000000..bcff5decd6 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/ask-row.tsx @@ -0,0 +1,102 @@ +// `[ask]` on a finished tool row: a press puts the row, quoted, into the prompt where the cursor is, so a question +// about a tool call, its output or the hook text in it needs no copy and no screenshot. The folded group line +// ("ran 3 shell commands") quotes each call's input; a single row also quotes its output, cut to MAX_OUTPUT. +// Each press goes to `mods-log/ask.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; +import { type Host, isRecord, logMod, settingsOf } from "./shared"; + +const MAX_OUTPUT = 800; +const MAX_INPUT = 300; + +function textOf(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (Array.isArray(value)) { + return value.map(textOf).join("\n"); + } + + if (isRecord(value)) { + if (typeof value.text === "string") { + return value.text; + } + + if (typeof value.stdout === "string") { + return [value.stdout, typeof value.stderr === "string" ? value.stderr : ""].filter(Boolean).join("\n"); + } + } + + return value === undefined ? "" : JSON.stringify(value); +} + +/** What a call was asked to do, in one short line: the command, the path, the pattern, else its JSON. */ +export function inputLine(tool: string, input: unknown): string { + const held = isRecord(input) ? input : {}; + const main = [held.command, held.file_path, held.path, held.pattern, held.description, held.prompt].find( + (value): value is string => typeof value === "string" + ); + const text = (main ?? JSON.stringify(input ?? {})).replace(/\s+/g, " ").trim(); + + return `${tool}: ${text.length > MAX_INPUT ? `${text.slice(0, MAX_INPUT)}…` : text}`; +} + +function cut(text: string): string { + const trimmed = text.trim(); + + return trimmed.length > MAX_OUTPUT + ? `${trimmed.slice(0, MAX_OUTPUT)}\n… (${trimmed.length - MAX_OUTPUT} more characters)` + : trimmed; +} + +/** The quote a press puts into the prompt: each line under `> `, then an empty line for the question. */ +export function quoteOf(rows: readonly { tool: string; input: unknown; output?: unknown }[]): string { + const body = rows + .map((row) => { + const output = row.output === undefined ? "" : cut(textOf(row.output)); + + return output ? `${inputLine(row.tool, row.input)}\n${output}` : inputLine(row.tool, row.input); + }) + .join("\n\n"); + + return `${body + .split("\n") + .map((line) => `> ${line}`) + .join("\n")}\n\n`; +} + +async function ask(host: Host, id: string, rows: Parameters<typeof quoteOf>[0]): Promise<void> { + const text = quoteOf(rows); + + await host.fill({ text, mode: "insert" }); + await logMod(host, "ask", { id, tools: rows.map((row) => row.tool), chars: text.length }); +} + +/** A finished tool row with `[ask]` beside it; the row as drawn while running, or with the mod off. */ +export async function askOnRow( + host: Host, + ui: ElementTable, + drawn: RenderElement, + row: { id: string; isRunning: boolean; calls: { tool: string; input: unknown; output?: unknown }[] } +): Promise<RenderElement> { + if (row.isRunning || !(await settingsOf(host)).askRow.enabled) { + return drawn; + } + + return withButton(ui, drawn, `ask-${row.id}`, () => ask(host, row.id, row.calls)); +} + +function withButton(ui: ElementTable, drawn: RenderElement, key: string, onPress: () => Promise<void>): RenderElement { + const { Box, Button } = ui; + + return ( + <Box key={`${key}-row`} flexDirection="row" gap={1} alignItems="flex-end"> + <Box key={`${key}-drawn`} flexGrow={1} flexShrink={1}> + {drawn} + </Box> + <Button key={key} dimColor onPress={() => void onPress()}> + ask + </Button> + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/band.tsx b/plugins/genesis-tools-mods/hooks/band.tsx new file mode 100644 index 0000000000..49e448c33f --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/band.tsx @@ -0,0 +1,24 @@ +// The band above the prompt: every feature's row (git, cache, agents, /tmp) stacked in a fixed order above what +// the engine and the plugins beneath draw there. A row is null when its feature is off or has nothing to say. +import type { ElementTable, RenderElement } from "claude-code"; + +export function bandTree( + ui: ElementTable, + rows: readonly (RenderElement | null)[], + below: RenderElement +): RenderElement { + const shown = rows.filter((row) => row !== null); + + if (shown.length === 0) { + return below; + } + + const { Box } = ui; + + return ( + <Box key="genesis-band" flexDirection="column"> + {shown} + {below} + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/cache-band.tsx b/plugins/genesis-tools-mods/hooks/cache-band.tsx new file mode 100644 index 0000000000..c4c518c116 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/cache-band.tsx @@ -0,0 +1,303 @@ +// The cache countdown. Each session writes `<agents>/mods-state/cache/<session>.json` when its main loop finishes a +// turn (the prompt cache was written then), and an `ended` mark when it ends. The band shows this session's minutes +// left dim, yellow under `mods.cacheBand.cautionMinutes` and red under `mods.cacheBand.warnMinutes`; with `others` +// on, it also shows every other session whose cache runs out inside the yellow window, so a reply that would rewrite +// a cold cache is visible from any open session. Reading the folder is a file listing, no process; it runs every 15 seconds. +// The times are live (`live.ts`): a yellow or red chip counts each second, a dim one each minute. After a compaction +// the chip says so and that the next reply starts a fresh cache, since there is nothing left to keep warm. +import type { ElementTable, RenderElement } from "claude-code"; +import type { CacheRow, CacheRows } from "../types"; +import { genesisProbe } from "./genesis"; +import { idleDueAt } from "./idle"; +import { liveAgo, liveLeft } from "./live"; +import { agentsDir, type Feature, type Host, isRecord, settingsOf } from "./shared"; + +let rows: CacheRows = []; + +/** Claude Code's prompt cache for a subscription session lives an hour after its last write. */ +const TTL_MS = 60 * 60_000; +const EVERY_MS = 15_000; + +interface CacheMark { + sessionId: string; + label: string; + lastAt: number; + ttlMs: number; + ended?: boolean; + /** When the main loop last compacted; at or after `lastAt` the old cache no longer matters. */ + compactedAt?: number; +} + +async function folder(host: Host): Promise<string | undefined> { + const dir = await agentsDir(host); + + return dir ? `${dir}/mods-state/cache` : undefined; +} + +async function labelOf(host: Host): Promise<string> { + const cwd = await host.cwd(); + const id = await host.sessionId(); + + return `${cwd.split("/").filter(Boolean).pop() ?? "~"}·${id.slice(0, 6)}`; +} + +async function mark(host: Host, why: "turn" | "ended" | "compacted"): Promise<void> { + const dir = await folder(host); + + if (!dir) { + return; + } + + const sessionId = await host.sessionId(); + const now = Date.now(); + const held: CacheMark = { + sessionId, + label: await labelOf(host), + lastAt: now, + ttlMs: TTL_MS, + ended: why === "ended", + ...(why === "compacted" ? { compactedAt: now } : {}), + }; + + // A turn writes the cache again; an end or a compaction keeps the time of the last write. + if (why !== "turn") { + const before = await host.read(`${dir}/${sessionId}.json`).catch(() => undefined); + + try { + const parsed: unknown = before ? JSON.parse(before) : undefined; + + if (isRecord(parsed) && typeof parsed.lastAt === "number") { + held.lastAt = parsed.lastAt; + } + + if (why === "ended" && isRecord(parsed) && typeof parsed.compactedAt === "number") { + held.compactedAt = parsed.compactedAt; + } + } catch { + // A broken mark is rewritten whole. + } + } + + await host.write(`${dir}/${sessionId}.json`, JSON.stringify(held)); +} + +/** This session's row, then the other live sessions inside the warning window, the soonest first. */ +export function cacheRowsOf(marks: readonly CacheMark[], ownId: string, warnMs: number, now: number): CacheRows { + const out: CacheRow[] = []; + + for (const held of marks) { + const expiresAt = held.lastAt + held.ttlMs; + const own = held.sessionId === ownId; + const compacted = held.compactedAt !== undefined && held.compactedAt >= held.lastAt; + + if (own) { + out.unshift({ + sessionId: held.sessionId, + label: held.label, + expiresAt, + own, + compacted, + ...(compacted ? { compactedAt: held.compactedAt } : {}), + }); + continue; + } + + if (!held.ended && !compacted && expiresAt > now && expiresAt - now <= warnMs) { + out.push({ sessionId: held.sessionId, label: held.label, expiresAt, own, compacted }); + } + } + + return [ + ...out.filter((row) => row.own), + ...out.filter((row) => !row.own).sort((a, b) => a.expiresAt - b.expiresAt), + ]; +} + +async function scan(host: Host): Promise<void> { + const settings = (await settingsOf(host)).cacheBand; + const dir = await folder(host); + + if (!settings.enabled || !dir) { + rows = []; + host.redraw(); + return; + } + + const ownId = await host.sessionId(); + const entries = settings.others + ? await host.list(dir).catch(() => []) + : [{ name: `${ownId}.json`, kind: "file" as const }]; + const marks: CacheMark[] = []; + + for (const entry of entries) { + if (entry.kind !== "file" || !entry.name.endsWith(".json")) { + continue; + } + + const text = await host.read(`${dir}/${entry.name}`).catch(() => undefined); + + try { + const parsed: unknown = text ? JSON.parse(text) : undefined; + + if (isRecord(parsed) && typeof parsed.sessionId === "string" && typeof parsed.lastAt === "number") { + marks.push({ + sessionId: parsed.sessionId, + label: String(parsed.label ?? parsed.sessionId.slice(0, 6)), + lastAt: parsed.lastAt, + ttlMs: typeof parsed.ttlMs === "number" ? parsed.ttlMs : TTL_MS, + ended: parsed.ended === true, + ...(typeof parsed.compactedAt === "number" ? { compactedAt: parsed.compactedAt } : {}), + }); + } + } catch { + // A mark half written by another session is read on the next pass. + } + } + + rows = cacheRowsOf(marks, ownId, alertMs(settings), Date.now()); + host.redraw(); +} + +export const cacheBand: Feature = { + async start(host) { + host.every(EVERY_MS, () => { + void scan(host); + }); + void scan(host); + }, + async afterTurn(host, e) { + if (e.agentId === undefined && (await settingsOf(host)).cacheBand.enabled) { + await mark(host, "turn"); + void scan(host); + } + }, + async compacted(host, agentId) { + if (agentId === undefined && (await settingsOf(host)).cacheBand.enabled) { + await mark(host, "compacted"); + void scan(host); + } + }, +}; + +/** The `ended` mark, so other sessions stop counting this one down. */ +export async function markEnded(host: Host): Promise<void> { + await mark(host, "ended").catch(() => undefined); +} + +/** The wider of the two windows: a chip turns yellow, or red, from this much time left. */ +function alertMs(settings: { warnMinutes: number; cautionMinutes: number }): number { + return Math.max(settings.warnMinutes, settings.cautionMinutes) * 60_000; +} + +/** `red` under the warning, `yellow` under the caution, undefined above both. */ +export function cacheTone(left: number, warnMs: number, cautionMs: number): "red" | "yellow" | undefined { + if (left <= warnMs) { + return "red"; + } + + return left <= cautionMs ? "yellow" : undefined; +} + +/** Focuses the cmux pane the row's session runs in (`tools claude cmux focus`); says so when no pane has it. */ +async function focusSession(host: Host, row: CacheRow): Promise<void> { + try { + // `--exact`: a pane that only shows the id on screen (ncdu listing /tmp, an agent that discussed it) is not + // the session's; without it a press once focused `ncdu /tmp` (2026-10-25). + const run = await host.run(["tools", "claude", "cmux", "focus", row.sessionId, "--first", "--exact"], { + timeoutMs: 15_000, + }); + + if (run.exitCode !== 0) { + host.toast(`No cmux pane is known to run ${row.label}; it may have continued as another session`); + } + } catch { + host.toast(`Could not focus ${row.label}: tools claude cmux focus did not run`); + } +} + +/** The band's cache row, or null with nothing to show. */ +export async function cacheRow(host: Host, ui: ElementTable): Promise<RenderElement | null> { + const held = rows; + const settings = (await settingsOf(host)).cacheBand; + + if (!settings.enabled || held.length === 0) { + return null; + } + + const { Box, Button, Text } = ui; + // A press runs `tools claude cmux focus`: without the GenesisTools CLI the chip is plain text. + const canFocus = (await genesisProbe(host)).tools; + const now = Date.now(); + const warnMs = settings.warnMinutes * 60_000; + const cautionMs = settings.cautionMinutes * 60_000; + const cell = (row: CacheRow) => { + const left = row.expiresAt - now; + const name = row.own ? "cache" : `${row.label} cache`; + + // After a compaction the next prompt is a new, small context: there is no cache worth keeping warm. + if (row.compacted) { + return ( + <Text key={row.sessionId} dimColor> + {`compacted ${row.compactedAt ? liveAgo(row.compactedAt, { unit: "minute" }) : ""} · cache restarts`} + </Text> + ); + } + + if (left <= 0) { + return row.own ? ( + <Text key={row.sessionId} dimColor> + {`${name} cold`} + </Text> + ) : null; + } + + const tone = cacheTone(left, warnMs, cautionMs); + const text = tone ? ( + <Text backgroundColor={tone} color={tone === "red" ? "white" : "black"} bold> + {` ${name} ${liveLeft(row.expiresAt)} `} + </Text> + ) : ( + <Text dimColor>{`${name} ${liveLeft(row.expiresAt, { unit: "minute" })}`}</Text> + ); + + // The own chip also counts down to the idle compaction, which comes before the cache runs out. + const due = row.own ? idleDueAt() : undefined; + const idle = + due !== undefined && due > now ? ( + <Text color={due - now <= warnMs ? "cyan" : undefined} dimColor={due - now > warnMs}> + {` · AC in ${liveLeft(due)}`} + </Text> + ) : null; + + // Another session's chip is a Button: a press focuses the cmux pane that session runs in. + if (!row.own && !canFocus) { + return <Box key={row.sessionId}>{text}</Box>; + } + + return row.own ? ( + <Box key={row.sessionId}> + {text} + {idle} + </Box> + ) : ( + <Button key={row.sessionId} plain onPress={() => void focusSession(host, row)}> + {text} + </Button> + ); + }; + + // Every chip keeps its width and the row is cut at its right edge: a full row once squeezed the own chip to nothing. + return ( + <Box key="cache-band" flexDirection="row" gap={1} overflow="hidden"> + {held.map((row) => { + const drawn = cell(row); + + return drawn ? ( + <Box key={`chip-${row.sessionId}`} flexShrink={0}> + {drawn} + </Box> + ) : null; + })} + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/checkouts.ts b/plugins/genesis-tools-mods/hooks/checkouts.ts new file mode 100644 index 0000000000..01502ef704 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/checkouts.ts @@ -0,0 +1,786 @@ +// The checkouts a session works in, for the git band: the git root of each file the session writes, and of each +// folder where it changes git state (`git -C <dir> commit`, `cd <dir> && git rebase`). A file git ignores does not +// count, so a plan a worktree session writes into the main checkout's `.claude/plans/` leaves the main checkout out. +// Reads and read-only git (status, log, diff) count nothing. A folder in a shell variable the same command set +// (`W=/tmp/x` then `git -C "$W" commit`) counts, and `git worktree add <path>` counts the new worktree. +// +// Each checkout keeps the branches the session worked on there: the one checked out at each write, and each one a +// command made or pushed (`switch -c`, `checkout -b`, `worktree add -b`, `push origin <branch>`), with the main +// checkout it belongs to. So a worktree the session removed, or a branch it switched away from, still has its PR. +// +// Each session's list lives in the plugin store, so it outlives a hot reload and a resume; the 40 sessions touched +// last are kept. A session with no list yet starts from the GenesisTools file journal +// (`~/.genesis-tools/claude-code/sessions/<id>.json`), each checkout dated by the newest change to one of its files. +// +// The person can also add a folder by hand (`/mod-git add <folder>`, the Git sidebar's field): its checkout shows +// in this session's band until removed. `folderSuggestions` proposes folders for that, from zoxide when it is +// installed (`GenesisPlayg` finds the GenesisPlayground path) and from the folder a typed `/` or `~/` path names. +import type { FsEntry } from "claude-code"; +import type { Host } from "./shared"; + +export interface Touch { + root: string; + at: number; + /** The main checkout: the root itself, or for a linked worktree the folder that holds its `.git`. */ + repo?: string; + /** The branches the session worked on here, the last first, at most `KEEP_BRANCHES`. */ + branches?: string[]; +} + +/** One folder a command changes git state in, with the branches it names as made or pushed. */ +export interface GitWrite { + dir: string; + branches: string[]; +} + +const STORE_KEY = "git-band-checkouts"; +const ADDED_KEY = "git-band-added"; +const ZOXIDE_TTL_MS = 60_000; +const KEEP_SESSIONS = 40; +const KEEP_ROOTS = 12; +const KEEP_BRANCHES = 6; +/** The journal's newest files the first list is built from. */ +const SEED_FILES = 200; +const WRITE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]); +const GIT_WRITES = new Set([ + "add", + "am", + "apply", + "checkout", + "cherry-pick", + "commit", + "merge", + "mv", + "pull", + "push", + "rebase", + "reset", + "restore", + "revert", + "rm", + "stash", + "switch", +]); + +type Ledger = Record<string, Touch[]>; + +/** This session's list, newest first, while this module lives; the store holds it across reloads. */ +let held: { session: string; touches: Touch[] } | undefined; +/** The folders this session added by hand, newest first, as git roots. */ +let added: { session: string; roots: string[] } | undefined; +let zoxide: { at: number; dirs: string[] } | undefined; +let loading: Promise<Touch[]> | undefined; +const rootByDir = new Map<string, string | null>(); + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parentOf(path: string): string { + return path.slice(0, path.lastIndexOf("/")) || "/"; +} + +function absolute(path: string, home: string | undefined): string | undefined { + if (path.startsWith("/")) { + return path.replace(/\/+$/, "") || "/"; + } + + return home && path.startsWith("~/") ? `${home}/${path.slice(2)}`.replace(/\/+$/, "") : undefined; +} + +/** The absolute files a tool call wrote: `file_path` of Edit, Write and MultiEdit, `notebook_path` of NotebookEdit. */ +export function writtenFiles(input: unknown): string[] { + if (!isRecord(input) || typeof input.tool !== "string" || !WRITE_TOOLS.has(input.tool)) { + return []; + } + + return [input.file_path, input.notebook_path].filter( + (path): path is string => typeof path === "string" && path.startsWith("/") + ); +} + +/** + * A shell word without its quotes. Outside single quotes `$NAME` and `${NAME}` take the value the command set + * before; a name it did not set stays as written, so the word is no folder. + */ +function shellWord(word: string, vars: ReadonlyMap<string, string>): string { + if (/^'.*'$/s.test(word)) { + return word.slice(1, -1); + } + + const bare = /^".*"$/s.test(word) ? word.slice(1, -1) : word; + + return bare.replace( + /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, + (all: string, braced?: string, plain?: string) => vars.get(braced ?? plain ?? "") ?? all + ); +} + +/** A folder from a command's word: absolute, `~/`, or relative to the folder the command is in; `..` resolved. */ +function resolveIn(value: string, dir: string | undefined, home: string | undefined): string | undefined { + const path = absolute(value, home) ?? (dir && value && !value.startsWith("$") ? `${dir}/${value}` : undefined); + + if (!path) { + return undefined; + } + + const parts: string[] = []; + + for (const part of path.split("/")) { + if (part === "..") { + parts.pop(); + } else if (part && part !== ".") { + parts.push(part); + } + } + + return `/${parts.join("/")}`; +} + +/** The branch name of a ref a push or a switch names: `refs/heads/x` and `+x` as `x`; `HEAD` and tags as none. */ +function branchOf(ref: string): string | undefined { + const name = ref.replace(/^\+/, "").replace(/^refs\/heads\//, ""); + + return !name || name === "HEAD" || name.startsWith("refs/") || name.startsWith("-") ? undefined : name; +} + +/** The branches a git command makes or pushes: `switch -c x`, `checkout -b x`, `push origin x` or `HEAD:x`. */ +function namedBranches(verb: string, args: readonly string[]): string[] { + if (verb === "switch" || verb === "checkout") { + const at = args.findIndex((arg) => ["-c", "-C", "-b", "-B", "--create", "--force-create"].includes(arg)); + const made = at >= 0 ? branchOf(args[at + 1] ?? "") : undefined; + + return made ? [made] : []; + } + + if (verb !== "push" || args.some((arg) => arg === "-d" || arg === "--delete" || arg === "--tags")) { + return []; + } + + const valued = new Set(["-o", "--push-option", "--repo", "--receive-pack", "--exec"]); + const positional: string[] = []; + + for (let at = 0; at < args.length; at += 1) { + const arg = args[at] ?? ""; + + if (valued.has(arg)) { + at += 1; + } else if (!arg.startsWith("-")) { + positional.push(arg); + } + } + + // The first word is the remote; each later one a refspec, `src:dst` pushing into `dst`, `:dst` deleting it. + return positional + .slice(1) + .filter((spec) => !spec.startsWith(":")) + .map((spec) => branchOf(spec.includes(":") ? spec.slice(spec.indexOf(":") + 1) : spec)) + .filter((name): name is string => name !== undefined); +} + +/** `git worktree add [-b <branch>] <path> [<commit>]`: the new worktree's path as written, and the branch it makes. */ +function worktreeAdd(args: readonly string[]): { path?: string; branch?: string } { + const valued = new Set(["-b", "-B", "--reason"]); + let branch: string | undefined; + + for (let at = 0; at < args.length; at += 1) { + const arg = args[at] ?? ""; + + if (valued.has(arg)) { + branch = arg === "--reason" ? branch : branchOf(args[at + 1] ?? ""); + at += 1; + } else if (!arg.startsWith("-")) { + return { path: arg, branch }; + } + } + + return { branch }; +} + +/** + * The folders a Bash command changes git state in: `git -C <dir> <verb>`, or `git <verb>` after `cd <dir>` in the + * same command, for a verb that writes (commit, rebase, stash, push…), and the path of `git worktree add`. A folder + * counts when it is absolute, `~/`, or in a variable the command set before it (`W=/tmp/x` then `git -C "$W"`); + * the shell's own folder is not known here. Each folder comes with the branches the command made or pushed there. + */ +export function gitWrites(command: string, home: string | undefined): GitWrite[] { + const writes = new Map<string, Set<string>>(); + const vars = new Map<string, string>(home ? [["HOME", home]] : []); + let here: string | undefined; + const note = (dir: string, branches: readonly string[]) => { + writes.set(dir, new Set([...(writes.get(dir) ?? []), ...branches])); + }; + + for (const part of command.split(/&&|\|\||[;|\n]/)) { + const raw = part.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + let start = raw[0] === "export" ? 1 : 0; + const sets: [string, string][] = []; + + while (raw[start] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(raw[start] ?? "")) { + const word = raw[start] ?? ""; + const eq = word.indexOf("="); + sets.push([word.slice(0, eq), shellWord(word.slice(eq + 1), vars)]); + start += 1; + } + + const words = raw.slice(start).map((word) => shellWord(word, vars)); + + // `W=/x` alone sets W for the rest of the command; `W=/x git …` sets it for that one command only. + if (words.length === 0) { + for (const [name, value] of sets) { + vars.set(name, value); + } + + continue; + } + + if (words[0] === "cd") { + here = words[1] ? resolveIn(words[1], here, home) : undefined; + continue; + } + + if (words[0] !== "git") { + continue; + } + + let dir = here; + let at = 1; + + while (words[at]?.startsWith("-")) { + const flag = words[at]; + + if (flag === "-C" || flag === "-c") { + if (flag === "-C") { + dir = resolveIn(words[at + 1] ?? "", dir, home); + } + + at += 2; + } else { + at += 1; + } + } + + const verb = words[at] ?? ""; + const args = words.slice(at + 1); + + if (verb === "worktree" && args[0] === "add") { + const added = worktreeAdd(args.slice(1)); + const path = added.path ? resolveIn(added.path, dir, home) : undefined; + + if (path) { + note(path, added.branch ? [added.branch] : []); + } + } else if (dir && GIT_WRITES.has(verb)) { + note(dir, namedBranches(verb, args)); + } + } + + return [...writes].map(([dir, branches]) => ({ dir, branches: [...branches] })); +} + +/** The folders of `gitWrites`, without their branches. */ +export function gitWriteDirs(command: string, home: string | undefined): string[] { + return gitWrites(command, home).map((write) => write.dir); +} + +/** The git root above a file or folder: the nearest folder holding `.git` (a folder, or a worktree's file). */ +async function rootAbove(host: Host, start: string): Promise<string | null> { + const walked: string[] = []; + let dir = start; + let found: string | null = null; + + while (dir !== "/") { + const known = rootByDir.get(dir); + + if (known !== undefined) { + found = known; + break; + } + + walked.push(dir); + + if (await host.exists(`${dir}/.git`).catch(() => false)) { + found = dir; + break; + } + + dir = parentOf(dir); + } + + for (const each of walked) { + rootByDir.set(each, found); + } + + return found; +} + +/** The files of `root` git does not ignore, in one `git check-ignore --stdin`. */ +async function notIgnored(host: Host, root: string, files: string[]): Promise<string[]> { + const run = await host + .run(["git", "-C", root, "check-ignore", "--stdin"], { stdin: `${files.join("\n")}\n`, timeoutMs: 10_000 }) + .catch(() => undefined); + + // 0: some are ignored, listed one per line. 1: none is. Anything else: git failed, so the root does not count. + if (run?.exitCode === 1) { + return files; + } + + if (run?.exitCode !== 0) { + return []; + } + + const ignored = new Set(run.stdout.split("\n").filter(Boolean)); + + return files.filter((file) => !ignored.has(file)); +} + +/** Each checkout of these files, dated by the newest change to a file of it that git does not ignore. */ +async function datedRoots(host: Host, files: string[]): Promise<Touch[]> { + const byRoot = new Map<string, string[]>(); + + for (const file of files) { + const root = await rootAbove(host, parentOf(file)); + + if (root) { + byRoot.set(root, [...(byRoot.get(root) ?? []), file]); + } + } + + const touches = await Promise.all( + [...byRoot].map(async ([root, held]): Promise<Touch | undefined> => { + const kept = await notIgnored(host, root, [...new Set(held)]); + const times = await Promise.all( + kept.map((file) => + host.stat(file).then( + (stat) => stat.mtimeMs, + () => 0 + ) + ) + ); + const at = Math.max(0, ...times); + + return at > 0 ? { root, at } : undefined; + }) + ); + + return touches.filter((touch): touch is Touch => touch !== undefined).sort((a, b) => b.at - a.at); +} + +/** The first list of a session the store has none for: the checkouts of the files its GenesisTools journal names. */ +async function fromJournal(host: Host, session: string): Promise<Touch[]> { + const home = (await host.env("GENESIS_TOOLS_HOME")) ?? (await host.env("HOME")); + const text = home + ? await host.read(`${home}/.genesis-tools/claude-code/sessions/${session}.json`).catch(() => undefined) + : undefined; + let files: string[] = []; + + try { + const parsed: unknown = text ? JSON.parse(text) : undefined; + + if (isRecord(parsed) && Array.isArray(parsed.files)) { + files = parsed.files + .filter((file): file is string => typeof file === "string") + .slice(-SEED_FILES) + .map((file) => { + try { + return decodeURIComponent(file); + } catch { + return file; + } + }) + .filter((file) => file.startsWith("/")); + } + } catch { + files = []; + } + + return (await datedRoots(host, files)).slice(0, KEEP_ROOTS); +} + +async function readLedger(host: Host, key = STORE_KEY): Promise<Ledger> { + const value = await host.storeGet(key).catch(() => undefined); + const ledger: Ledger = {}; + + if (isRecord(value)) { + for (const [session, list] of Object.entries(value)) { + if (Array.isArray(list)) { + ledger[session] = list.filter( + (touch): touch is Touch => + isRecord(touch) && typeof touch.root === "string" && typeof touch.at === "number" + ); + } + } + } + + return ledger; +} + +async function save(host: Host, session: string, touches: Touch[], key = STORE_KEY): Promise<void> { + const ledger = { ...(await readLedger(host, key)), [session]: touches }; + const newest = (list: Touch[]) => Math.max(0, ...list.map((touch) => touch.at)); + const kept = Object.entries(ledger) + .sort((a, b) => newest(b[1]) - newest(a[1])) + .slice(0, KEEP_SESSIONS); + + await host.storeSet(key, Object.fromEntries(kept)).catch(() => undefined); +} + +async function load(host: Host, session: string): Promise<Touch[]> { + const stored = (await readLedger(host))[session]; + + if (stored && stored.length > 0) { + return stored; + } + + const seeded = await fromJournal(host, session); + + if (seeded.length > 0) { + await save(host, session, seeded); + } + + return seeded; +} + +/** This session's checkouts, the one it worked in last first. */ +export async function sessionCheckouts(host: Host): Promise<Touch[]> { + const session = await host.sessionId(); + + if (held?.session === session) { + return held.touches; + } + + loading ??= load(host, session).finally(() => { + loading = undefined; + }); + const touches = await loading; + held = { session, touches }; + + return touches; +} + +/** + * A checkout's main checkout and the branch it has checked out, from its `.git` files without running git: a linked + * worktree's `.git` file points into `<main>/.git/worktrees/<name>`, whose `HEAD` names the branch. + */ +export async function headOf(host: Host, root: string): Promise<{ repo: string; branch?: string }> { + const dotGit = `${root}/.git`; + let gitDir = dotGit; + let repo = root; + + if ((await host.stat(dotGit).catch(() => undefined))?.kind === "file") { + const pointed = /^gitdir:\s*(.+)$/m.exec(await host.read(dotGit).catch(() => ""))?.[1]?.trim(); + + if (pointed) { + gitDir = resolveIn(pointed, root, undefined) ?? pointed; + const at = gitDir.indexOf("/.git/worktrees/"); + repo = at > 0 ? gitDir.slice(0, at) : repo; + } + } + + const head = await host.read(`${gitDir}/HEAD`).catch(() => ""); + + return { repo, branch: /^ref: refs\/heads\/(.+)$/m.exec(head)?.[1]?.trim() }; +} + +/** The branches first, then the ones before them, each once, at most `KEEP_BRANCHES`. */ +function withBranches(first: readonly (string | undefined)[], before: readonly string[] = []): string[] { + const names = first.filter((name): name is string => name !== undefined); + + return [...new Set([...names, ...before])].slice(0, KEEP_BRANCHES); +} + +/** + * Records the checkouts a finished tool call worked in, each with its branch and the branches the command made or + * pushed there. True when one of them was not already the newest, so the band's rows may have moved. + */ +export async function noteToolCall(host: Host, input: unknown): Promise<boolean> { + const home = await host.env("HOME"); + const files = writtenFiles(input); + const command = isRecord(input) && input.tool === "Bash" && typeof input.command === "string" ? input.command : ""; + const writes = command ? gitWrites(command, home) : []; + + if (files.length === 0 && writes.length === 0) { + return false; + } + + const now = Date.now(); + const named = new Map<string, string[]>(); + + for (const touch of await datedRoots(host, files)) { + named.set(touch.root, []); + } + + for (const write of writes) { + const root = await rootAbove(host, write.dir); + + if (root !== null) { + named.set(root, [...(named.get(root) ?? []), ...write.branches]); + } + } + + const roots = [...named.keys()]; + + if (roots.length === 0) { + return false; + } + + const before = await sessionCheckouts(host); + const fresh = await Promise.all( + roots.map(async (root): Promise<Touch> => { + const head = await headOf(host, root); + const held = before.find((touch) => touch.root === root); + + return { + root, + at: now, + repo: head.repo, + branches: withBranches([head.branch, ...(named.get(root) ?? [])], held?.branches), + }; + }) + ); + const touches = [...fresh, ...before.filter((touch) => !roots.includes(touch.root))].slice(0, KEEP_ROOTS); + const session = await host.sessionId(); + + held = { session, touches }; + await save(host, session, touches); + + return roots.some((root) => before[0]?.root !== root); +} + +/** + * The checkouts the band shows: the session's own folder while it has worked in no checkout (or always, with + * `cwd: "always"`), the folders added by hand, then up to `checkouts` the session worked in, the last first. + */ +export function pickRoots( + touches: readonly Touch[], + cwdRoot: string | undefined, + settings: { checkouts: number; cwd: "touched" | "always" }, + addedRoots: readonly string[] = [] +): string[] { + const worked = touches + .map((touch) => touch.root) + .filter((root) => !addedRoots.includes(root)) + .slice(0, settings.checkouts); + const own = cwdRoot && (settings.cwd === "always" || worked.length === 0) ? [cwdRoot] : []; + + return [...new Set([...own, ...addedRoots, ...worked])]; +} + +/** Drops a checkout the session worked in from its band, until the session works there again. */ +export async function hideCheckout(host: Host, root: string): Promise<void> { + const session = await host.sessionId(); + const touches = (await sessionCheckouts(host)).filter((touch) => touch.root !== root); + + held = { session, touches }; + await save(host, session, touches); +} + +/** The folders this session added by hand, newest first, as git roots. */ +export async function addedCheckouts(host: Host): Promise<string[]> { + const session = await host.sessionId(); + + if (added?.session !== session) { + const stored = (await readLedger(host, ADDED_KEY))[session] ?? []; + added = { session, roots: stored.map((touch) => touch.root) }; + } + + return added.roots; +} + +async function saveAdded(host: Host, roots: string[]): Promise<void> { + const session = await host.sessionId(); + const now = Date.now(); + + added = { session, roots }; + await save( + host, + session, + roots.map((root) => ({ root, at: now })), + ADDED_KEY + ); +} + +/** A typed folder as an absolute path: `~/x` under the home, a bare relative one under the session's folder. */ +async function expand(host: Host, typed: string): Promise<string> { + const home = await host.env("HOME"); + const path = absolute(typed.trim(), home); + + return path ?? `${await host.cwd()}/${typed.trim()}`.replace(/\/+$/, ""); +} + +/** Adds the checkout of a folder to this session's band; the error says why not. */ +export async function addCheckout(host: Host, typed: string): Promise<{ root: string } | { error: string }> { + const path = await expand(host, typed); + const stat = await host.stat(path).catch(() => undefined); + + if (stat?.kind !== "dir") { + return { error: `${path} is not a folder` }; + } + + const root = await rootAbove(host, path); + + if (!root) { + return { error: `${path} is in no git checkout` }; + } + + await saveAdded(host, [root, ...(await addedCheckouts(host)).filter((held) => held !== root)]); + + return { root }; +} + +/** Removes a folder added by hand, named by its path or its folder name; false when none matches. */ +export async function removeAdded(host: Host, typed: string): Promise<string | undefined> { + const roots = await addedCheckouts(host); + const path = await expand(host, typed); + const match = roots.find((root) => root === path || root.slice(root.lastIndexOf("/") + 1) === typed.trim()); + + if (match) { + await saveAdded( + host, + roots.filter((root) => root !== match) + ); + } + + return match; +} + +export interface FolderSuggestion { + /** The folder with the casing it has on disk. */ + path: string; + from: "zoxide" | "folder"; + /** It is in a git checkout, so `/mod-git add` takes it. */ + isGit: boolean; +} + +/** zoxide's folders, best first; none without zoxide. One `zoxide query --list` per minute. */ +async function zoxideDirs(host: Host): Promise<string[]> { + if (zoxide && Date.now() - zoxide.at < ZOXIDE_TTL_MS) { + return zoxide.dirs; + } + + const run = await host.run(["zoxide", "query", "--list"], { timeoutMs: 3_000 }).catch(() => undefined); + const dirs = run?.exitCode === 0 ? run.stdout.split("\n").filter((dir) => dir.startsWith("/")) : []; + + zoxide = { at: Date.now(), dirs }; + + return dirs; +} + +/** zoxide's folders that match a name: the folder name starting with it first, then holding it, then the path. */ +export function rankByName(dirs: readonly string[], typed: string): string[] { + const needle = typed.toLowerCase(); + const nameOf = (dir: string) => dir.slice(dir.lastIndexOf("/") + 1).toLowerCase(); + const tier = (dir: string) => + nameOf(dir).startsWith(needle) + ? 0 + : nameOf(dir).includes(needle) + ? 1 + : dir.toLowerCase().includes(needle) + ? 2 + : 3; + + return dirs + .map((dir, index) => ({ dir, index, tier: tier(dir) })) + .filter((entry) => entry.tier < 3) + .sort((a, b) => a.tier - b.tier || a.index - b.index) + .map((entry) => entry.dir); +} + +/** + * The folder as it is on disk: the casing of each name, from its parent's listing, since zoxide keeps whatever casing + * a `cd` typed (`ReservineBack` and `Reservineback` on a case-blind disk). Undefined when it is gone. + */ +async function onDisk( + host: Host, + path: string, + listings: Map<string, Promise<FsEntry[]>> +): Promise<string | undefined> { + const parent = parentOf(path); + const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + + if (!listings.has(parent)) { + listings.set( + parent, + host.list(parent).catch(() => []) + ); + } + + const entry = (await listings.get(parent))?.find( + (held) => held.name.toLowerCase() === name && (held.kind === "dir" || held.kind === "other") + ); + + return entry ? `${parent === "/" ? "" : parent}/${entry.name}` : undefined; +} + +/** + * Folders for what the person typed: for a `/` or `~/` path, the folders in the folder it names that start with + * its last part, then zoxide's under it; for a name, zoxide's folders that match it. Each with its casing on disk, + * once, and gone ones left out. + */ +export async function folderSuggestions(host: Host, typed: string, limit = 8): Promise<FolderSuggestion[]> { + const text = typed.trim(); + + if (!text) { + return []; + } + + const home = await host.env("HOME"); + const path = absolute(text, home); + const found: { path: string; from: FolderSuggestion["from"] }[] = []; + + if (path) { + const dir = text.endsWith("/") ? path : parentOf(path); + const start = text.endsWith("/") ? "" : path.slice(path.lastIndexOf("/") + 1).toLowerCase(); + const entries = await host.list(dir).catch(() => []); + + for (const entry of entries) { + const name = entry.name.toLowerCase(); + + if (entry.kind === "dir" && name.startsWith(start) && (start.startsWith(".") || !name.startsWith("."))) { + found.push({ path: `${dir === "/" ? "" : dir}/${entry.name}`, from: "folder" }); + } + } + + found.sort((a, b) => a.path.localeCompare(b.path)); + + for (const zdir of await zoxideDirs(host)) { + if (zdir.toLowerCase().startsWith(path.toLowerCase()) && zdir !== path) { + found.push({ path: zdir, from: "zoxide" }); + } + } + } else { + for (const zdir of rankByName(await zoxideDirs(host), text)) { + found.push({ path: zdir, from: "zoxide" }); + } + } + + const listings = new Map<string, Promise<FsEntry[]>>(); + const seen = new Set<string>(); + const out: FolderSuggestion[] = []; + + for (const candidate of found) { + if (out.length >= limit) { + break; + } + + const real = candidate.from === "folder" ? candidate.path : await onDisk(host, candidate.path, listings); + + if (!real || seen.has(real.toLowerCase())) { + continue; + } + + seen.add(real.toLowerCase()); + out.push({ path: real, from: candidate.from, isGit: (await rootAbove(host, real)) !== null }); + } + + return out; +} + +/** Forgets this module's memory: a test sets up another session. */ +export function forgetCheckouts(): void { + held = undefined; + added = undefined; + zoxide = undefined; + loading = undefined; + rootByDir.clear(); +} diff --git a/plugins/genesis-tools-mods/hooks/decisions.tsx b/plugins/genesis-tools-mods/hooks/decisions.tsx new file mode 100644 index 0000000000..1a0df6c6ff --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/decisions.tsx @@ -0,0 +1,797 @@ +// The Decisions sidebar (`/decisions`, or the `decisions` chip in the band): every `❓ DECISION N` of this session, +// as the answer MCP's decision log has it (`answers.ts`), grouped by what it waits for. A waiting one opens to its +// prompt, its options as Buttons and a field for an answer in the person's own words. A press stages the answer in +// the decision store (`tools question inbox draft`; a second press unstages), so the widget shows the same pick, and a +// pick made in the widget shows here. [send] sends the session's staged answers through its own prompt, the store +// recording them as sent; [put in prompt] leaves them in the prompt to edit first. [jump to] scrolls the transcript to +// where the decision was first asked. A decision the store cannot take (asked in the chat before the last reply) is +// answered by putting `D16: a)` in the prompt. +// +// The session's own messages count too, so the sidebar works without the `tools` CLI and before the log has a new +// decision: a first ask is a `❓ DECISION N` that is not a link (a repeat is `[❓ DECISION N](…)`), its options are +// the `a)` lines under it, and a later user message answers it (`D16: a)`, `D16 b)`, `16 a)`, `D16: own words`) or a +// later assistant message says it replaces it. +import type { ElementTable, RenderElement } from "claude-code"; +import type { DecisionOption, OpenDecision, OpenDecisions, StagedDecisions } from "../types"; +import { + afterQuestionTool, + answersOf, + type DecisionStatus, + findAskRows, + jumpTo, + refreshAnswers, + sendStaged, + stageAnswer, + startAnswers, +} from "./answers"; +import { liveAgo } from "./live"; +import { type Feature, type Host, logMod, settingsOf } from "./shared"; +import { + actionRow, + dimNote, + headerRow, + type Kit, + listRow, + type Pressable, + plural, + pressed, + sectionTitle, + sidebarFrame, + wrapWords, +} from "./sidebar"; + +export const DECISIONS_PANE = "genesis-decisions"; +/** What the session's messages say: the decisions still open there, and the numbers a user message answered. */ +let chat: { open: OpenDecisions; answered: Set<number> } = { open: [], answered: new Set() }; +/** Rows opened or closed against `mods.decisions.expand`. */ +const flipped = new Set<number>(); +/** The answered, done and closed decisions shown or folded against `mods.decisions.history`. */ +let historyFlipped = false; +/** + * The fields whose drawn value carries a trailing zero-width mark. The engine empties a field after Enter and keeps + * it empty until a drawing passes a `value` different from the one drawn before; the mark flips on each Enter, so the + * next drawing differs and the field shows the staged text again. What the field reports is read without it. + */ +const marked = new Set<number>(); +const MARK = "\u2060"; +/** What each answer field holds before Enter stages it, so a redraw or a press elsewhere keeps the typed text. */ +const typed = new Map<number, string>(); + +const ASK = /(^|[^[])❓\s*\**\s*DECISION\s+(\d+)\**\s*[:.]?\s*(.*)$/; +const OPTION = /^\s*(?:[-*]\s+)?\**\(?([a-z])\)\**\s*(.+)$/; +const ANSWER_LETTER = /\bD(?:ECISION)?\s*(\d+)\s*[:-]?\s*\(?([a-z])\)/gi; +const ANSWER_BARE = /^\s*(\d+)\s+([a-z])\)/gm; +const ANSWER_OWN = /^\s*D(\d+)\s*:\s*(.+)$/gm; +const REPLACES = /replaces?\s+\[?❓\s*DECISION\s+(\d+)/gi; + +function clean(text: string): string { + return text + .replace(/\*\*/g, "") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .trim(); +} + +/** The decisions an assistant text asks for the first time, with the options listed under each. */ +export function asksIn(text: string): OpenDecision[] { + const lines = text.split("\n"); + const found: OpenDecision[] = []; + + for (let i = 0; i < lines.length; i++) { + const match = ASK.exec(lines[i] ?? ""); + + if (!match) { + continue; + } + + let title = clean(match[3] ?? ""); + const options: DecisionOption[] = []; + let j = i + 1; + + for (; j < lines.length && j <= i + 14; j++) { + const line = lines[j] ?? ""; + + if (ASK.test(line)) { + break; + } + + const option = OPTION.exec(line); + + if (option) { + options.push({ letter: option[1] ?? "", text: clean(option[2] ?? "") }); + continue; + } + + if (!title && line.trim()) { + title = clean(line); + continue; + } + + if (options.length > 0 && line.trim() && !/^\s/.test(line)) { + break; + } + } + + found.push({ n: Number(match[2]), title, options }); + i = j - 1; + } + + return found; +} + +/** The decision numbers a user text answers. */ +export function answersIn(text: string): Set<number> { + const answered = new Set<number>(); + + for (const pattern of [ANSWER_LETTER, ANSWER_BARE, ANSWER_OWN]) { + for (const match of text.matchAll(pattern)) { + answered.add(Number(match[1])); + } + } + + return answered; +} + +/** The decisions still open after reading the messages in order: asked, and since then neither answered nor replaced. */ +export function openDecisions(messages: readonly { role: string; text: string }[]): OpenDecisions { + return chatDecisions(messages).open; +} + +/** The open decisions of the messages, and every number a user message answered (asked here or before a compaction). */ +export function chatDecisions(messages: readonly { role: string; text: string }[]): { + open: OpenDecisions; + answered: Set<number>; +} { + const asked = new Map<number, OpenDecision>(); + const answered = new Set<number>(); + + for (const message of messages) { + if (message.role === "assistant") { + for (const decision of asksIn(message.text)) { + if (!asked.has(decision.n)) { + asked.set(decision.n, decision); + } + } + + for (const match of message.text.matchAll(REPLACES)) { + asked.delete(Number(match[1])); + } + + continue; + } + + for (const n of answersIn(message.text)) { + asked.delete(n); + answered.add(n); + } + } + + return { open: [...asked.values()].sort((a, b) => a.n - b.n), answered }; +} + +/** The message `[send]` submits: one `D<n>: <answer>` line per staged decision, in number order. */ +export function stagedMessage(answers: StagedDecisions): string { + return Object.entries(answers) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([n, answer]) => `D${n}: ${answer}`) + .join("\n"); +} + +/** One decision as the sidebar shows it: the log's row, a chat ask the log does not have yet, or both merged. */ +export interface ShownDecision { + n: number; + title: string; + prompt: string; + status: DecisionStatus | "chat-answered"; + options: { letter: string; text: string }[]; + recommended?: string; + option?: string; + answer?: string; + /** The staged answer the store holds (state `drafted`): picked letters and a note. */ + draftOption?: string; + draft?: string; + at?: number; +} + +/** A staged answer in words: `b)`, `my words`, `b) my words`. */ +export function stagedText(decision: Pick<ShownDecision, "draftOption" | "draft">): string { + const letters = [...(decision.draftOption ?? "")].map((letter) => `${letter})`).join(" "); + + return [letters, decision.draft?.trim() ?? ""].filter(Boolean).join(" "); +} + +/** + * The log's decisions, plus the chat's open ones it lacks, by number. A decision the log still has waiting that a + * user message already answered reads `chat-answered`: the agent has the answer and has not recorded it yet. + */ +export function shownDecisions( + logged: readonly ShownDecision[], + fromChat: { open: OpenDecisions; answered: ReadonlySet<number> } +): ShownDecision[] { + const numbers = new Set(logged.map((decision) => decision.n)); + const merged = logged.map((decision) => + (decision.status === "waiting" || decision.status === "drafted") && fromChat.answered.has(decision.n) + ? { ...decision, status: "chat-answered" as const } + : decision + ); + const asked = fromChat.open + .filter((decision) => !numbers.has(decision.n)) + .map((decision) => ({ ...decision, prompt: "", status: "waiting" as const })); + + return [...merged, ...asked].sort((a, b) => a.n - b.n); +} + +const GROUPS: { title: string; statuses: ShownDecision["status"][] }[] = [ + { title: "Waiting for you", statuses: ["waiting", "drafted"] }, + { title: "Answered", statuses: ["chat-answered", "answered", "sent", "acknowledged"] }, + { title: "Done", statuses: ["implemented"] }, + { title: "Closed", statuses: ["superseded", "dismissed"] }, +]; + +function isWaiting(decision: ShownDecision): boolean { + return decision.status === "waiting" || decision.status === "drafted"; +} + +/** The decisions the sidebar and the chip count, newest read. */ +function currentDecisions(): ShownDecision[] { + return shownDecisions(answersOf()?.decisions ?? [], chat); +} + +async function readChat(host: Host): Promise<void> { + chat = chatDecisions(await host.messages()); + host.redraw(); + await findAskRows( + host, + currentDecisions().map((decision) => decision.n) + ); +} + +async function refresh(host: Host, why: string): Promise<void> { + await Promise.all([readChat(host), refreshAnswers(host, why, true)]); +} + +export const decisions: Feature = { + async start(host) { + if ((await settingsOf(host)).decisions.enabled) { + startAnswers(host); + await readChat(host); + } + }, + async afterTool(host, e) { + if ((await settingsOf(host)).decisions.enabled) { + afterQuestionTool(host, e); + } + }, + async afterTurn(host, e) { + if (e.agentId === undefined && (await settingsOf(host)).decisions.enabled) { + await readChat(host); + } + }, + sidebars: [{ id: DECISIONS_PANE, title: "Decisions", draw: decisionsPane }], + commands: [ + { + name: "decisions", + description: + "The Decisions sidebar: every decision of this session, a summary by state, stage and send answers, jump to where each was asked", + async run(host) { + if (!(await settingsOf(host)).decisions.enabled) { + return { + text: "The decisions mod is off: tools agents hooks config set mods.decisions.enabled true --write", + }; + } + + startAnswers(host); + await host.togglePane(DECISIONS_PANE, "Decisions", "command"); + await refresh(host, "command"); + await logMod(host, "decisions", { event: "open", shown: currentDecisions().map((d) => d.n) }); + + return {}; + }, + }, + ], +}; + +function chipText(list: readonly ShownDecision[], mode: "any" | "waiting" | "off"): string | undefined { + const waiting = list.filter(isWaiting).length; + + if (mode === "off" || list.length === 0 || (mode === "waiting" && waiting === 0)) { + return undefined; + } + + return waiting > 0 ? `decisions ${waiting} waiting` : `decisions ${list.length}`; +} + +/** The band chip, `decisions 1 waiting` or `decisions 4`, with the columns it takes; null when it has nothing to say. */ +export async function decisionsChip( + host: Host, + ui: ElementTable +): Promise<{ element: RenderElement; width: number } | null> { + const settings = (await settingsOf(host)).decisions; + + if (!settings.enabled) { + return null; + } + + const list = currentDecisions(); + const label = chipText(list, settings.chip); + + if (!label) { + return null; + } + + const { Button, Text } = ui; + const waiting = list.some(isWaiting); + + return { + element: ( + <Button key="decisions-chip" plain onPress={() => void host.togglePane(DECISIONS_PANE, "Decisions")}> + <Text color={waiting ? "yellow" : undefined} dimColor={!waiting}> + {label} + </Text> + </Button> + ), + width: [...label].length, + }; +} + +/** `text` cut to `cells` characters with `…` where it was cut. */ +function cut(text: string, cells: number): string { + const chars = [...text]; + + return chars.length <= cells ? text : `${chars.slice(0, cells - 1).join("")}…`; +} + +function tailOf(decision: ShownDecision): { text: string; color?: string } { + const ago = decision.at ? ` · ${liveAgo(decision.at, { unit: "minute" })}` : ""; + const picked = decision.option ? `${decision.option}) ` : ""; + + switch (decision.status) { + case "waiting": + return { text: `waiting${ago}`, color: "yellow" }; + case "drafted": + // The whole answer is in the field below; a long one here took the row and squeezed the title. + return { text: `staged ${cut(stagedText(decision), 24)}`, color: "cyan" }; + case "chat-answered": + return { text: "answered in chat", color: "green" }; + case "answered": + case "sent": + case "acknowledged": + return { text: `${picked}${decision.status}${ago}`, color: "green" }; + case "implemented": + return { text: `${picked}done${ago}` }; + case "superseded": + return { text: "replaced" }; + case "dismissed": + return { text: "dismissed" }; + } +} + +/** The Decisions sidebar: a summary line, the decisions by state, each opening to its options and [jump to]. */ +async function decisionsPane(host: Host, kit: Kit): Promise<RenderElement> { + const { Box, Button, Text } = kit.ui; + const elements = kit.ui; + // The mobile app draws no field yet; there an answer is staged from the options alone. + const Input = "Input" in elements ? elements.Input : undefined; + const settings = (await settingsOf(host)).decisions; + const list = currentDecisions(); + const read = answersOf(); + const waiting = list.filter(isWaiting).length; + const staged = list.filter((decision) => decision.status === "drafted"); + const message = stagedMessage( + Object.fromEntries(staged.map((decision) => [String(decision.n), stagedText(decision)])) + ); + const width = Math.max(10, kit.columns - 4); + const isOpen = (decision: ShownDecision) => { + const byDefault = settings.expand === "all" || (settings.expand === "waiting" && isWaiting(decision)); + + return flipped.has(decision.n) ? !byDefault : byDefault; + }; + /** + * Stages in the decision store: an option toggles, the answer field stores its text (Enter on an empty field + * clears it). A decision the store cannot take gets its answer put in the prompt instead. + */ + const stage = async (decision: ShownDecision, answer: { option?: string; text?: string }, key: string) => { + await pressed(host, key, "Staging in the decision log…", "busy"); + const done = await stageAnswer(host, decision.n, answer); + const words = answer.option ? `${answer.option})` : (answer.text ?? ""); + + if (!done.error) { + await pressed(host, key, words ? `Staged ${words}; the widget shows it too.` : "Unstaged."); + return; + } + + if (/not waiting/.test(done.error) && words) { + await host.fill({ text: `D${decision.n}: ${words}\n`, mode: "append" }); + await pressed( + host, + key, + `The decision log does not have DECISION ${decision.n}; "D${decision.n}: ${words}" is in the prompt instead.` + ); + return; + } + + await pressed(host, key, `Not staged: ${done.error}`, "error"); + }; + const send = async (how: "send" | "fill") => { + if (!message) { + return; + } + + if (how === "fill") { + await host.fill({ text: message, mode: "replace" }); + await pressed( + host, + "decisions-fill", + "In the prompt; the answers stay staged until the agent records them." + ); + return; + } + + await pressed(host, "decisions-send", "Sending…", "busy"); + const sent = await sendStaged(host); + + if (sent.error || !sent.text) { + await pressed(host, "decisions-send", `Not sent: ${sent.error ?? "nothing staged"}`, "error"); + return; + } + + await host.submit(sent.text); + typed.clear(); + await pressed( + host, + "decisions-send", + `Sent ${plural(sent.numbers?.length ?? 0, "answer")}; the decision log marks them sent.` + ); + }; + + /** + * The own-answer field. While the ring is elsewhere it sits in a rounded border, so it reads as a field: cyan + * while its text is the staged answer. It keeps what was typed (`typed`); Enter stages the text, Enter on an + * empty field unstages it, and with `mods.decisions.enter` `stage-then-send` Enter on the staged, unchanged answer + * sends every staged answer (a terminal passes no Cmd+Enter to a mod's field). + * + * While the engine's Input has the keys it takes the pane's whole body width, whatever box holds it, and wraps a + * long text itself; inside the border it ran 6 columns over and cut the text. So with the keys the field drops + * its border and margin for two cyan rules across the pane. Without the keys the engine draws one cut line, so a + * longer answer is a Button under the field's key holding the answer wrapped over as many lines as it takes; + * Tab onto it brings the Input back. + */ + const ownField = (decision: ShownDecision): RenderElement | null => { + if (!Input) { + return null; + } + + const key = `d${decision.n}-own`; + const stored = decision.draft ?? ""; + const draft = typed.get(decision.n) ?? stored; + const unmarked = (value: string) => value.replaceAll(MARK, ""); + const isOwnStaged = stored !== "" && stored === draft; + const sendsOnEnter = isOwnStaged && settings.enter === "stage-then-send"; + const isEditing = kit.isFocused === true && kit.focused === key; + // Inside the margin (2), the border (2) and the padding (2). + const inner = Math.max(10, kit.columns - 6); + const isLong = [...draft].length > inner; + const lines = isLong && !isEditing ? wrapWords(draft, inner, 40) : []; + // Only for the status bar's line while the field has focus; Enter is the field's own. + kit.pressables?.push({ + key, + label: "answer", + hint: sendsOnEnter + ? `Enter sends every staged answer now; change the text to stage it again instead.` + : `Your own answer to DECISION ${decision.n}. Enter stages it; Enter on an empty field unstages it.`, + onPress: () => undefined, + }); + const wrapped = lines.map((line, index) => ( + <Text key={`${key}-l${index}`} color={isOwnStaged ? "cyan" : undefined}> + {line} + </Text> + )); + const field = ( + <Input + key={key} + value={marked.has(decision.n) ? `${draft}${MARK}` : draft} + placeholder="your own answer, Enter stages it" + submitLabel={sendsOnEnter ? "send all" : isOwnStaged ? "staged" : "stage"} + onInput={(value: string) => { + typed.set(decision.n, unmarked(value)); + }} + onSubmit={(value: string) => { + const words = unmarked(value).trim(); + typed.set(decision.n, words); + + if (marked.has(decision.n)) { + marked.delete(decision.n); + } else { + marked.add(decision.n); + } + + if (settings.enter === "stage-then-send" && words !== "" && words === stored) { + void send("send"); + return; + } + + void stage(decision, { text: words }, key); + }} + /> + ); + + if (isEditing) { + const rule = (side: string) => ( + <Text key={`${key}-${side}`} color="cyan" wrap="truncate-end"> + {"─".repeat(Math.max(1, kit.columns))} + </Text> + ); + + return ( + <Box key={`${key}-box`} flexDirection="column"> + {rule("top")} + {/* A docked pane keeps its last column; the engine sizes the field to the column before it. */} + <Box marginRight={kit.placement === "dock" ? -1 : 0}>{field}</Box> + {rule("bottom")} + </Box> + ); + } + + return ( + <Box + key={`${key}-box`} + flexDirection="column" + marginLeft={2} + borderStyle="round" + borderColor={isOwnStaged ? "cyan" : undefined} + borderDimColor={!isOwnStaged} + paddingX={1} + > + {isLong ? ( + <Button key={key} plain onPress={() => undefined}> + {wrapped} + </Button> + ) : ( + field + )} + </Box> + ); + }; + + /** The letters of the answer given: the recorded option, else letters the answer starts with (`b)`, `ac)`). */ + const pickedOf = (decision: ShownDecision): string => { + if (decision.option) { + return decision.option; + } + + return /^\s*([a-z]{1,4})\)/.exec(decision.answer ?? "")?.[1] ?? ""; + }; + + const body = (decision: ShownDecision): RenderElement => { + const key = `d${decision.n}`; + const jump: Pressable = { + key: `${key}-jump`, + label: "jump to", + hint: `Scrolls the transcript to where DECISION ${decision.n} was first asked.`, + onPress: () => void jumpTo(host, `${key}-jump`, { decision: decision.n }), + }; + const picked = pickedOf(decision); + // The answer in words, when it says more than the letters picked. + const said = (decision.answer ?? "").replace(/^\s*[a-z]{1,4}\)\s*/, "").trim(); + + return ( + <Box key={`${key}-body`} flexDirection="column" marginBottom={1}> + <Box flexDirection="column" marginLeft={2}> + {wrapWords(decision.prompt.trim() === decision.title.trim() ? "" : decision.prompt, width, 4).map( + (text, index) => ( + <Text key={`${key}-p${index}`} dimColor wrap="truncate-end"> + {text} + </Text> + ) + )} + {decision.options.map((option) => { + const isStaged = (decision.draftOption ?? "").includes(option.letter); + const isPicked = picked.includes(option.letter); + const mark = isStaged ? "● " : isPicked ? "✓ " : " "; + const star = decision.recommended === option.letter ? " (recommended)" : ""; + const label = `${mark}${option.letter}) ${option.text}${star}`; + + if (!isWaiting(decision)) { + return ( + <Text + key={`${key}-${option.letter}`} + color={isPicked ? "green" : undefined} + bold={isPicked} + dimColor={!isPicked} + wrap="truncate-end" + > + {label} + </Text> + ); + } + + return listRow(kit, { + key: `${key}-${option.letter}`, + label, + ...(isStaged ? { color: "cyan" } : {}), + hint: isStaged + ? `Unstages ${option.letter}) for DECISION ${decision.n}.` + : `Stages ${option.letter}) as the answer to DECISION ${decision.n}; [send] sends every staged answer.`, + onPress: () => + void stage( + decision, + { option: isStaged ? "" : option.letter }, + `d${decision.n}-${option.letter}` + ), + }); + })} + {!isWaiting(decision) && said + ? wrapWords(`answer: ${said}`, width, 8).map((text, index) => ( + <Text key={`${key}-a${index}`} color="green" wrap="truncate-end"> + {text} + </Text> + )) + : null} + </Box> + {isWaiting(decision) && Input ? ownField(decision) : null} + <Box marginLeft={2}>{actionRow(kit, [jump])}</Box> + </Box> + ); + }; + + /** One state group: its title and each decision's row, opening to its body. */ + const group = (title: string, rows: ShownDecision[]): RenderElement | null => { + if (rows.length === 0) { + return null; + } + + return ( + <Box key={`group-${title}`} flexDirection="column" marginTop={1}> + {sectionTitle(kit, title, `(${rows.length})`)} + {rows.map((decision) => { + const open = isOpen(decision); + const tail = tailOf(decision); + + return ( + <Box key={`row-d${decision.n}`} flexDirection="column"> + {listRow( + kit, + { + key: `d${decision.n}-row`, + label: `${open ? "▾" : "▸"} D${decision.n} ${decision.title}`, + isDim: !isWaiting(decision), + hint: open + ? "Folds this decision." + : "Opens this decision: its options and [jump to].", + onPress: () => { + if (flipped.has(decision.n)) { + flipped.delete(decision.n); + } else { + flipped.add(decision.n); + } + + host.redraw(); + }, + }, + <Text color={tail.color} dimColor={!tail.color} wrap="truncate-end"> + {tail.text} + </Text> + )} + {open ? body(decision) : null} + </Box> + ); + })} + </Box> + ); + }; + + const [first, ...rest] = GROUPS; + const waitingRows = first ? list.filter((decision) => first.statuses.includes(decision.status)) : []; + const history = rest.map((held) => ({ + title: held.title, + rows: list.filter((decision) => held.statuses.includes(decision.status)), + })); + const historyCount = history.reduce((sum, held) => sum + held.rows.length, 0); + const showsHistory = (settings.history === "shown") !== historyFlipped; + // What [send] submits, as the CLI builds it; this session's own list where the CLI is older. + const outgoing = read?.staged ?? message; + + return sidebarFrame( + kit, + <Box flexDirection="column"> + {headerRow( + kit, + <Text wrap="truncate-end"> + <Text bold>{plural(list.length, "decision")}</Text> + <Text dimColor>{waiting > 0 ? ` · ${waiting} waiting` : ""}</Text> + </Text>, + [ + { + key: "decisions-refresh", + label: "refresh", + isDim: true, + hint: "Reads the decision log and this session's messages again.", + onPress: () => void refresh(host, "button"), + }, + ] + )} + {dimNote( + kit, + read + ? `From the decision log, read ${liveAgo(read.at, { unit: "minute" })}, and this session's messages.` + : "From this session's messages (the decision log needs the tools CLI)." + )} + {list.length === 0 ? dimNote(kit, "No decisions in this session yet.") : null} + {first ? group(first.title, waitingRows) : null} + {outgoing ? ( + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Staged", `(${staged.length})`)} + {dimNote(kit, "send submits exactly this:")} + {outgoing.split("\n").flatMap((line, index) => + wrapWords(line, width, 6).map((text, part) => ( + <Text key={`staged-${index}-${part}`} color="cyan" wrap="truncate-end"> + {text} + </Text> + )) + )} + {actionRow(kit, [ + { + key: "decisions-send", + label: "send", + isPrimary: true, + hotkey: "s", + hint: "Sends every staged answer as one message through this session's prompt (hotkey s); the decision log marks them sent.", + onPress: () => void send("send"), + }, + { + key: "decisions-fill", + label: "put in prompt", + hint: "Puts the staged answers in the prompt to edit; they stay staged until the agent records them.", + onPress: () => void send("fill"), + }, + { + key: "decisions-clear", + label: "clear", + isDim: true, + hint: "Unstages every answer in the decision store and empties the answer fields.", + onPress: () => { + typed.clear(); + // One `tools` start at a time, then one read: never a process per row at once. + void (async () => { + for (const decision of staged) { + await stageAnswer( + host, + decision.n, + { option: "", text: "" }, + { refresh: false } + ); + } + + await refreshAnswers(host, "clear", true); + await pressed(host, "decisions-clear", "Unstaged every answer."); + })(); + }, + }, + ])} + </Box> + ) : waiting > 0 ? ( + <Box marginTop={1}> + {dimNote(kit, "Nothing staged: press an option, or type an answer and press Enter.")} + </Box> + ) : null} + {historyCount > 0 ? ( + <Box flexDirection="column" marginTop={1}> + {listRow(kit, { + key: "decisions-history", + label: showsHistory + ? `▾ hide the ${historyCount} answered, done and closed` + : `▸ show ${plural(historyCount, "answered decision")}`, + isDim: true, + hint: showsHistory + ? "Folds the answered, done and closed decisions into this one row." + : "Shows the answered, done and closed decisions; an opened one marks the answer given.", + onPress: () => { + historyFlipped = !historyFlipped; + host.redraw(); + }, + })} + {showsHistory ? history.map((held) => group(held.title, held.rows)) : null} + </Box> + ) : null} + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/genesis.ts b/plugins/genesis-tools-mods/hooks/genesis.ts new file mode 100644 index 0000000000..a5ab506306 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/genesis.ts @@ -0,0 +1,160 @@ +// The plugin's one door to GenesisTools: whether its CLI runs here, and the links to its apps a click really opens. +// A link to the link host opens something only when GenesisTools.app is installed, macOS gives it https links, and +// the router has a route for that path; otherwise the click lands in the browser on a page that does not exist. The +// marketplace installs this plugin without the GenesisTools checkout, so `tools` itself may be missing. +// +// The answer comes from `tools genesis-tools probe --json` (GenesisTools `src/utils/genesis-tools/probe.ts`, which +// this file cannot import): the spawn proves that `tools` runs, and its JSON says which link kinds open. One run per +// 30 minutes per session, about 0.2 s, started by `session.start` and a timer (`primeGenesisProbe`), never by a +// render: the engine aborts a render hook's process when a newer render replaces it, and on 2026-10-23 that left +// every session believing no link kind opened. Every link this plugin writes is built here, from the probe's link +// host, and only for a kind that opens; any other file asks for one and gets undefined when it would be dead. +import type { Host } from "./shared"; + +export const LINK_KINDS = ["md", "finder", "hub", "claude-find"] as const; +export type LinkKind = (typeof LINK_KINDS)[number]; + +export interface GenesisProbe { + /** `tools` ran: the GenesisTools CLI is installed and on PATH. */ + tools: boolean; + linkHost: string | null; + opens: Record<LinkKind, boolean>; +} + +const NO_LINKS: Record<LinkKind, boolean> = { md: false, finder: false, hub: false, "claude-find": false }; +const NOTHING: GenesisProbe = { tools: false, linkHost: null, opens: NO_LINKS }; +const MAX_AGE_MS = 30 * 60_000; + +let held: { at: number; value: GenesisProbe } | undefined; +let pending: Promise<GenesisProbe> | undefined; + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The probe out of `tools genesis-tools probe --json`; undefined for anything else. */ +export function probeOf(stdout: string): GenesisProbe | undefined { + let parsed: unknown; + + try { + parsed = JSON.parse(stdout); + } catch { + return undefined; + } + + if (!isRecord(parsed) || !isRecord(parsed.links) || !isRecord(parsed.router)) { + return undefined; + } + + const links = parsed.links; + const linkHost = typeof parsed.router.linkHost === "string" ? parsed.router.linkHost : null; + const opens = (kind: LinkKind): boolean => { + const state = links[kind]; + return linkHost !== null && isRecord(state) && state.opens === true; + }; + + return { + tools: true, + linkHost, + opens: { md: opens("md"), finder: opens("finder"), hub: opens("hub"), "claude-find": opens("claude-find") }, + }; +} + +/** Undefined when the run was cut off (the hook that started it was replaced), which says nothing about this Mac. */ +async function runProbe(host: Host): Promise<GenesisProbe | undefined> { + try { + const run = await host.run(["tools", "genesis-tools", "probe", "--json"], { timeoutMs: 20_000 }); + + if (run.exitCode === 0) { + // A GenesisTools from before the probe (2026-10-23) prints help: its CLI runs, its links stay unknown. + return probeOf(run.stdout) ?? { ...NOTHING, tools: true }; + } + + // 127: the shell found no `tools`. Any other code: it runs, and the probe failed. + return { ...NOTHING, tools: run.exitCode !== 127 }; + } catch (error) { + // A render hook's `$` calls are aborted when a newer render replaces it (the live clock redraws each + // second): that run never reached `tools`, so nothing is remembered and the next caller asks again. + if (String(error).includes("aborted")) { + return undefined; + } + + // No `tools` on PATH: the spawn itself fails. + return NOTHING; + } +} + +function probeWith(host: Host): Promise<GenesisProbe> { + pending ??= runProbe(host).then((value) => { + pending = undefined; + + if (value === undefined) { + return held?.value ?? NOTHING; + } + + held = { at: Date.now(), value }; + return value; + }); + + return pending; +} + +/** + * Runs the probe from the session's start, outside any render, and again every 30 minutes on a timer, so a render + * only ever reads the answer. `register.tsx` calls it from `session.start`. + */ +export function primeGenesisProbe(host: Host): void { + void probeWith(host); + host.every(MAX_AGE_MS, () => { + void probeWith(host); + }); +} + +/** What this Mac has: the last answer, or the run in flight; concurrent callers share one run. */ +export async function genesisProbe(host: Host): Promise<GenesisProbe> { + if (held && (Date.now() - held.at < MAX_AGE_MS || pending)) { + return held.value; + } + + return probeWith(host); +} + +/** Drops the remembered probe: a test sets up another Mac. */ +export function forgetGenesisProbe(): void { + held = undefined; + pending = undefined; +} + +/** A path for a link: `encodeURI`, plus the parentheses a markdown link target cannot hold. */ +export function encodePath(path: string): string { + return encodeURI(path).replace(/\(/g, "%28").replace(/\)/g, "%29"); +} + +function linkOn(probe: GenesisProbe, kind: LinkKind, path: string): string | undefined { + return probe.linkHost && probe.opens[kind] ? `https://${probe.linkHost}/${path}` : undefined; +} + +/** A note or data file in Genesis Markdown; `range` highlights `12` or `12-30`. */ +export function mdLink(probe: GenesisProbe, path: string, range?: string): string | undefined { + return linkOn(probe, "md", `md/open?path=${encodePath(path)}${range ? `&range=${range}` : ""}`); +} + +/** A folder or file shown in Finder. */ +export function finderLink(probe: GenesisProbe, target: string): string | undefined { + return linkOn(probe, "finder", `finder/reveal?target=${encodePath(target)}`); +} + +/** The GenesisTools hub on a PR (`owner/repo#42`), or a repository's commit. */ +export function hubLink( + probe: GenesisProbe, + query: { pr: string; commit?: string } | { repo: string; commit: string } +): string | undefined { + const pairs = Object.entries(query).filter((pair): pair is [string, string] => typeof pair[1] === "string"); + + return linkOn(probe, "hub", `hub?${pairs.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&")}`); +} + +/** Scrolls a live Claude session to where DECISION `n` was first asked. */ +export function decisionLink(probe: GenesisProbe, session: string, n: number): string | undefined { + return linkOn(probe, "claude-find", `claude/find/${encodeURIComponent(session)}/decision/${n}`); +} diff --git a/plugins/genesis-tools-mods/hooks/git-band.tsx b/plugins/genesis-tools-mods/hooks/git-band.tsx new file mode 100644 index 0000000000..48419e4708 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/git-band.tsx @@ -0,0 +1,1469 @@ +// The git band: one line above the prompt per checkout the session works in (`checkouts.ts`: the ones it wrote +// files in or changed git state in, the last first, up to `mods.git.checkouts`), each with the branch, unpushed and +// unpulled commits, the dirty count, and the pull requests that matter there (the branch's, plus the ones +// `/mod-git pr add` tracks) with their CI counts. A session that has worked in no checkout yet shows its own +// folder; `mods.git.cwd: always` shows that folder first in every session. It refreshes when the session starts, +// after a Bash call that ran git or gh, after a call that works in another checkout, every 3 minutes, and on +// `/mod-git refresh`; `gh` answers are reused for 2 minutes. `/mod-git on|off|toggle` switches it for this +// session, `mods.git.enabled` for every session. Each refresh and command goes to `mods-log/git.jsonl`. +// +// `/mod-git add <folder>` (the prompt's typeahead proposes folders, zoxide's too) and the sidebar's field put any +// checkout in this session's band; `remove` takes it out, the sidebar's `hide` drops a worked-in one until the +// session works there again. A PR's `track` keeps it in its checkout's row after a branch switch; the branch's own +// PR shows anyway, so its `hide` is what takes it out of the band. +// +// With `mods.git.earlier: show` (the default) the work stays visible after it left the checkout: a branch the session +// made, pushed or worked on there and then switched away from keeps its PR in the row, and a worktree the session +// removed keeps its row (`worktree removed`), with its branch and PRs read in the main checkout. A row is named by +// `mods.git.name`: `Kalendora/enhancements` for a linked worktree (`repo-folder`), `Kalendora`, or `enhancements`. +import type { ElementTable, RenderElement } from "claude-code"; +import type { BandCheck, BandPr, GitBand, GitBandRows, GitBandSwitch } from "../types"; +import { + addCheckout, + addedCheckouts, + type FolderSuggestion, + folderSuggestions, + headOf, + hideCheckout, + noteToolCall, + pickRoots, + removeAdded, + sessionCheckouts, + type Touch, +} from "./checkouts"; +import { type GenesisProbe, genesisProbe, hubLink } from "./genesis"; +import { liveAgo } from "./live"; +import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; +import { + actionButton, + buttonsWidth, + divider, + type Kit, + line, + listRow, + type Pressable, + plural, + pressed, + sidebarFrame, + wrapWords, +} from "./sidebar"; + +export const GIT_PANE = "mods-git"; + +/** The last refresh, one row per checkout; empty until one finished. */ +let rows: GitBandRows = []; +/** The git root of the session's own folder at the last refresh. */ +let ownRoot: string | undefined; +/** `/mod-git on|off` for this session; null follows `mods.git.enabled`. */ +let turnedOn: GitBandSwitch = null; + +const GH_TTL_MS = 2 * 60_000; +const EVERY_MS = 3 * 60_000; +/** The earlier branches of a checkout whose PRs a row looks up, the last first. */ +const EARLIER_PRS = 3; +const ghCache = new Map<string, { at: number; pr: BandPr | null }>(); +let refreshing: Promise<void> | undefined; + +async function run(host: Host, argv: string[], cwd: string): Promise<string | undefined> { + const done = await host.run(argv, { cwd, timeoutMs: 15_000 }).catch(() => undefined); + + return done?.exitCode === 0 ? done.stdout : undefined; +} + +async function cwdRoot(host: Host): Promise<string | undefined> { + const cwd = await host.cwd(); + + return (await run(host, ["git", "rev-parse", "--show-toplevel"], cwd))?.trim() || undefined; +} + +/** The checkouts the band shows now, with why: the added ones, then the ones the session worked in, the last first. */ +async function bandRoots(host: Host): Promise<{ root: string; why: GitBand["why"] }[]> { + ownRoot = await cwdRoot(host); + const settings = (await settingsOf(host)).git; + const all = await sessionCheckouts(host); + // With `earlier: hide` a removed worktree takes no row, so it leaves its place to one that is there. + const there = await Promise.all(all.map((touch) => host.exists(touch.root).catch(() => true))); + const touches = settings.earlier === "show" ? all : all.filter((_, index) => there[index]); + const added = await addedCheckouts(host); + + return pickRoots(touches, ownRoot, settings, added).map((root) => ({ + root, + why: added.includes(root) ? "added" : touches.some((touch) => touch.root === root) ? "worked" : "folder", + })); +} + +/** The checkout `/mod-git pr` acts on: the band's first row. */ +async function firstRoot(host: Host): Promise<string | undefined> { + return rows[0]?.root ?? (await bandRoots(host))[0]?.root; +} + +/** A path with the home as `~`. */ +function tilde(path: string, home: string | undefined): string { + return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +/** A checkout's folder name: `shop-web-marketplace-2.0-poc`. */ +export function checkoutName(root: string): string { + return root.slice(root.lastIndexOf("/") + 1) || root; +} + +/** + * A row's name by `mods.git.name`: `repo-folder` gives a linked worktree as `Kalendora/enhancements` and a main + * checkout as `Kalendora`; `repo` gives the main checkout's name alone; `folder` the checkout's own folder. + */ +export function labelOf(band: Pick<GitBand, "root" | "repo">, name: "repo-folder" | "repo" | "folder"): string { + const folder = checkoutName(band.root); + const repo = band.repo ? checkoutName(band.repo) : folder; + + if (name === "folder") { + return folder; + } + + return name === "repo" || repo === folder ? repo : `${repo}/${folder}`; +} + +const storeKey = (root: string) => `git-band-prs:${root}`; + +const hiddenKey = (root: string) => `git-band-hidden-prs:${root}`; + +async function numbersAt(host: Host, key: string): Promise<number[]> { + const held = await host.storeGet(key); + + return Array.isArray(held) ? held.filter((n): n is number => typeof n === "number") : []; +} + +export function trackedPrs(host: Host, root: string): Promise<number[]> { + return numbersAt(host, storeKey(root)); +} + +/** The branch PRs the sidebar's `hide` took out of a checkout's band row. */ +function hiddenPrs(host: Host, root: string): Promise<number[]> { + return numbersAt(host, hiddenKey(root)); +} + +const PASSED = ["SUCCESS", "NEUTRAL", "SKIPPED"]; +const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]; + +/** Each check of `statusCheckRollup`: a check run has `name` and `conclusion`, a commit status `context` and `state`. */ +export function checksOf(rollup: unknown): BandCheck[] { + return (Array.isArray(rollup) ? rollup : []).filter(isRecord).map((check) => { + const verdict = String(check.conclusion || check.state || "").toUpperCase(); + + return { + name: String(check.name || check.context || "check"), + verdict: PASSED.includes(verdict) ? "pass" : FAILED.includes(verdict) ? "fail" : "pending", + description: typeof check.description === "string" ? check.description : "", + link: String(check.detailsUrl || check.targetUrl || ""), + }; + }); +} + +/** + * The checks out of `gh pr checks --json name,description,link,bucket`: one per check (a re-run counts once), with + * the description a commit status carries, which `statusCheckRollup` leaves out. Undefined when it is not that list. + */ +export function prChecksOf(stdout: string): BandCheck[] | undefined { + let parsed: unknown; + + try { + parsed = JSON.parse(stdout); + } catch { + return undefined; + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + return undefined; + } + + return parsed.filter(isRecord).map((check) => { + const bucket = String(check.bucket ?? ""); + + return { + name: String(check.name || "check"), + verdict: bucket === "fail" || bucket === "cancel" ? "fail" : bucket === "pending" ? "pending" : "pass", + description: typeof check.description === "string" ? check.description : "", + link: typeof check.link === "string" ? check.link : "", + }; + }); +} + +/** CI counts out of a list of checks. */ +export function countChecks(checks: readonly BandCheck[]): { passed: number; failed: number; pending: number } { + return { + passed: checks.filter((check) => check.verdict === "pass").length, + failed: checks.filter((check) => check.verdict === "fail").length, + pending: checks.filter((check) => check.verdict === "pending").length, + }; +} + +/** CI counts out of `statusCheckRollup`. */ +export function checkCounts(rollup: unknown): { passed: number; failed: number; pending: number } { + return countChecks(checksOf(rollup)); +} + +/** + * A PR from `gh pr view` in `root`: `which` is a number or a branch, or "" for the checked-out branch, whose answer is + * kept under `branch` so a switch does not show the last branch's PR for the next 2 minutes. + */ +async function prOf( + host: Host, + ask: { root: string; which: string; tracked: boolean; branch?: string } +): Promise<BandPr | null> { + const { root, which, tracked } = ask; + const key = `${root}|${which || `@${ask.branch ?? ""}`}`; + const held = ghCache.get(key); + + if (held && Date.now() - held.at < GH_TTL_MS) { + return held.pr ? { ...held.pr, tracked } : null; + } + + const argv = [ + "gh", + "pr", + "view", + ...(which ? [which] : []), + "--json", + "number,title,url,state,isDraft,statusCheckRollup", + ]; + const text = await run(host, argv, root); + let pr: BandPr | null = null; + + try { + const parsed: unknown = text ? JSON.parse(text) : undefined; + + if (isRecord(parsed) && typeof parsed.number === "number") { + pr = { + number: parsed.number, + title: String(parsed.title ?? ""), + url: String(parsed.url ?? ""), + state: String(parsed.state ?? ""), + draft: parsed.isDraft === true, + tracked, + own: false, + hidden: false, + ...checkCounts(parsed.statusCheckRollup), + checks: checksOf(parsed.statusCheckRollup), + }; + } + } catch { + pr = null; + } + + // A PR with a check to look at gets `gh pr checks`, which says why a status failed (`Deployment was blocked`) + // and counts a re-run once. It exits 1 with a failed check and 8 with a pending one, so its exit code says nothing. + if (pr && (pr.failed > 0 || pr.pending > 0)) { + const done = await host + .run(["gh", "pr", "checks", String(pr.number), "--json", "name,description,link,bucket"], { + cwd: root, + timeoutMs: 15_000, + }) + .catch(() => undefined); + const checks = done ? prChecksOf(done.stdout) : undefined; + + if (checks) { + pr = { ...pr, ...countChecks(checks), checks }; + } + } + + ghCache.set(key, { at: Date.now(), pr }); + + return pr; +} + +/** `## branch...origin/branch [ahead 2, behind 1]` and one line per changed path, from `git status --porcelain --branch`. */ +export function parseStatus(text: string): Pick<GitBand, "branch" | "ahead" | "behind" | "dirty"> { + const [head = "", ...rest] = text.split("\n"); + const branch = /^## (?:No commits yet on )?([^.\s]+(?:\.(?!\.)[^.\s]*)*)/.exec(head)?.[1] ?? "?"; + + return { + branch: branch === "HEAD" ? "detached" : branch, + ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0), + behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0), + dirty: rest.filter((row) => row.trim()).length, + }; +} + +/** The line totals of `git diff --numstat HEAD`; a binary file (`-`) counts no lines. */ +export function parseNumstat(text: string): Pick<GitBand, "added" | "removed"> { + let added = 0; + let removed = 0; + + for (const row of text.split("\n")) { + const [plus = "", minus = ""] = row.split("\t"); + added += /^\d+$/.test(plus) ? Number(plus) : 0; + removed += /^\d+$/.test(minus) ? Number(minus) : 0; + } + + return { added, removed }; +} + +async function isOn(host: Host): Promise<boolean> { + const held = turnedOn; + + return held ?? (await settingsOf(host)).git.enabled; +} + +/** + * The PRs a row shows: the branch's own (`which` names it, or "" for the checked-out branch), the tracked ones, then + * the ones of the branches the session worked on in the checkout before (`touch.branches`), each marked `from`. + */ +async function rowPrs( + host: Host, + at: { cwd: string; root: string; which: string; branch: string; touch?: Touch } +): Promise<BandPr[]> { + const tracked = await trackedPrs(host, at.root); + const hidden = await hiddenPrs(host, at.root); + const own = await prOf(host, { root: at.cwd, which: at.which, tracked: false, branch: at.branch }); + const others = await Promise.all( + tracked + .filter((n) => n !== own?.number) + .map((n) => prOf(host, { root: at.cwd, which: String(n), tracked: true })) + ); + const mine = own + ? { ...own, own: true, tracked: tracked.includes(own.number), hidden: hidden.includes(own.number) } + : null; + const prs = [mine, ...others].filter((pr): pr is BandPr => pr !== null); + const names = (at.touch?.branches ?? []).filter((name) => name !== at.branch).slice(0, EARLIER_PRS); + const earlier = await Promise.all(names.map((name) => prOf(host, { root: at.cwd, which: name, tracked: false }))); + + earlier.forEach((pr, index) => { + if (pr && !prs.some((held) => held.number === pr.number)) { + prs.push({ ...pr, from: names[index], hidden: hidden.includes(pr.number) }); + } + }); + + return prs; +} + +/** + * The row of a worktree the session removed: the branch it had last, that branch's PRs and the earlier ones, read in + * the main checkout; null when the branch has no local ref and no PR left to show. + */ +async function goneRow(host: Host, root: string, touch: Touch): Promise<GitBand | null> { + const branch = touch.branches?.[0]; + const repo = touch.repo; + + if (!branch || !repo || repo === root) { + return null; + } + + const ref = await run( + host, + ["git", "for-each-ref", "--format=%(refname:short)%09%(upstream:track)", `refs/heads/${branch}`], + repo + ); + const sync = ref?.split("\t")[1] ?? ""; + const prs = await rowPrs(host, { cwd: repo, root, which: branch, branch, touch }); + + if (!ref?.trim() && prs.length === 0) { + return null; + } + + return { + root, + why: "worked", + at: Date.now(), + branch, + ahead: Number(/ahead (\d+)/.exec(sync)?.[1] ?? 0), + behind: Number(/behind (\d+)/.exec(sync)?.[1] ?? 0), + dirty: 0, + added: 0, + removed: 0, + prs, + repo, + gone: true, + }; +} + +/** + * One checkout's row: undefined when git cannot read it this time (the last row stays), null when its folder is gone + * and there is nothing left to show. + */ +async function readRow(host: Host, root: string, why: GitBand["why"]): Promise<GitBand | null | undefined> { + const status = await run(host, ["git", "status", "--porcelain", "--branch"], root); + const earlier = (await settingsOf(host)).git.earlier === "show"; + const touch = earlier ? (await sessionCheckouts(host)).find((held) => held.root === root) : undefined; + + if (status === undefined) { + if (await host.exists(root).catch(() => true)) { + return undefined; + } + + return touch ? goneRow(host, root, touch) : null; + } + + const numstat = parseNumstat((await run(host, ["git", "diff", "--numstat", "HEAD"], root)) ?? ""); + const state = parseStatus(status); + const prs = await rowPrs(host, { cwd: root, root, which: "", branch: state.branch, touch }); + const { repo } = await headOf(host, root); + + return { root, why, at: Date.now(), ...state, ...numstat, prs, repo }; +} + +async function refreshNow(host: Host, why: string): Promise<void> { + const started = Date.now(); + const roots = await bandRoots(host); + const read = await Promise.all(roots.map(({ root, why }) => readRow(host, root, why))); + // A checkout git could not read this time keeps its last row; one whose folder is gone with nothing to show goes. + const next = roots + .map(({ root }, index) => { + const row = read[index]; + + return row === null ? undefined : (row ?? rows.find((held) => held.root === root)); + }) + .filter((row): row is GitBand => row !== undefined); + + rows = next; + host.redraw(); + await logMod(host, "git", { + event: "refresh", + why, + rows: next.map((band) => ({ + root: band.root, + branch: band.branch, + ahead: band.ahead, + behind: band.behind, + dirty: band.dirty, + added: band.added, + removed: band.removed, + gone: band.gone, + prs: band.prs.map((pr) => ({ + n: pr.number, + from: pr.from, + passed: pr.passed, + failed: pr.failed, + pending: pr.pending, + })), + })), + ms: Date.now() - started, + }); +} + +/** One refresh at a time; a call while one runs waits for it instead of starting a second `gh`. */ +export function refresh(host: Host, why: string): Promise<void> { + refreshing ??= refreshNow(host, why).finally(() => { + refreshing = undefined; + }); + + return refreshing; +} + +const USAGE = + "/mod-git [show] | on | off | toggle | refresh | status | add <folder> | remove <folder> | pr add <n…> | pr rm <n…> | pr list"; + +/** The answer to `/mod-git <args>`. */ +async function command(host: Host, args: string): Promise<string> { + const [verb = "show", sub, ...rest] = args.trim().split(/\s+/).filter(Boolean); + + if (verb === "show") { + await host.togglePane(GIT_PANE, "Git", "command"); + + return ""; + } + + if (verb === "on" || verb === "off" || verb === "toggle") { + const now = verb === "toggle" ? !(await isOn(host)) : verb === "on"; + turnedOn = now; + host.redraw(); + + if (now) { + await refresh(host, "command"); + } + + return `Git band ${now ? "on" : "off"} for this session.`; + } + + if (verb === "refresh") { + ghCache.clear(); + await refresh(host, "command"); + + return "Git band refreshed."; + } + + if (verb === "add" || verb === "remove") { + const folder = args.trim().slice(verb.length).trim(); + + if (!folder) { + return USAGE; + } + + if (verb === "add") { + const done = await addCheckout(host, folder); + + if ("error" in done) { + return `Not added: ${done.error}.`; + } + + await refresh(host, "add"); + + return `${checkoutName(done.root)} (${done.root}) is in this session's git band.`; + } + + const gone = await removeAdded(host, folder); + const added = await addedCheckouts(host); + + if (!gone) { + return `No added folder ${folder}. Added here: ${added.map(checkoutName).join(", ") || "none"}.`; + } + + await refresh(host, "remove"); + + return `${checkoutName(gone)} is out of this session's git band.`; + } + + const root = await firstRoot(host); + + if (!root) { + return "Not in a git repository."; + } + + const where = checkoutName(root); + + if (verb === "pr") { + const numbers = rest.map((n) => Number(n.replace(/^#/, ""))).filter((n) => Number.isInteger(n) && n > 0); + const held = await trackedPrs(host, root); + + if (sub === "list" || sub === undefined) { + return held.length + ? `Tracked in ${where}: ${held.map((n) => `#${n}`).join(", ")}` + : `No tracked PRs in ${where}.`; + } + + if ((sub !== "add" && sub !== "rm") || numbers.length === 0) { + return USAGE; + } + + const next = sub === "add" ? [...new Set([...held, ...numbers])] : held.filter((n) => !numbers.includes(n)); + await host.storeSet(storeKey(root), next); + await refresh(host, `pr ${sub}`); + + return `Tracked in ${where}: ${next.length ? next.map((n) => `#${n}`).join(", ") : "none"}`; + } + + if (verb === "status") { + const band = rows[0]; + const on = await isOn(host); + const name = (await settingsOf(host)).git.name; + const shown = rows.length ? `; checkouts ${rows.map((row) => labelOf(row, name)).join(", ")}` : ""; + + return `Git band ${on ? "on" : "off"}; last refresh ${band ? new Date(band.at).toLocaleTimeString() : "never"}${shown}.`; + } + + return USAGE; +} + +export const gitBand: Feature = { + async start(host) { + host.every(EVERY_MS, () => { + void isOn(host).then((yes) => (yes ? refresh(host, "timer") : undefined)); + }); + + if (await isOn(host)) { + void refresh(host, "start"); + } + }, + commands: [ + { + name: "mod-git", + description: + "The git band: no argument opens its sidebar; add <folder> and remove <folder> put a checkout in this session's band; on, off, toggle, refresh, status, pr add <n>, pr rm <n>, pr list", + argumentHint: "add <folder>|remove <folder>|on|off|refresh|pr add <n>", + async run(host, args) { + const text = await command(host, args); + await logMod(host, "git", { event: "command", args, answer: text }); + + return text ? { text } : {}; + }, + }, + ], + sidebars: [{ id: GIT_PANE, title: "Git", draw: gitPane }], + async autocomplete(host, e) { + const head = /^\/mod-git\s+(add|remove)\s+/.exec(e.text); + + if (!head || e.start < head[0].length || !(await isOn(host))) { + return []; + } + + const home = await host.env("HOME"); + + if (head[1] === "remove") { + const needle = e.token.toLowerCase(); + + return (await addedCheckouts(host)) + .filter((root) => root.toLowerCase().includes(needle)) + .map((root) => ({ text: root, label: tilde(root, home), description: "added to this session's band" })); + } + + return (await folderSuggestions(host, e.token)).map((found) => ({ + text: found.path, + label: tilde(found.path, home), + description: `${found.isGit ? "git checkout" : "not in git"} · ${found.from}`, + })); + }, + async afterTool(host, e) { + if (!(await isOn(host))) { + return; + } + + const moved = await noteToolCall(host, e); + + if (moved || (e.tool === "Bash" && /(^|[\s;&|(])(git|gh)\s/.test(e.command))) { + void refresh(host, moved ? "checkout" : "tool"); + } + }, +}; + +function ciText(pr: BandPr): string { + const parts = [ + pr.passed ? `✓${pr.passed}` : "", + pr.failed ? `✗${pr.failed}` : "", + pr.pending ? `…${pr.pending}` : "", + ]; + + return parts.filter(Boolean).join(" ") || "no CI"; +} + +function prColor(pr: BandPr): string { + return pr.failed ? "red" : pr.pending ? "yellow" : pr.state === "MERGED" ? "magenta" : "green"; +} + +function prNote(pr: BandPr): string { + return `${pr.draft ? " draft" : ""}${pr.state === "OPEN" ? "" : ` ${pr.state.toLowerCase()}`} ${ciText(pr)}`; +} + +/** The hub link for a GitHub PR (`hub?pr=owner%2Frepo%23507`); undefined for another forge or where it opens nothing. */ +export function hubUrlOf(probe: GenesisProbe, pr: Pick<BandPr, "url" | "number">): string | undefined { + const slug = /github\.com\/([^/]+\/[^/]+)\/pull\//.exec(pr.url)?.[1]; + + return slug ? hubLink(probe, { pr: `${slug}#${pr.number}` }) : undefined; +} + +/** The share of the room left for the name and the branch that the checkout name may take when both cannot fit. */ +const NAME_SHARE = 0.4; +/** The shortest a cut name or branch gets. */ +const LABEL_MIN = 8; + +/** `feat/2026-10-13-enhancements-unified` as `feat/2026-10-13…ements-unified` at 30. */ +export function middleCut(text: string, max: number): string { + if (text.length <= max) { + return text; + } + + const head = Math.ceil((max - 1) / 2); + + return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`; +} + +/** Whether the rows name their checkout: always, unless the one row is the session's own folder. */ +export function namesCheckouts(shown: readonly Pick<GitBand, "root">[], own: string | undefined): boolean { + return shown.length > 1 || (shown.length === 1 && shown[0]?.root !== own); +} + +/** Columns a text takes in the terminal: one per character, as every symbol the band draws is narrow. */ +function widthOf(text: string): number { + return [...text].length; +} + +/** + * The checkout name and the branch, cut to `room` columns together. Both fit whole when they can; else the name + * keeps up to 40% of the room and the branch the rest, each keeping its start and end. + */ +export function fitLabels(name: string | undefined, branch: string, room: number): { name?: string; branch: string } { + const nameWidth = name === undefined ? 0 : widthOf(name); + + if (nameWidth + widthOf(branch) <= room) { + return { name, branch }; + } + + const nameRoom = name === undefined ? 0 : Math.min(nameWidth, Math.max(LABEL_MIN, Math.floor(room * NAME_SHARE))); + + return { + name: name === undefined ? undefined : middleCut(name, nameRoom), + branch: middleCut(branch, Math.max(LABEL_MIN, room - nameRoom)), + }; +} + +/** The PRs a band row shows: all but the ones the sidebar's `hide` took out. */ +function shownPrs(band: GitBand): BandPr[] { + return band.prs.filter((pr) => !pr.hidden); +} + +/** How much a band row says after the branch: words, signs only, or signs with bare PR numbers. */ +type Wording = "full" | "signs" | "bare"; + +/** The texts after the branch, in order: unpushed and unpulled, the changed files, their lines, then each PR. */ +function countsOf(band: GitBand, wording: Wording): { sync: string; dirty: string; lines: string; prs: string[] } { + const words = wording === "full"; + + return { + sync: [ + band.ahead ? `↑${band.ahead}${words ? " to push" : ""}` : "", + band.behind ? `↓${band.behind}${words ? " to pull" : ""}` : "", + ] + .filter(Boolean) + .join(words ? " · " : " "), + dirty: band.gone + ? words + ? "worktree removed" + : "removed" + : band.dirty + ? words + ? `${plural(band.dirty, "file")} changed` + : `●${band.dirty}` + : "clean", + lines: band.dirty && (band.added || band.removed) ? `+${band.added} −${band.removed}` : "", + prs: shownPrs(band).map((pr) => `#${pr.number}${wording === "bare" ? "" : prNote(pr)}`), + }; +} + +/** The columns a row's counts take: one of gap before each part, then `⎇ ` and the space after a name. */ +function countsWidth(counts: ReturnType<typeof countsOf>, named: boolean): number { + const parts = [counts.sync, counts.dirty, counts.lines, ...counts.prs].filter(Boolean); + + return parts.reduce((sum, part) => sum + widthOf(part) + 1, 0) + 2 + (named ? 1 : 0); +} + +/** + * The fullest wording whose counts leave the name and the branch their shortest cut: words when they fit + * (`mods.git.labels: full`), else signs (`↑27 ↓2 ●13`), else signs with the PRs as bare numbers. + */ +export function wordingFor(band: GitBand, room: number, named: boolean, labelled: boolean): Wording { + const need = LABEL_MIN * (named ? 2 : 1); + const tries: Wording[] = labelled ? ["full", "signs", "bare"] : ["signs", "bare"]; + + return tries.find((wording) => room - countsWidth(countsOf(band, wording), named) >= need) ?? "bare"; +} + +/** + * The band's git rows, one per checkout; none while the band is off or has nothing yet. Each row fits `columns`, + * the first one less `firstReserve` (the hooks chip beside it): the counts keep their width, in words while they + * fit, else in signs, and the checkout name and the branch are cut to what is left. A row still too wide is cut at + * the right edge; nothing wraps. Each PR is a Button (it inverts under the pointer, so it reads as one) that opens + * the Git sidebar; the sidebar opens the PR in the browser or the hub. + */ +export async function gitRows( + host: Host, + ui: ElementTable, + room: { columns: number; firstReserve: number } +): Promise<RenderElement[]> { + const shown = rows; + + if (shown.length === 0 || !(await isOn(host))) { + return []; + } + + const { Box, Button, Text } = ui; + const git = (await settingsOf(host)).git; + const labelled = git.labels === "full"; + const named = namesCheckouts(shown, ownRoot); + const open = () => void host.togglePane(GIT_PANE, "Git"); + + const widthAt = (index: number) => room.columns - (index === 0 ? room.firstReserve : 0); + // Every row says its counts the same way: the shortest wording any row needs. + const order: Wording[] = ["full", "signs", "bare"]; + const wording = + order[ + Math.max(...shown.map((band, index) => order.indexOf(wordingFor(band, widthAt(index), named, labelled)))) + ] ?? "bare"; + + return shown.map((band, index) => { + const width = widthAt(index); + const counts = countsOf(band, wording); + const labels = fitLabels( + named ? labelOf(band, git.name) : undefined, + band.branch, + width - countsWidth(counts, named) + ); + + return ( + <Box key={`git-${band.root}`} flexDirection="row" gap={1} overflow="hidden"> + <Box flexShrink={0}> + <Button key="git-branch" plain onPress={open}> + {labels.name === undefined ? null : <Text color="cyan">{`${labels.name} `}</Text>} + <Text dimColor>⎇ </Text> + <Text bold>{labels.branch}</Text> + </Button> + </Box> + {counts.sync ? ( + <Text color="yellow" wrap="truncate-end"> + {counts.sync} + </Text> + ) : null} + <Text color={band.dirty ? "magenta" : undefined} dimColor={!band.dirty} wrap="truncate-end"> + {counts.dirty} + </Text> + {counts.lines ? ( + <Text wrap="truncate-end"> + <Text color="green">{`+${band.added}`}</Text> + <Text> </Text> + <Text color="red">{`−${band.removed}`}</Text> + </Text> + ) : null} + {shownPrs(band).map((pr) => ( + <Box key={`pr-${pr.number}`} flexShrink={0}> + <Button key={`pr-${pr.number}`} plain onPress={open}> + <Text color={prColor(pr)} underline>{`#${pr.number}`}</Text> + {wording === "bare" ? null : <Text color={prColor(pr)}>{prNote(pr)}</Text>} + </Button> + </Box> + ))} + </Box> + ); + }); +} + +/** What the add field holds, and the folders it proposes for it. */ +let addQuery = ""; +let addFound: FolderSuggestion[] = []; +let addAsked = 0; + +async function proposeFolders(host: Host, typed: string): Promise<void> { + const asked = ++addAsked; + const found = await folderSuggestions(host, typed, 5); + + if (asked === addAsked) { + addFound = found; + host.redraw(); + } +} + +/** Adds what the field holds: the folder typed, or else the first one proposed. */ +async function addFolder(host: Host, typed: string): Promise<void> { + const text = typed.trim(); + + if (!text) { + return; + } + + await pressed(host, "git-add", `Adding ${text}…`, "busy"); + let done = await addCheckout(host, text); + + if ("error" in done && addFound[0]) { + done = await addCheckout(host, addFound[0].path); + } + + if ("error" in done) { + await pressed(host, "git-add", `Not added: ${done.error}.`, "error"); + return; + } + + addQuery = ""; + addFound = []; + await refresh(host, "sidebar add"); + await pressed(host, "git-add", `Added ${checkoutName(done.root)} (${done.root}) to this session's band.`); +} + +/** Runs `open` on a URL or a folder and says how it went. */ +async function openAndSay(host: Host, key: string, target: string, done: string): Promise<void> { + const run = await host.openUrl(target).catch((error: unknown) => error); + const code = isRecord(run) && typeof run.exitCode === "number" ? run.exitCode : -1; + + await pressed(host, key, code === 0 ? done : `open ${target} failed (exit ${code}).`, code === 0 ? "ok" : "error"); +} + +/** Why a checkout is in the band, in two or three words. */ +function whyText(band: GitBand): string { + return band.why === "added" ? "added by hand" : band.why === "worked" ? "worked in" : "own folder"; +} + +/** A checkout's state in short: `↑24 to push · ↓2 to pull · 16 changed +643 −1359`, or `clean`; zeros left out. */ +function stateParts(band: GitBand): { text: string; color?: string }[] { + const parts: { text: string; color?: string }[] = []; + + if (band.ahead) { + parts.push({ text: `↑${band.ahead} to push`, color: "yellow" }); + } + + if (band.behind) { + parts.push({ text: `↓${band.behind} to pull`, color: "yellow" }); + } + + if (band.dirty) { + parts.push({ text: `${band.dirty} changed`, color: "magenta" }); + + if (band.added || band.removed) { + parts.push({ text: `+${band.added}`, color: "green" }, { text: `−${band.removed}`, color: "red" }); + } + } + + if (band.gone) { + parts.push({ text: "worktree removed" }); + } + + return parts.length ? parts : [{ text: "clean" }]; +} + +/** Runs a sidebar toggle: says it is busy, flips the row at once, stores it, reads git again, says it is done. */ +async function toggle( + host: Host, + key: string, + work: { busy: string; flip: () => void; store: () => Promise<unknown>; done: string } +): Promise<void> { + await pressed(host, key, work.busy, "busy"); + work.flip(); + host.redraw(); + await work.store(); + await refresh(host, "sidebar"); + await pressed(host, key, work.done); +} + +/** The Git sidebar's actions for one checkout: Finder while its folder is there, and remove or hide. */ +function checkoutActions(host: Host, band: GitBand, at: string, name: string): Pressable[] { + const finder: Pressable = { + key: `${at}-finder`, + label: "Finder", + isDim: true, + hint: `Show ${band.root} in Finder.`, + onPress: () => void openAndSay(host, `${at}-finder`, band.root, `Opened ${band.root} in Finder.`), + }; + + if (band.why === "added") { + return [ + finder, + { + key: `${at}-remove`, + label: "remove", + isDim: true, + hint: `Take ${name} out of this session's band. You added it by hand.`, + onPress: () => + void toggle(host, `${at}-remove`, { + busy: `Removing ${name}…`, + flip: () => { + rows = rows.filter((row) => row.root !== band.root); + }, + store: () => removeAdded(host, band.root), + done: `Removed ${name} from this session's band.`, + }), + }, + ]; + } + + if (band.why === "worked") { + return [ + ...(band.gone ? [] : [finder]), + { + key: `${at}-hide`, + label: "hide", + isDim: true, + hint: `Hide ${name} until this session writes a file or changes git there again.`, + onPress: () => + void toggle(host, `${at}-hide`, { + busy: `Hiding ${name}…`, + flip: () => { + rows = rows.filter((row) => row.root !== band.root); + }, + store: () => hideCheckout(host, band.root), + done: `Hid ${name}. It comes back when this session works there.`, + }), + }, + ]; + } + + return [finder]; +} + +/** Flips one flag of one PR in the rows the band and the sidebar draw, before git is read again. */ +function flipPr(root: string, n: number, flag: "tracked" | "hidden"): void { + rows = rows.map((row) => + row.root === root + ? { ...row, prs: row.prs.map((pr) => (pr.number === n ? { ...pr, [flag]: !pr[flag] } : pr)) } + : row + ); +} + +/** The Git sidebar's actions for one PR: open, hub, copy link, track or untrack, and hide or show for the branch's own. */ +function prActions(host: Host, band: GitBand, pr: BandPr, at: string, lists: Lists): Pressable[] { + const n = pr.number; + const name = lists.label; + const link = `[#${n} (${pr.title})](${pr.url})`; + const hub = hubUrlOf(lists.probe, pr); + const store = (key: string, list: number[], on: boolean) => async () => { + await host.storeSet(key, on ? [...new Set([...list, n])] : list.filter((held) => held !== n)); + await logMod(host, "git", { + event: key.startsWith("git-band-hidden") ? "hide" : "track", + on, + n, + root: band.root, + }); + }; + const trackHint = pr.own + ? pr.tracked + ? `Stop keeping #${n} after a branch switch. It still shows while ${band.branch} is checked out.` + : `Keep #${n} in ${name}'s row even after ${name} switches to another branch.` + : `Take #${n} out of ${name}'s row.`; + const actions: Pressable[] = [ + { + key: `${at}-open`, + label: "open", + isPrimary: true, + hint: `Open #${n} in the browser: ${pr.url}`, + onPress: () => void openAndSay(host, `${at}-open`, pr.url, `Opened #${n} in the browser.`), + }, + ...(hub + ? [ + { + key: `${at}-hub`, + label: "hub", + hint: `Open #${n} in the GenesisTools hub.`, + onPress: () => void openAndSay(host, `${at}-hub`, hub, `Opened #${n} in the hub.`), + }, + ] + : []), + { + key: `${at}-copy`, + label: "copy link", + isDim: true, + hint: `Copy ${link}`, + onPress: () => + void host + .copy(link) + .then((result) => + pressed( + host, + `${at}-copy`, + result.isCopied ? `Copied ${link}` : `Not copied: ${result.reason}.`, + result.isCopied ? "ok" : "error" + ) + ), + }, + ]; + + // An earlier branch's PR shows as long as the session remembers the branch; `hide` is what takes it out. + if (!pr.from) { + actions.push({ + key: `${at}-track`, + label: pr.tracked ? "untrack" : "track", + isDim: true, + hint: trackHint, + onPress: () => + void toggle(host, `${at}-track`, { + busy: pr.tracked ? `Untracking #${n}…` : `Tracking #${n}…`, + flip: () => flipPr(band.root, n, "tracked"), + store: store(storeKey(band.root), lists.tracked, !pr.tracked), + done: pr.tracked + ? pr.own + ? `#${n} is no longer kept after a branch switch.` + : `#${n} is out of ${name}'s row.` + : `#${n} stays in ${name}'s row after a branch switch.`, + }), + }); + } + + if (pr.own || pr.from) { + actions.push({ + key: `${at}-hide`, + label: pr.hidden ? "show" : "hide", + isDim: true, + hint: pr.hidden + ? `Put #${n} back in the band.` + : `Take #${n} out of the band. It stays listed here, with show.`, + onPress: () => + void toggle(host, `${at}-hide`, { + busy: pr.hidden ? `Showing #${n}…` : `Hiding #${n}…`, + flip: () => flipPr(band.root, n, "hidden"), + store: store(hiddenKey(band.root), lists.hidden, !pr.hidden), + done: pr.hidden ? `#${n} is back in the band.` : `#${n} is out of the band.`, + }), + }); + } + + return actions; +} + +/** Which checkouts the person opened or folded in the sidebar; the rest follow `mods.git.expand`. */ +const opened = new Set<string>(); +const folded = new Set<string>(); + +function isOpen(root: string, index: number, expand: "first" | "all" | "none"): boolean { + if (opened.has(root)) { + return true; + } + + if (folded.has(root)) { + return false; + } + + return expand === "all" || (expand === "first" && index === 0); +} + +/** What one checkout's sidebar lines read: the link probe, its PR lists, its name and the home for `~`. */ +type Lists = { probe: GenesisProbe; tracked: number[]; hidden: number[]; label: string; home?: string }; + +/** A checkout's state in signs for its folded line: `↑24 ●16`, `clean`. */ +function stateSigns(band: GitBand): { text: string; color?: string }[] { + const parts: { text: string; color?: string }[] = []; + + if (band.ahead) { + parts.push({ text: `↑${band.ahead}`, color: "yellow" }); + } + + if (band.behind) { + parts.push({ text: `↓${band.behind}`, color: "yellow" }); + } + + if (band.dirty) { + parts.push({ text: `●${band.dirty}`, color: "magenta" }); + } + + if (band.gone) { + parts.push({ text: "removed" }); + } else if (parts.length === 0) { + parts.push({ text: "clean" }); + } + + for (const pr of band.prs.filter((held) => !held.hidden)) { + parts.push({ text: `#${pr.number} ${ciText(pr)}`, color: prColor(pr) }); + } + + return parts; +} + +/** + * A checkout's first line, one Button the width of the pane: the fold mark, its name and branch at the left, and at + * the right why it is here (open) or its state in signs (folded). The branch gives way first when the line is full. + */ +function checkoutLine( + host: Host, + kit: Kit, + band: GitBand, + at: string, + isOpenNow: boolean, + name: string +): RenderElement { + const { Button, Text } = kit.ui; + const right = isOpenNow ? [{ text: whyText(band) }] : stateSigns(band); + const rightText = right.map((part) => part.text).join(" "); + const room = kit.columns - widthOf(rightText) - 1; + const labels = fitLabels(name, band.branch, room - 3); + const left = `${isOpenNow ? "▾" : "▸"} ${labels.name ?? name} ${labels.branch}`; + const pad = " ".repeat(Math.max(1, kit.columns - widthOf(left) - widthOf(rightText))); + const row: Pressable = { + key: `${at}-fold`, + label: name, + hint: isOpenNow ? `Fold ${name} to one line.` : `Open ${name}: its state, its actions and its pull requests.`, + onPress: () => { + if (isOpenNow) { + opened.delete(band.root); + folded.add(band.root); + } else { + folded.delete(band.root); + opened.add(band.root); + } + + host.redraw(); + }, + }; + kit.pressables?.push(row); + + return line( + kit, + at, + <Button key={row.key} plain onPress={row.onPress}> + <Text dimColor>{isOpenNow ? "▾ " : "▸ "}</Text> + <Text color="cyan" bold>{`${labels.name ?? name} `}</Text> + <Text bold>{labels.branch}</Text> + <Text>{pad}</Text> + {right.map((part, index) => ( + <Text key={`r-${index}`} color={part.color} dimColor={!part.color}> + {`${index ? " " : ""}${part.text}`} + </Text> + ))} + </Button> + ); +} + +/** One failing or pending check, one line: its name, what it said, and a press that opens its page. */ +function checkLine(host: Host, kit: Kit, check: BandCheck, key: string): RenderElement { + const { Button, Text } = kit.ui; + const failed = check.verdict === "fail"; + const row: Pressable = { + key, + label: check.name, + hint: check.link ? `Open the ${check.name} check: ${check.link}` : `${check.name} has no page to open.`, + onPress: () => + void (check.link + ? openAndSay(host, key, check.link, `Opened the ${check.name} check.`) + : pressed(host, key, `${check.name} has no page to open.`, "error")), + }; + kit.pressables?.push(row); + + return line( + kit, + key, + <Button key={key} plain onPress={row.onPress}> + <Text>{" "}</Text> + <Text color={failed ? "red" : "yellow"}>{`${failed ? "✗" : "…"} ${check.name}`}</Text> + {check.description ? <Text dimColor>{` · ${check.description}`}</Text> : null} + </Button> + ); +} + +/** A row of Buttons, indented, as the lines it takes: wrapped the way flex wraps them, greedily. */ +function buttonLines(kit: Kit, actions: Pressable[], key: string, indent: number): RenderElement[] { + const { Box } = kit.ui; + const room = kit.columns - indent; + const rowsOf: Pressable[][] = []; + + for (const action of actions) { + const last = rowsOf.at(-1); + + if (last && buttonsWidth([...last, action]) <= room) { + last.push(action); + } else { + rowsOf.push([action]); + } + } + + return rowsOf.map((held, index) => + line( + kit, + `${key}-${index}`, + <Box paddingLeft={indent} flexDirection="row" columnGap={1}> + {held.map((action) => actionButton(kit, action))} + </Box> + ) + ); +} + +/** One PR under its open checkout, as lines: number and title (two lines at most), its state, its checks, its actions. */ +function prLines(host: Host, kit: Kit, band: GitBand, pr: BandPr, at: string, lists: Lists): RenderElement[] { + const { Text } = kit.ui; + const failing = pr.checks.filter((check) => check.verdict === "fail"); + const pending = pr.checks.filter((check) => check.verdict === "pending"); + const why = [ + pr.own ? "this branch's PR" : pr.from ? "earlier branch" : "tracked", + pr.own && pr.tracked ? "kept after a switch" : "", + pr.hidden ? "hidden from the band" : "", + ] + .filter(Boolean) + .join(" · "); + const number = `#${pr.number} `; + const titleLines = wrapWords(`${number}${pr.title}`, kit.columns - 2, 2); + const out: RenderElement[] = [line(kit, `${at}-gap`, null)]; + + titleLines.forEach((text, index) => { + out.push( + line( + kit, + `${at}-title-${index}`, + index === 0 ? ( + <Text> + <Text>{" "}</Text> + <Text color={prColor(pr)} bold> + {number} + </Text> + <Text bold={!pr.hidden} dimColor={pr.hidden}> + {text.slice(number.length)} + </Text> + </Text> + ) : ( + <Text bold={!pr.hidden} dimColor={pr.hidden}>{` ${text}`}</Text> + ) + ) + ); + }); + out.push( + line( + kit, + `${at}-state`, + <Text dimColor>{` ${pr.state.toLowerCase()}${pr.draft ? " draft" : ""} · ${ciText(pr)} · ${why}`}</Text> + ) + ); + + // Its own line, since a sidebar is too narrow to keep a branch name at the end of the state line. + if (pr.from) { + const from = middleCut(pr.from, Math.max(LABEL_MIN, kit.columns - 9)); + out.push(line(kit, `${at}-from`, <Text dimColor>{` branch ${from}`}</Text>)); + } + failing.forEach((check, index) => { + out.push(checkLine(host, kit, check, `${at}-fail-${index}`)); + }); + pending.slice(0, 3).forEach((check, index) => { + out.push(checkLine(host, kit, check, `${at}-pending-${index}`)); + }); + + if (pending.length > 3) { + out.push(line(kit, `${at}-more`, <Text dimColor>{` … ${pending.length - 3} more pending`}</Text>)); + } + + out.push(...buttonLines(kit, prActions(host, band, pr, at, lists), `${at}-actions`, 2)); + + return out; +} + +/** An open checkout's lines under its first one: its state with its actions at the right, then its PRs. */ +function openLines(host: Host, kit: Kit, band: GitBand, at: string, lists: Lists): RenderElement[] { + const { Box, Text } = kit.ui; + const actions = checkoutActions(host, band, at, lists.label); + const state = stateParts(band); + const stateWidth = widthOf(state.map((part) => part.text).join(" ")) + 2; + const fits = stateWidth + 1 + buttonsWidth(actions) <= kit.columns; + const stateText = ( + <Box flexShrink={1} minWidth={0}> + <Text wrap="truncate-end"> + <Text>{" "}</Text> + {state.map((part, index) => ( + <Text key={`s-${index}`} color={part.color} dimColor={!part.color}> + {`${index ? " " : ""}${part.text}`} + </Text> + ))} + </Text> + </Box> + ); + const out: RenderElement[] = fits + ? [ + line( + kit, + `${at}-state`, + <Box flexDirection="row" flexGrow={1}> + {stateText} + <Box flexGrow={1} /> + <Box flexDirection="row" columnGap={1} flexShrink={0}> + {actions.map((action) => actionButton(kit, action))} + </Box> + </Box> + ), + ] + : [line(kit, `${at}-state`, stateText), ...buttonLines(kit, actions, `${at}-actions`, 2)]; + + // A linked worktree says where it is, since its name alone does not (`/tmp/cc/<project>/<session>/worktrees/x`). + if (band.repo && band.repo !== band.root) { + const where = band.gone ? "was at" : "worktree at"; + const path = middleCut(tilde(band.root, lists.home), Math.max(LABEL_MIN, kit.columns - where.length - 3)); + out.push(line(kit, `${at}-where`, <Text dimColor>{` ${where} ${path}`}</Text>)); + } + + if (band.prs.length === 0) { + out.push(line(kit, `${at}-nopr`, <Text dimColor>{" No pull request for this branch."}</Text>)); + } + + for (const pr of band.prs) { + out.push(...prLines(host, kit, band, pr, `${at}-pr-${pr.number}`, lists)); + } + + return out; +} + +/** + * The Git sidebar, one row per line so the status bar can sit pinned at the pane's foot (`sidebar.tsx`): a header + * with when git was read and `refresh`, the add field with the folders it proposes, then each checkout, folded to + * one line or open with its state, actions and PRs. What the focused Button does, and what a press did, show in the + * status bar, so the focus ring moves nothing. + */ +async function gitPane(host: Host, kit: Kit): Promise<RenderElement> { + const { Box, Text } = kit.ui; + // Every surface but the mobile app draws a field. + const Input = "Input" in kit.ui ? kit.ui.Input : undefined; + const shown = rows; + const home = await host.env("HOME"); + const git = (await settingsOf(host)).git; + const readAt = Math.max(0, ...shown.map((band) => band.at)); + const reload: Pressable = { + key: "git-refresh", + label: "refresh", + isDim: true, + hint: "Read every checkout's git state and pull requests again.", + onPress: () => { + ghCache.clear(); + void pressed(host, "git-refresh", `Reading ${plural(rows.length, "checkout")}…`, "busy") + .then(() => refresh(host, "sidebar")) + .then(() => pressed(host, "git-refresh", `Read ${plural(rows.length, "checkout")} again.`)); + }, + }; + const field: Pressable = { + key: "git-add", + label: "add a folder", + hint: "Type a path (/ or ~/) or part of a folder name. Enter adds it, or the first match below it.", + onPress: () => undefined, + }; + kit.pressables?.push(field); + const probe = await genesisProbe(host); + const lines: RenderElement[] = [ + line( + kit, + "git-head", + <Box flexDirection="row" flexGrow={1}> + <Box flexShrink={1} minWidth={0}> + <Text wrap="truncate-end"> + <Text bold>Git</Text> + <Text dimColor> + {shown.length + ? ` · ${plural(shown.length, "checkout")} · read ${liveAgo(readAt)}` + : " · no reading yet"} + </Text> + </Text> + </Box> + <Box flexGrow={1} /> + <Box flexShrink={0}>{actionButton(kit, reload)}</Box> + </Box> + ), + line(kit, "git-gap", null), + line( + kit, + "git-field", + Input ? ( + <Input + key="git-add" + label="add a folder" + placeholder="a path, or part of a folder name" + value={addQuery} + submitLabel="add" + onInput={(value: string) => { + addQuery = value; + void proposeFolders(host, value); + }} + onSubmit={(value: string) => void addFolder(host, value)} + /> + ) : ( + <Text dimColor>/mod-git add <folder> in the prompt adds a folder.</Text> + ) + ), + ]; + + addFound.forEach((found, index) => { + const row: Pressable = { + key: `git-add-${index}`, + label: tilde(found.path, home), + isDim: true, + hint: found.isGit + ? `Add ${found.path} to this session's band.` + : `${found.path} is in no git checkout, so the band cannot show it.`, + onPress: () => void addFolder(host, found.path), + }; + lines.push( + line( + kit, + `git-found-${index}`, + listRow( + kit, + row, + <Text color={found.isGit ? "green" : undefined} dimColor={!found.isGit}> + {`${found.isGit ? "git" : "not in git"} · ${found.from}`} + </Text> + ) + ) + ); + }); + + for (const [index, band] of shown.entries()) { + const at = `git-${index}`; + const isOpenNow = isOpen(band.root, index, git.expand); + const label = labelOf(band, git.name); + + lines.push(line(kit, `${at}-rule`, divider(kit)), checkoutLine(host, kit, band, at, isOpenNow, label)); + + if (isOpenNow) { + lines.push( + ...openLines(host, kit, band, at, { + probe, + tracked: await trackedPrs(host, band.root), + hidden: await hiddenPrs(host, band.root), + label, + home, + }) + ); + } + } + + return sidebarFrame(kit, <Box flexDirection="column">{lines}</Box>, lines.length); +} diff --git a/plugins/genesis-tools-mods/hooks/hook-ledger.tsx b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx new file mode 100644 index 0000000000..bd096ae68b --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/hook-ledger.tsx @@ -0,0 +1,408 @@ +// The hook ledger. Every classic (settings and plugin) hook's answer passes through `classic.*`, so this counts what +// the hooks hand the model: `additionalContext` entries and `block` reasons (a Stop nudge is one), per event, for the +// main loop and for subagents. `mods.hooks.tokens` shows the total as a band chip (`hooks ~38.2k · 41`; tokens are +// characters / 4, an estimate), and a press on it opens the Hooks sidebar: the count per event, the latest injections +// with their text, and the dedupe switch for this session. The status line cannot take a click, so the count left it +// on 2026-10-23. The count is per session and survives a reload of the plugin. `mods.hooks.dedupe` drops a repeat: an +// entry, or an area-rule block inside one, already handed to the same loop since its last compaction becomes a +// one-line receipt. Each injection and each drop goes to `mods-log/hooks.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; +import type { HookCount, HookLedger } from "../types"; +import { liveSince } from "./live"; +import { agentsDir, type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; +import { barText, dimNote, divider, headerRow, type Kit, listRow, plural, sectionTitle, sidebarFrame } from "./sidebar"; + +export const HOOKS_PANE = "mods-hooks"; + +const EMPTY: HookLedger = { + main: { fires: 0, chars: 0 }, + agents: { fires: 0, chars: 0 }, + byEvent: {}, + dropped: { fires: 0, chars: 0 }, +}; + +/** Counted for this session. Kept in the plugin store under `hook-ledger:<session id>`, so a reload keeps the count. */ +let ledger: HookLedger = EMPTY; +let storeKey: string | undefined; + +function countOf(value: unknown): HookCount | undefined { + return isRecord(value) && typeof value.fires === "number" && typeof value.chars === "number" + ? { fires: value.fires, chars: value.chars } + : undefined; +} + +/** A ledger read back from the store, or undefined when it is not one. */ +export function ledgerOf(value: unknown): HookLedger | undefined { + if (!isRecord(value) || !isRecord(value.byEvent)) { + return undefined; + } + + const main = countOf(value.main); + const agents = countOf(value.agents); + const dropped = countOf(value.dropped); + const byEvent = Object.fromEntries( + Object.entries(value.byEvent).flatMap(([event, count]) => { + const held = countOf(count); + + return held ? [[event, held]] : []; + }) + ); + + return main && agents && dropped ? { main, agents, dropped, byEvent } : undefined; +} + +function save(host: Host): void { + if (storeKey) { + void host.storeSet(storeKey, ledger).catch(() => undefined); + } +} + +/** The latest injections, newest last, for the sidebar: the text cut at `ENTRY_TEXT` characters. */ +interface Injection { + at: number; + event: string; + agent: string; + tool?: string; + chars: number; + text: string; + dropped: string[]; +} + +let recent: Injection[] = []; +const RECENT = 40; +const ENTRY_TEXT = 4_000; +/** The sidebar's dedupe switch for this session; null follows `mods.hooks.dedupe`. */ +let dedupeHere: boolean | null = null; +/** The injection the sidebar shows in full, by its `at`. */ +let opened: number | undefined; + +/** Shorter entries are never dropped: a receipt would save nothing. */ +const MIN_DEDUPE_CHARS = 200; +const AREA_BLOCK = /(?=Contents of \S+ \(area rules\):)/; + +/** What each loop has been handed since its last compaction: a key per whole entry and per area-rule block. */ +const seen = new Map<string, Map<string, number>>(); + +function keyOf(text: string): string { + let hash = 5381; + + for (let i = 0; i < text.length; i++) { + hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; + } + + return `${text.length}:${hash >>> 0}`; +} + +export function tokensText(chars: number): string { + const tokens = chars / 4; + + return tokens >= 1000 ? `~${(tokens / 1000).toFixed(1)}k` : `~${Math.round(tokens)}`; +} + +function receipt(what: string, chars: number, at: number): string { + return `[genesis-tools-mods: ${what} repeated, ${chars} characters already in this context since ${new Date(at).toLocaleTimeString()}; dropped]`; +} + +/** + * One entry with its repeats taken out: a whole repeat becomes a receipt; an entry of area-rule blocks keeps its + * new blocks and names the dropped ones. `held` remembers what the loop has now. + */ +export function dedupeEntry( + text: string, + held: Map<string, number>, + now: number +): { text: string; droppedChars: number; dropped: string[] } { + const whole = keyOf(text); + const before = held.get(whole); + + if (before !== undefined && text.length >= MIN_DEDUPE_CHARS) { + const head = text.split("\n")[0]?.slice(0, 80) ?? "hook context"; + const note = receipt(`"${head}"`, text.length, before); + + return { text: note, droppedChars: text.length - note.length, dropped: [head] }; + } + + held.set(whole, now); + const [preface = "", ...blocks] = text.split(AREA_BLOCK); + + if (blocks.length === 0) { + return { text, droppedChars: 0, dropped: [] }; + } + + const kept: string[] = []; + const dropped: string[] = []; + let droppedChars = 0; + + for (const block of blocks) { + const key = keyOf(block.trim()); + const at = held.get(key); + const path = /^Contents of (\S+)/.exec(block)?.[1] ?? "area rules"; + + if (at === undefined) { + held.set(key, now); + kept.push(block); + continue; + } + + const note = receipt(`area rules ${path}`, block.length, at); + dropped.push(path); + droppedChars += block.length - note.length; + kept.push(`${note}\n\n`); + } + + return { text: dropped.length ? `${preface}${kept.join("")}`.trimEnd() : text, droppedChars, dropped }; +} + +function add(count: HookCount, chars: number): HookCount { + return { fires: count.fires + 1, chars: count.chars + chars }; +} + +/** + * Counts what one classic hook event handed the model and takes out the repeats. Answers the `additionalContext` to + * pass on in place of the hooks' own, or undefined to pass their answer on unchanged. + */ +export async function ledgerClassic(host: Host, input: unknown, answer: unknown): Promise<string[] | undefined> { + { + if (!isRecord(input) || !isRecord(answer)) { + return undefined; + } + + const event = typeof input.hook_event_name === "string" ? input.hook_event_name : "?"; + const agent = typeof input.agent_id === "string" ? input.agent_id : "main"; + + if (event === "SessionStart" && (input.source === "compact" || input.source === "clear")) { + seen.delete(agent); + } + + const entries = Array.isArray(answer.additionalContext) + ? answer.additionalContext.filter((entry): entry is string => typeof entry === "string") + : []; + const block = typeof answer.block === "string" ? answer.block : ""; + + if (entries.length === 0 && !block) { + return undefined; + } + + const settings = (await settingsOf(host)).hooks; + const dedupe = dedupeHere ?? settings.dedupe; + const held = seen.get(agent) ?? new Map<string, number>(); + seen.set(agent, held); + const now = Date.now(); + let droppedChars = 0; + const dropped: string[] = []; + const kept = entries.map((entry) => { + if (!dedupe) { + return entry; + } + + const out = dedupeEntry(entry, held, now); + droppedChars += out.droppedChars; + dropped.push(...out.dropped); + + return out.text; + }); + const chars = kept.reduce((sum, entry) => sum + entry.length, 0) + block.length; + + const before = ledger; + ledger = { + main: agent === "main" ? add(before.main, chars) : before.main, + agents: agent === "main" ? before.agents : add(before.agents, chars), + byEvent: { ...before.byEvent, [event]: add(before.byEvent[event] ?? { fires: 0, chars: 0 }, chars) }, + dropped: dropped.length ? add(before.dropped, droppedChars) : before.dropped, + }; + await logMod(host, "hooks", { + event, + agent, + tool: typeof input.tool_name === "string" ? input.tool_name : undefined, + chars, + heads: kept.map((entry) => entry.split("\n")[0]?.slice(0, 100)), + block: block ? block.slice(0, 100) : undefined, + dropped: dropped.length ? dropped : undefined, + droppedChars: droppedChars || undefined, + }); + save(host); + recent = [ + ...recent, + { + at: now, + event, + agent, + tool: typeof input.tool_name === "string" ? input.tool_name : undefined, + chars, + text: [...kept, block].filter(Boolean).join("\n\n").slice(0, ENTRY_TEXT), + dropped, + }, + ].slice(-RECENT); + host.redraw(); + + return dropped.length ? kept : undefined; + } +} + +export const hookLedger: Feature = { + async start(host) { + // Sessions that ran 1.7.0 pinned the count as a status line; the chip replaces it. + host.status(undefined); + const key = `hook-ledger:${await host.sessionId()}`; + + if (key !== storeKey) { + storeKey = key; + ledger = ledgerOf(await host.storeGet(key).catch(() => undefined)) ?? EMPTY; + host.redraw(); + } + }, + compacted(_host, agentId) { + seen.delete(agentId ?? "main"); + }, + sidebars: [{ id: HOOKS_PANE, title: "Hooks", draw: hooksPane }], + commands: [ + { + name: "mod-hooks", + description: "The Hooks sidebar: what the hooks handed the model, per event, with the latest injections", + async run(host) { + await host.togglePane(HOOKS_PANE, "Hooks", "command"); + + return {}; + }, + }, + ], +}; + +/** The band chip's two texts: `hooks ~38.2k · 41`, and what the dedupe dropped. */ +function chipTexts(): { total: string; dropped: string } { + const held = ledger; + + return { + total: `hooks ${tokensText(held.main.chars + held.agents.chars)} · ${held.main.fires + held.agents.fires}`, + dropped: held.dropped.fires ? ` −${tokensText(held.dropped.chars)}` : "", + }; +} + +/** The columns the band chip's text takes. */ +export function hooksChipWidth(): number { + const { total, dropped } = chipTexts(); + + return [...total, ...dropped].length; +} + +/** The band chip, `hooks ~38.2k · 41`, or null while `mods.hooks.tokens` is off. */ +export async function hooksChip(host: Host, ui: ElementTable): Promise<RenderElement | null> { + if (!(await settingsOf(host)).hooks.tokens) { + return null; + } + + const { Button, Text } = ui; + const { total, dropped } = chipTexts(); + + return ( + <Button key="hooks-chip" plain onPress={() => void host.togglePane(HOOKS_PANE, "Hooks")}> + <Text dimColor>{total}</Text> + {dropped ? <Text color="green">{dropped}</Text> : null} + </Button> + ); +} + +function ago(at: number, now: number): string { + return liveSince(at, now); +} + +/** The Hooks sidebar: the totals, the count per event as bars, and the latest injections, each opening its text. */ +async function hooksPane(host: Host, kit: Kit): Promise<RenderElement> { + const { Box, Text } = kit.ui; + const held = ledger; + const now = Date.now(); + const settings = (await settingsOf(host)).hooks; + const dedupe = dedupeHere ?? settings.dedupe; + const total = held.main.chars + held.agents.chars; + const events = Object.entries(held.byEvent).sort((a, b) => b[1].chars - a[1].chars); + const most = events[0]?.[1].chars ?? 1; + const log = `${(await agentsDir(host)) ?? "~/.genesis-tools/agents"}/mods-log/hooks.jsonl`; + const shown = recent.find((entry) => entry.at === opened); + const reset = () => { + ledger = EMPTY; + save(host); + recent = []; + host.redraw(); + }; + + return sidebarFrame( + kit, + <Box flexDirection="column"> + {headerRow( + kit, + <Text wrap="truncate-end"> + <Text bold>{`${tokensText(total)} tokens`}</Text> + <Text dimColor>{` from hooks · ${plural(held.main.fires + held.agents.fires, "fire")}`}</Text> + </Text>, + [ + { + key: "hooks-dedupe", + label: `dedupe: ${dedupe ? "on" : "off"}`, + onPress: () => { + dedupeHere = !dedupe; + void logMod(host, "hooks", { event: "dedupe-switch", on: dedupeHere }); + host.redraw(); + }, + }, + { + key: "hooks-log", + label: "show log", + isDim: true, + // Finder straight away: a reveal needs no link router. + onPress: () => void host.run(["/usr/bin/open", "-R", log], { timeoutMs: 10_000 }), + }, + { key: "hooks-reset", label: "reset", isDim: true, onPress: reset }, + ] + )} + {dimNote( + kit, + `main ${tokensText(held.main.chars)} · ${held.main.fires} agents ${tokensText(held.agents.chars)} · ${held.agents.fires} deduped ${tokensText(held.dropped.chars)} · ${held.dropped.fires}` + )} + {dimNote(kit, "Counted for this session; tokens are characters / 4.")} + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "By event")} + {events.length === 0 ? dimNote(kit, "No hook added context yet.") : null} + {events.map(([event, count]) => ( + <Box key={`event-${event}`} flexDirection="row" gap={1}> + <Text wrap="truncate-end">{event}</Text> + <Box flexGrow={1} /> + <Text dimColor>{`${count.fires}×`}</Text> + <Text color="cyan">{barText(count.chars / most, 10)}</Text> + <Text>{tokensText(count.chars).padStart(6)}</Text> + </Box> + ))} + </Box> + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Latest", "(press one to read it)")} + {[...recent] + .reverse() + .slice(0, 15) + .map((entry) => + listRow( + kit, + { + key: `entry-${entry.at}`, + label: `${entry.at === opened ? "▾" : "▸"} ${entry.event}${entry.tool ? ` ${entry.tool}` : ""}${entry.agent === "main" ? "" : " (agent)"}`, + isDim: entry.at !== opened, + onPress: () => { + opened = entry.at === opened ? undefined : entry.at; + host.redraw(); + }, + }, + <Text + dimColor + >{`${entry.dropped.length ? "deduped · " : ""}${tokensText(entry.chars)} · ${ago(entry.at, now)}`}</Text> + ) + )} + </Box> + {shown ? ( + <Box flexDirection="column" marginTop={1}> + {divider(kit)} + <Text dimColor wrap="wrap"> + {shown.text.split("\n").slice(0, 30).join("\n")} + </Text> + </Box> + ) : null} + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/idle.ts b/plugins/genesis-tools-mods/hooks/idle.ts index 6968c99fab..0ef9fa36b0 100644 --- a/plugins/genesis-tools-mods/hooks/idle.ts +++ b/plugins/genesis-tools-mods/hooks/idle.ts @@ -1,3 +1,5 @@ +import { modsSettings } from "./settings"; + /** The idle-compaction mod's settings, from the `mods` section of the hooks file. */ export interface IdleCompactSettings { enabled: boolean; @@ -14,41 +16,28 @@ export interface IdleCompactSettings { */ export const IDLE_COMPACT_DEFAULTS: IdleCompactSettings = { enabled: true, minutes: 56, minTokens: 200_000 }; -/** The start of the notice the mod appends. GenesisClaude's session-analyzer `idle-compact.ts scan` counts it. */ -export const IDLE_NOTICE = "Compacted while idle"; +let dueAt: number | undefined; -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; +/** When the armed idle compaction fires (ms since the epoch), or `undefined` while none will: the cache band shows it. */ +export function idleDueAt(): number | undefined { + return dueAt; } -function wholeIn(value: unknown, min: number, max: number): value is number { - return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max; +export function setIdleDueAt(at: number | undefined): void { + dueAt = at; } +/** The start of the notice the mod appends. GenesisClaude's session-analyzer `idle-compact.ts scan` counts it. */ +export const IDLE_NOTICE = "Compacted while idle"; + /** - * `mods.idleCompact`, `mods.idleCompactMinutes` and `mods.idleCompactMinTokens` from the hooks file - * (`tools agents hooks config set mods.idleCompact false --write`). A missing or broken file, and a value + * `mods.idleCompact` of the hooks file: `{ enabled, minutes, minTokens }`, or the flat `mods.idleCompact`, + * `mods.idleCompactMinutes` and `mods.idleCompactMinTokens` of before 2026-10-23 + * (`tools agents hooks config set mods.idleCompact.enabled false --write`). A missing or broken file, and a value * of the wrong kind, leave that value's default. */ export function idleCompactSettings(hooksFile: string | undefined): IdleCompactSettings { - let mods: unknown; - - try { - const stored: unknown = hooksFile === undefined ? undefined : JSON.parse(hooksFile); - mods = isRecord(stored) ? stored.mods : undefined; - } catch { - mods = undefined; - } - - const held = isRecord(mods) ? mods : {}; - - return { - enabled: typeof held.idleCompact === "boolean" ? held.idleCompact : IDLE_COMPACT_DEFAULTS.enabled, - minutes: wholeIn(held.idleCompactMinutes, 1, 59) ? held.idleCompactMinutes : IDLE_COMPACT_DEFAULTS.minutes, - minTokens: wholeIn(held.idleCompactMinTokens, 0, Number.MAX_SAFE_INTEGER) - ? held.idleCompactMinTokens - : IDLE_COMPACT_DEFAULTS.minTokens, - }; + return modsSettings(hooksFile).idleCompact; } /** @@ -79,7 +68,7 @@ export function idleCompactSkip({ tokens: number | undefined; }): string | undefined { if (!settings.enabled) { - return "mods.idleCompact is off"; + return "mods.idleCompact.enabled is off"; } if (surfaces === 0) { diff --git a/plugins/genesis-tools-mods/hooks/inbox.tsx b/plugins/genesis-tools-mods/hooks/inbox.tsx new file mode 100644 index 0000000000..a6611e3f25 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/inbox.tsx @@ -0,0 +1,272 @@ +// The Inbox sidebar (`/mod-inbox`, or the `inbox` chip in the band): what this session put in the GenesisTools +// inbox through the answer MCP, as a summary. The forms it waits on, the messages it sent (`inbox_send`), the +// questions and answers it recorded (`question_answer`) and its todos; an entry opens to its text, [jump to] (the +// tool call that sent it) and its images. `new` is an entry nobody read in the widget yet. +import type { ElementTable, RenderElement } from "claude-code"; +import { + afterQuestionTool, + answersOf, + jumpRowOf, + jumpTo, + type LoggedEntry, + refreshAnswers, + startAnswers, +} from "./answers"; +import { liveAgo } from "./live"; +import { type Feature, type Host, logMod, settingsOf } from "./shared"; +import { + actionRow, + dimNote, + headerRow, + type Kit, + listRow, + type Pressable, + plural, + sectionTitle, + sidebarFrame, + wrapWords, +} from "./sidebar"; + +export const INBOX_PANE = "mods-inbox"; +/** Entries opened or closed against `mods.inbox.expand`. */ +const flipped = new Set<string>(); +const DONE_TODOS = new Set(["implemented", "dismissed", "superseded"]); + +export const inbox: Feature = { + async start(host) { + if ((await settingsOf(host)).inbox.enabled) { + startAnswers(host); + } + }, + async afterTool(host, e) { + if ((await settingsOf(host)).inbox.enabled) { + afterQuestionTool(host, e); + } + }, + sidebars: [{ id: INBOX_PANE, title: "Inbox", draw: inboxPane }], + commands: [ + { + name: "mod-inbox", + description: + "The Inbox sidebar: the forms, messages, answers and todos this session put in the GenesisTools inbox", + async run(host) { + if (!(await settingsOf(host)).inbox.enabled) { + return { + text: "The inbox mod is off: tools agents hooks config set mods.inbox.enabled true --write", + }; + } + + startAnswers(host); + await host.togglePane(INBOX_PANE, "Inbox", "command"); + await refreshAnswers(host, "command", true); + + return {}; + }, + }, + ], +}; + +/** How many items the chip and the header count: waiting forms, entries and open todos. */ +function counts(): { items: number; unread: number } { + const read = answersOf(); + + if (!read) { + return { items: 0, unread: 0 }; + } + + const todos = read.todos.filter((todo) => !DONE_TODOS.has(todo.state)).length; + + return { + items: read.forms.length + read.entries.length + todos, + unread: read.forms.length + read.entries.filter((entry) => entry.isUnread).length, + }; +} + +/** The band chip, `inbox 2 new` or `inbox 5`, with the columns it takes; null when it has nothing to say. */ +export async function inboxChip( + host: Host, + ui: ElementTable +): Promise<{ element: RenderElement; width: number } | null> { + const settings = (await settingsOf(host)).inbox; + const { items, unread } = counts(); + + if (!settings.enabled || settings.chip === "off" || items === 0 || (settings.chip === "unread" && unread === 0)) { + return null; + } + + const label = unread > 0 ? `inbox ${unread} new` : `inbox ${items}`; + const { Button, Text } = ui; + + return { + element: ( + <Button key="inbox-chip" plain onPress={() => void host.togglePane(INBOX_PANE, "Inbox")}> + <Text color={unread > 0 ? "cyan" : undefined} dimColor={unread === 0}> + {label} + </Text> + </Button> + ), + width: [...label].length, + }; +} + +/** The first line of a text, for a row's label. */ +function firstLine(text: string): string { + return ( + text + .split("\n") + .find((line) => line.trim()) + ?.trim() ?? "" + ); +} + +/** The Inbox sidebar: a summary line, then forms, messages, answers and todos; an entry opens to its text. */ +async function inboxPane(host: Host, kit: Kit): Promise<RenderElement> { + const { Box, Text } = kit.ui; + const settings = (await settingsOf(host)).inbox; + const read = answersOf(); + const { items, unread } = counts(); + const width = Math.max(10, kit.columns - 4); + const messages = read?.entries.filter((entry) => entry.tag === "message") ?? []; + const answers = read?.entries.filter((entry) => entry.tag !== "message") ?? []; + const todos = [...(read?.todos ?? [])].sort( + (a, b) => Number(DONE_TODOS.has(a.state)) - Number(DONE_TODOS.has(b.state)) || a.n - b.n + ); + const isOpen = (entry: LoggedEntry) => { + const byDefault = settings.expand === "all" || (settings.expand === "unread" && entry.isUnread); + + return flipped.has(entry.id) ? !byDefault : byDefault; + }; + + const entryRow = (entry: LoggedEntry): RenderElement => { + const open = isOpen(entry); + const actions: Pressable[] = []; + + if (jumpRowOf({ entry: entry.id })) { + actions.push({ + key: `${entry.id}-jump`, + label: "jump to", + hint: "Scrolls the transcript to the tool call that sent this.", + onPress: () => void jumpTo(host, `${entry.id}-jump`, { entry: entry.id }), + }); + } + + if (entry.images.length > 0) { + actions.push({ + key: `${entry.id}-images`, + label: plural(entry.images.length, "image"), + isDim: true, + hint: "Opens the attached images in Preview.", + onPress: () => void host.run(["/usr/bin/open", ...entry.images], { timeoutMs: 10_000 }), + }); + } + + return ( + <Box key={`entry-${entry.id}`} flexDirection="column"> + {listRow( + kit, + { + key: `${entry.id}-row`, + label: `${open ? "▾" : "▸"} ${firstLine(entry.title) || "(no title)"}`, + isDim: !entry.isUnread, + hint: open ? "Folds this entry." : "Opens this entry: its text and [jump to].", + onPress: () => { + if (flipped.has(entry.id)) { + flipped.delete(entry.id); + } else { + flipped.add(entry.id); + } + + host.redraw(); + void logMod(host, "inbox", { event: open ? "fold" : "open", id: entry.id }); + }, + }, + <Text color={entry.isUnread ? "cyan" : undefined} dimColor={!entry.isUnread}> + {`${entry.isUnread ? "new · " : ""}${entry.tag === "message" ? "" : `${entry.tag} · `}${liveAgo(entry.at, { unit: "minute" })}`} + </Text> + )} + {open ? ( + <Box flexDirection="column" marginLeft={2} marginBottom={1}> + {wrapWords( + entry.text.trim() === entry.title.trim() ? "" : entry.text.replace(/\n+/g, " "), + width, + 12 + ).map((line, index) => ( + <Text key={`${entry.id}-t${index}`} wrap="truncate-end"> + {line} + </Text> + ))} + {actions.length > 0 ? actionRow(kit, actions) : null} + </Box> + ) : null} + </Box> + ); + }; + + return sidebarFrame( + kit, + <Box flexDirection="column"> + {headerRow( + kit, + <Text wrap="truncate-end"> + <Text bold>{plural(items, "item")}</Text> + <Text dimColor>{unread > 0 ? ` · ${unread} new` : ""}</Text> + </Text>, + [ + { + key: "inbox-refresh", + label: "refresh", + isDim: true, + hint: "Reads the inbox again.", + onPress: () => void refreshAnswers(host, "button", true), + }, + ] + )} + {dimNote( + kit, + read + ? `What this session sent to the GenesisTools inbox, read ${liveAgo(read.at, { unit: "minute" })}.` + : "Not read yet: the inbox needs the tools CLI." + )} + {read && items === 0 ? dimNote(kit, "Nothing from this session in the inbox yet.") : null} + {read && read.forms.length > 0 ? ( + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Forms waiting for you", `(${read.forms.length})`)} + {read.forms.map((form) => ( + <Box key={`form-${form.id}`} flexDirection="row"> + <Text wrap="truncate-end">{firstLine(form.prompts[0] ?? "") || form.id}</Text> + <Box flexGrow={1} /> + <Text color="yellow">{`waiting · ${liveAgo(form.at, { unit: "minute" })}`}</Text> + </Box> + ))} + {dimNote(kit, "Answer a form in the GenesisTools widget.")} + </Box> + ) : null} + {messages.length > 0 ? ( + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Messages", `(${messages.length})`)} + {messages.map(entryRow)} + </Box> + ) : null} + {answers.length > 0 ? ( + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Questions and answers", `(${answers.length})`)} + {answers.map(entryRow)} + </Box> + ) : null} + {todos.length > 0 ? ( + <Box flexDirection="column" marginTop={1}> + {sectionTitle(kit, "Todos", `(${todos.length})`)} + {todos.map((todo) => ( + <Box key={`todo-${todo.n}`} flexDirection="row"> + <Text dimColor={DONE_TODOS.has(todo.state)} wrap="truncate-end"> + {`TODO ${todo.n} ${todo.title}`} + </Text> + <Box flexGrow={1} /> + <Text dimColor>{todo.state}</Text> + </Box> + ))} + </Box> + ) : null} + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/links.tsx b/plugins/genesis-tools-mods/hooks/links.tsx new file mode 100644 index 0000000000..4be3a3bbc8 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/links.tsx @@ -0,0 +1,511 @@ +// The link fixer: the main loop's reply text is rewritten line by line as it streams, so a path, a commit or a +// repeated decision reaches the screen and the transcript as a link (AGENTS.md "Clickable paths"). Code fences, +// existing markdown links and bare URLs are left as they are, and nothing is linked unless it resolves: a path +// that exists, a commit the repository has, a decision asked in an earlier message. Every transform and every +// candidate it rejected goes to `mods-log/links.jsonl` with the reason, to tune the heuristics from real replies. +// A genesis.tools link (a note, a folder, a commit in the hub, a decision) is written only when this Mac opens it +// (`genesis.ts`); else a note gets its plain path and a folder, a commit or a decision stays text. +import type { HookStream, TurnStepChunk, TurnStepInput, TurnStepResult } from "claude-code"; +import { decisionLink, encodePath, finderLink, type GenesisProbe, genesisProbe, hubLink, mdLink } from "./genesis"; +import { type Host, isRecord, logMod } from "./shared"; + +const DOC = new Set(["md", "mdx", "json", "jsonl", "yaml", "yml", "toml", "log", "txt", "csv"]); +const CODE = new Set([ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "swift", + "py", + "go", + "rs", + "rb", + "php", + "java", + "kt", + "sh", + "zsh", + "html", + "css", + "scss", + "sql", + "proto", + "c", + "h", + "m", + "mm", + "cpp", + "vue", + "svelte", +]); +const ANY_EXT = new Set([ + ...DOC, + ...CODE, + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "pdf", + "zst", + "plist", + "xml", + "lock", + "env", +]); + +/** A path as written: absolute or under the home, relative with a slash, or a bare name with a known extension. */ +const PATH = + /(?:~\/|\/(?:Users|tmp|private|opt|var|Applications|Library|Volumes)\/)[^\s`'"()<>[\]|,;]+|(?:\.{1,2}\/)?(?:[\w@.+-]+\/)+[\w@.+-]*(?::\d+(?:-\d+)?)?|[\w@+-][\w@.+-]*\.[a-zA-Z][a-zA-Z0-9]{0,6}(?::\d+(?:-\d+)?)?/g; +/** A commit: 7 to 12 hex characters with at least one digit and one letter. */ +const SHA = /\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,12}\b/g; +const DECISION = /❓\s*DECISION\s+(\d+)/g; +/** A pull request named by number: `#507`, not inside a word or a URL anchor. */ +const PR = /(?<![\w/#&])#(\d{1,6})\b/g; +/** What stays as written: a markdown link, a bare URL, an inline code span (tried as a path on its own). */ +const PROTECTED = + /(\[[^\]\n]*\]\([^)\s]*\))|((?:https?|cursor|genesis-tools|genesis-md|file):\/\/[^\s)]+)|(`[^`\n]+`)/g; + +export interface LinkContext { + host: Host; + home: string; + cwd: string; + root: string | undefined; + repo: string | undefined; + sessionId: string; + askedBefore: Set<number>; + probe: GenesisProbe; + log: Record<string, unknown>[]; +} + +/** Paths the tools of this session named, by file name, so a bare `register.tsx` finds its one absolute path. */ +const known = new Map<string, Set<string>>(); +const commits = new Map<string, boolean>(); + +function remember(path: unknown): void { + if (typeof path !== "string" || !path.startsWith("/")) { + return; + } + + const name = path.split("/").filter(Boolean).pop(); + + if (!name) { + return; + } + + const set = known.get(name) ?? new Set<string>(); + set.add(path.replace(/\/+$/, "")); + known.set(name, set); +} + +function extOf(path: string): string { + const name = path.split("/").pop() ?? ""; + const dot = name.lastIndexOf("."); + + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +/** + * The link target for an absolute path, by kind (AGENTS.md "Clickable paths"), or undefined for a folder this Mac + * cannot reveal from a link (a plain folder path opens nothing useful). + */ +export function targetFor( + probe: GenesisProbe, + path: string, + kind: "file" | "dir", + line?: number, + end?: number +): string | undefined { + if (kind === "dir") { + return finderLink(probe, path); + } + + const ext = extOf(path); + + if (DOC.has(ext)) { + const md = mdLink(probe, path, line ? `${line}${end ? `-${end}` : ""}` : undefined); + + if (md) { + return md; + } + } + + if (line) { + return `cursor://file${encodePath(path)}:${line}`; + } + + return encodePath(path); +} + +async function kindOf(host: Host, path: string): Promise<"file" | "dir" | undefined> { + const stat = await host.stat(path).catch(() => undefined); + + return stat?.kind === "file" || stat?.kind === "dir" ? stat.kind : undefined; +} + +/** Resolves one candidate to an existing absolute path, or says why not. */ +async function resolve( + ctx: LinkContext, + token: string +): Promise<{ path: string; kind: "file" | "dir"; line?: number; end?: number } | { reason: string }> { + const lineMatch = /^(.*?):(\d+)(?:-(\d+))?$/.exec(token); + const raw = (lineMatch ? lineMatch[1] : token) ?? token; + const line = lineMatch ? Number(lineMatch[2]) : undefined; + const end = lineMatch?.[3] ? Number(lineMatch[3]) : undefined; + const bare = !raw.includes("/"); + + if (bare && !ANY_EXT.has(extOf(raw))) { + return { reason: "no known extension" }; + } + + const tries: string[] = []; + + if (raw.startsWith("~/")) { + tries.push(`${ctx.home}${raw.slice(1)}`); + } else if (raw.startsWith("/")) { + tries.push(raw); + } else if (bare) { + const found = [...(known.get(raw) ?? [])]; + + if (found.length > 1) { + return { reason: `ambiguous: ${found.length} paths in this session` }; + } + + tries.push(...found, `${ctx.cwd}/${raw}`); + } else { + const rel = raw.replace(/^\.\//, ""); + tries.push(`${ctx.cwd}/${rel}`); + + if (ctx.root && ctx.root !== ctx.cwd) { + tries.push(`${ctx.root}/${rel}`); + } + } + + for (const path of tries) { + const clean = path.replace(/\/+$/, ""); + const kind = await kindOf(ctx.host, clean); + + if (kind) { + return { path: clean, kind, line, end }; + } + } + + return { reason: "not on disk" }; +} + +const pullRequests = new Map<string, { title: string; url: string } | null>(); + +/** The PR's title and URL from `gh`, kept for the session; null for a number that is no PR of this repository. */ +async function pullRequest(ctx: LinkContext, n: string): Promise<{ title: string; url: string } | null> { + if (!ctx.root || !ctx.repo) { + return null; + } + + const key = `${ctx.repo}#${n}`; + const held = pullRequests.get(key); + + if (held !== undefined) { + return held; + } + + const run = await ctx.host + .run(["gh", "pr", "view", n, "--json", "title,url"], { cwd: ctx.root, timeoutMs: 8_000 }) + .catch(() => undefined); + let found: { title: string; url: string } | null = null; + + try { + const parsed: unknown = run?.exitCode === 0 ? JSON.parse(run.stdout) : undefined; + + if (isRecord(parsed) && typeof parsed.title === "string" && typeof parsed.url === "string") { + found = { title: parsed.title.replace(/^Draft: /, ""), url: parsed.url }; + } + } catch { + found = null; + } + + pullRequests.set(key, found); + + return found; +} + +async function isCommit(ctx: LinkContext, sha: string): Promise<boolean> { + if (!ctx.root) { + return false; + } + + const key = `${ctx.root}|${sha}`; + const held = commits.get(key); + + if (held !== undefined) { + return held; + } + + const run = await ctx.host + .run(["git", "-C", ctx.root, "cat-file", "-t", sha], { timeoutMs: 3_000 }) + .catch(() => undefined); + const yes = run?.exitCode === 0 && run.stdout.trim() === "commit"; + commits.set(key, yes); + + return yes; +} + +/** Links the paths, decisions and commits of one stretch of plain text (no link, URL or code span in it). */ +async function linkPlain(ctx: LinkContext, text: string): Promise<string> { + let out = ""; + let at = 0; + const hits: { start: number; end: number; kind: "path" | "sha" | "decision" | "pr"; token: string }[] = []; + + for (const match of text.matchAll(DECISION)) { + hits.push({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + kind: "decision", + token: match[0], + }); + } + + for (const match of text.matchAll(PATH)) { + hits.push({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + kind: "path", + token: match[0], + }); + } + + for (const match of text.matchAll(PR)) { + hits.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length, kind: "pr", token: match[0] }); + } + + for (const match of text.matchAll(SHA)) { + hits.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length, kind: "sha", token: match[0] }); + } + + hits.sort((a, b) => a.start - b.start || b.end - a.end); + + for (const hit of hits) { + if (hit.start < at) { + continue; + } + + let replacement: string | undefined; + let start = hit.start; + let end = hit.end; + + if (hit.kind === "pr") { + const pr = await pullRequest(ctx, hit.token.slice(1)); + + if (pr) { + // The rule: a PR's label carries its title, unless the reply already writes it after the number. + const titled = text.slice(end, end + 2) === " ("; + replacement = `[${titled ? hit.token : `${hit.token} (${pr.title})`}](${pr.url})`; + } + } else if (hit.kind === "decision") { + const n = Number(/\d+/.exec(hit.token)?.[0]); + + const link = ctx.askedBefore.has(n) ? decisionLink(ctx.probe, ctx.sessionId, n) : undefined; + + if (link) { + replacement = `[${hit.token}](${link})`; + } + } else if (hit.kind === "sha") { + const link = ctx.repo ? hubLink(ctx.probe, { repo: ctx.repo, commit: hit.token }) : undefined; + + if (link && (await isCommit(ctx, hit.token))) { + const bold = text.slice(start - 2, start) === "**" && text.slice(end, end + 2) === "**"; + start = bold ? start - 2 : start; + end = bold ? end + 2 : end; + const label = bold ? `**${hit.token}**` : hit.token; + replacement = `[${label}](${link})`; + } + } else { + const token = hit.token.replace(/[.,:;!?]+$/, ""); + end = hit.start + token.length; + const found = await resolve(ctx, token); + + const target = + "path" in found ? targetFor(ctx.probe, found.path, found.kind, found.line, found.end) : undefined; + + if (target) { + replacement = `[${token}](${target})`; + } else if ("path" in found) { + ctx.log.push({ kind: "miss", token, reason: "this Mac opens no link to it" }); + } else if (token.includes("/") || ANY_EXT.has(extOf(token.replace(/:\d+(-\d+)?$/, "")))) { + ctx.log.push({ kind: "miss", token, reason: found.reason }); + } + } + + if (replacement === undefined) { + continue; + } + + ctx.log.push({ kind: hit.kind, token: text.slice(start, end), link: replacement }); + out += text.slice(at, start) + replacement; + at = end; + } + + return out + text.slice(at); +} + +/** One line of reply text with its candidates linked; links, URLs and code spans that do not resolve stay. */ +export async function linkLine(ctx: LinkContext, line: string): Promise<string> { + let out = ""; + let at = 0; + + for (const match of line.matchAll(PROTECTED)) { + const start = match.index ?? 0; + out += await linkPlain(ctx, line.slice(at, start)); + at = start + match[0].length; + + const code = match[3]; + + if (code) { + const inner = code.slice(1, -1).trim(); + const found = /\s/.test(inner) ? { reason: "code with spaces" } : await resolve(ctx, inner); + + const target = + "path" in found ? targetFor(ctx.probe, found.path, found.kind, found.line, found.end) : undefined; + + if (target) { + const link = `[${inner}](${target})`; + ctx.log.push({ kind: "code-path", token: code, link }); + out += link; + continue; + } + } + + out += match[0]; + } + + return out + (await linkPlain(ctx, line.slice(at))); +} + +/** `messageCount`: the messages the request carried; later ones are this reply itself, which asks nothing earlier. */ +async function contextOf(host: Host, messageCount: number): Promise<LinkContext> { + const home = (await host.env("HOME")) ?? ""; + const cwd = await host.cwd(); + const sessionId = await host.sessionId(); + const top = await host + .run(["git", "-C", cwd, "rev-parse", "--show-toplevel"], { timeoutMs: 3_000 }) + .catch(() => undefined); + const root = top?.exitCode === 0 ? top.stdout.trim() : undefined; + const origin = root + ? await host + .run(["git", "-C", root, "remote", "get-url", "origin"], { timeoutMs: 3_000 }) + .catch(() => undefined) + : undefined; + const repo = + origin?.exitCode === 0 ? /github\.com[:/]([^/]+\/[^/.]+?)(?:\.git)?\s*$/.exec(origin.stdout)?.[1] : undefined; + const askedBefore = new Set<number>(); + + for (const message of (await host.messages()).slice(0, messageCount)) { + if (message.role !== "assistant") { + continue; + } + + for (const match of message.text.matchAll(/(^|[^[])❓\s*\**\s*DECISION\s+(\d+)/gm)) { + askedBefore.add(Number(match[2])); + } + } + + return { host, home, cwd, root, repo, sessionId, askedBefore, probe: await genesisProbe(host), log: [] }; +} + +/** Notes the absolute paths a tool call names, so a bare file name in a later reply finds its one path. */ +export function rememberToolPaths(input: unknown): void { + if (isRecord(input)) { + remember(input.file_path); + remember(input.notebook_path); + remember(input.path); + + if (typeof input.command === "string") { + for (const match of input.command.matchAll(/(?:^|[\s'"=])(\/(?:Users|tmp|private)\/[^\s'"|;&)]+)/g)) { + remember(match[1]); + } + } + } +} + +/** The turn.step stage of the main loop: reply text passes line by line, each line linked (see `linkLine`). */ +export async function* linksStep( + host: Host, + e: TurnStepInput, + stream: HookStream<TurnStepChunk, TurnStepResult | undefined> +): AsyncGenerator<TurnStepChunk, TurnStepResult | undefined> { + { + let ctx: LinkContext | undefined; + const lines = new Map<number, string>(); + const fenced = new Set<number>(); + let answer = ""; + let changed = false; + + async function* emit(index: number, line: string): AsyncGenerator<TurnStepChunk> { + if (/^\s*```/.test(line)) { + if (fenced.has(index)) { + fenced.delete(index); + } else { + fenced.add(index); + } + + answer += line; + yield { kind: "text", index, text: line }; + return; + } + + if (fenced.has(index) || !line.trim()) { + answer += line; + yield { kind: "text", index, text: line }; + return; + } + + ctx ??= await contextOf(host, e.messageCount); + const linked = await linkLine(ctx, line); + changed ||= linked !== line; + answer += linked; + yield { kind: "text", index, text: linked }; + } + + async function* flush(): AsyncGenerator<TurnStepChunk> { + for (const [index, rest] of lines) { + if (rest) { + yield* emit(index, rest); + } + } + + lines.clear(); + } + + for await (const chunk of stream) { + if (chunk.kind !== "text") { + yield* flush(); + yield chunk; + continue; + } + + let held = (lines.get(chunk.index) ?? "") + chunk.text; + let newline = held.indexOf("\n"); + + while (newline !== -1) { + yield* emit(chunk.index, held.slice(0, newline + 1)); + held = held.slice(newline + 1); + newline = held.indexOf("\n"); + } + + lines.set(chunk.index, held); + } + + yield* flush(); + + if (ctx && ctx.log.length > 0) { + await logMod(host, "links", { session: ctx.sessionId, turn: e.turnId, step: e.index, items: ctx.log }); + } + + // The recorded answer carries the links too, so a hook above and the transcript read what was shown. + const result: TurnStepResult | undefined = await stream.result; + + return result && changed ? { ...result, answer } : result; + } +} diff --git a/plugins/genesis-tools-mods/hooks/live.ts b/plugins/genesis-tools-mods/hooks/live.ts new file mode 100644 index 0000000000..30438d271d --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/live.ts @@ -0,0 +1,81 @@ +// Times that stay current on screen: `liveAgo(at)` ("12s ago") and `liveLeft(until)` ("4m 05s") are the text to draw, +// and drawing one notes when that text next changes. `startLiveClock` checks once a second and redraws this plugin's +// views only when a drawn time has changed, so nothing redraws while no live time is on screen: a closed sidebar, a +// band whose times count in minutes. A redraw draws the times again, which notes the next change. +import { type Host, shortDuration } from "./shared"; + +const HOUR = 3_600_000; + +/** When the earliest live time drawn since the last tick changes its text. */ +let due: number | undefined; +let started = false; + +function changesAt(at: number): void { + due = due === undefined ? at : Math.min(due, at); +} + +/** The ms until a `shortDuration` of `ms` that grows reads differently: the next second under an hour, else the next minute. */ +function untilNextUp(ms: number): number { + return ms < HOUR ? 1_000 - (ms % 1_000) : 60_000 - (ms % 60_000); +} + +/** How long since `at`: `12s`, `4m 05s`, `1h 02m`; for a run time or an idle time, which take no "ago". */ +export function liveSince(at: number, now = Date.now()): string { + const ms = Math.max(0, now - at); + changesAt(now + untilNextUp(ms)); + + return shortDuration(ms); +} + +/** `12s ago`, `4m 05s ago`, `1h 02m ago`; `unit: "minute"` gives `4m ago` and changes once a minute. */ +export function liveAgo(at: number, options: { unit?: "second" | "minute"; now?: number } = {}): string { + const now = options.now ?? Date.now(); + const ms = Math.max(0, now - at); + + if (options.unit === "minute") { + changesAt(now + 60_000 - (ms % 60_000)); + return ms < 60_000 ? "just now" : `${shortDuration(ms).split(" ")[0]} ago`; + } + + return `${liveSince(at, now)} ago`; +} + +/** What is left until `until`: `4m 05s`; `unit: "minute"` gives whole minutes rounded up, `5m`. */ +export function liveLeft(until: number, options: { unit?: "second" | "minute"; now?: number } = {}): string { + const now = options.now ?? Date.now(); + const ms = Math.max(0, until - now); + + if (options.unit === "minute") { + changesAt(now + (ms % 60_000 || 60_000)); + return `${Math.ceil(ms / 60_000)}m`; + } + + changesAt(now + (ms % 1_000 || 1_000)); + + return shortDuration(ms); +} + +/** Once a second: when a drawn live time has changed, redraw. Started once per loaded module. */ +export function startLiveClock(host: Host): void { + if (started) { + return; + } + + started = true; + host.every(1_000, () => { + if (due !== undefined && Date.now() >= due) { + due = undefined; + host.redraw(); + } + }); +} + +/** When the next redraw is due; a test reads it. */ +export function liveDue(): number | undefined { + return due; +} + +/** Forgets the due time: a test starts over. */ +export function forgetLive(): void { + due = undefined; +} diff --git a/plugins/genesis-tools-mods/hooks/model-label.ts b/plugins/genesis-tools-mods/hooks/model-label.ts new file mode 100644 index 0000000000..107d275493 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/model-label.ts @@ -0,0 +1,49 @@ +// The short name of a model id, for the agent-model mod, the agents band and the agents panel rows (`agent-row.ts`). +// No engine types here: the CLI's row command (`src/agents/bin/hook-subagent-line.ts`) imports it. + +export type Format = "label" | "short" | "version"; + +const LETTERS: Record<string, string> = { opus: "O", sonnet: "S", fable: "F", haiku: "H" }; + +/** + * `label`: `claude-sonnet-5-5` shows as `sonnet-5-5`, a dated id loses its date, an alias such as `sonnet` stays. + * `short` (`mods.agentModel.format`): one letter per family, O S F H, else the label. + * `version`: the letter and the version, `O5.5`; an alias without a version is the letter alone. + */ +export function modelLabel(id: string, format: Format): string { + const label = id.replace(/^claude-/, "").replace(/-\d{8}$/, ""); + + if (format === "label") { + return label; + } + + const family = Object.keys(LETTERS).find((name) => label.startsWith(name)); + const letter = family ? LETTERS[family] : undefined; + + if (!family || !letter) { + return label; + } + + const version = format === "version" ? /^-(\d+(?:-\d+)*)/.exec(label.slice(family.length))?.[1] : undefined; + + return version ? `${letter}${version.replaceAll("-", ".")}` : letter; +} + +/** The model with the effort it runs at: `O5.5(xhigh)`, `sonnet-5-5 (high)`; no effort, the model alone. */ +export function withEffort(label: string, effort: string | undefined, format: Format): string { + if (!effort) { + return label; + } + + return format === "label" ? `${label} (${effort})` : `${label}(${effort})`; +} + +/** What the agent-model mod puts in front of a description: `sonnet · ` (`label`), `(S) ` or `(S5.5) `. */ +export function modelPrefix(label: string, format: Format): string { + return format === "label" ? `${label} · ` : `(${label}) `; +} + +/** A description without the model the agent-model mod put in front (`(H) `, `(H5.5) `, `haiku-5-5 · `). */ +export function bareDescription(description: string): string { + return description.replace(/^\([A-Z][\d.]*\) /, "").replace(/^[a-z]+(?:-[\w.]+)* · /, ""); +} diff --git a/plugins/genesis-tools-mods/hooks/register.tsx b/plugins/genesis-tools-mods/hooks/register.tsx index 0a06750801..ddbf44cc31 100644 --- a/plugins/genesis-tools-mods/hooks/register.tsx +++ b/plugins/genesis-tools-mods/hooks/register.tsx @@ -1,7 +1,29 @@ -import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from "claude-code"; +import type { + ElementTable, + EngineInterface, + HookStream, + Register, + RenderElement, + Timer, + TurnStepChunk, + TurnStepResult, + UiPane, +} from "claude-code"; import { atom, read, update } from "claude-code"; import type { FlippedFiles, ShownCalls } from "../types"; -import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice } from "./idle"; +import { agentModelStep, spawnWithModel } from "./agent-model"; +import { agentsBand, agentsRow, noteTranscript, recordSpawn } from "./agents-band"; +import { askOnRow } from "./ask-row"; +import { bandTree } from "./band"; +import { cacheBand, cacheRow, markEnded } from "./cache-band"; +import { decisions, decisionsChip } from "./decisions"; +import { primeGenesisProbe } from "./genesis"; +import { gitBand, gitRows } from "./git-band"; +import { hookLedger, hooksChip, hooksChipWidth, ledgerClassic } from "./hook-ledger"; +import { compactionEndsIdleWait, idleCompactSettings, idleCompactSkip, idleNotice, setIdleDueAt } from "./idle"; +import { inbox, inboxChip } from "./inbox"; +import { linksStep, rememberToolPaths } from "./links"; +import { startLiveClock } from "./live"; import { type CallSection, collapseGitSyncEnabled, @@ -12,6 +34,11 @@ import { openByDefault, sectionsOfCalls, } from "./sections"; +import { agentsDir, type Feature, type Host, logMod, readHooksFile, settingsNow, settingsOf } from "./shared"; +import { focusOf, forgetFocus, noteFocus } from "./sidebar"; +import { progressSuffix, spinner } from "./spinner"; +import { clearStall, noteStop, stallCatcher, stallRow } from "./stall"; +import { tmpRow, tmpUsage } from "./tmp-usage"; // The full-diff mod. When the diff hook (`tools agents hooks`) cut a Bash call's message, it wrote the // whole diff to ~/.genesis-tools/agents/hook-diffs/<tool_use_id>.md. This draws those hunks with Claude @@ -27,34 +54,46 @@ import { // One drawing may hold 100,000 characters in all (CodeProps.source); stay well inside it. const MAX_CHARS = 60_000; -/** `<home>/.genesis-tools/agents`; `GENESIS_TOOLS_HOME` replaces the home, as it does for every tool. */ -async function agentsDir($: EngineInterface): Promise<string | undefined> { - const home = (await $.env.get("GENESIS_TOOLS_HOME")) ?? (await $.env.get("HOME")); - - return home ? `${home}/.genesis-tools/agents` : undefined; -} - -async function readHooksFile($: EngineInterface, dir: string | undefined): Promise<string | undefined> { - return dir ? $.fs.read(`${dir}/hooks.json`).catch(() => undefined) : undefined; -} - interface Diffs { sections: CallSection[]; - /** The calls that run a git sync (`git pull`, …), each with that command, while `mods.collapseGitSync` is on. */ + /** The calls that run a git sync (`git pull`, …), each with that command, while `mods.diff.collapseGitSync` is on. */ syncs: Map<string, string>; /** The notes of the calls past `MAX_CHARS` (`sectionsOfCalls`), named under the diffs so none goes without a trace. */ omitted: string[]; } +/** + * A call's diff note never changes once the diff hook wrote it, and a tool row draws again on every redraw (the live + * clock's included), so a note read once is kept; a missing one is asked for again. The hooks file is read at most + * every 5 seconds. + */ +const noteText = new Map<string, string>(); +const NOTES_KEPT = 80; +let hooksFileHeld: { at: number; dir: string; text: string | undefined } | undefined; + +async function hooksFileOf(host: Host, dir: string): Promise<string | undefined> { + const held = hooksFileHeld; + + if (held && held.dir === dir && Date.now() - held.at < 5_000) { + return held.text; + } + + const text = await readHooksFile(host, dir); + hooksFileHeld = { at: Date.now(), dir, text }; + + return text; +} + async function fullDiffs($: EngineInterface, calls: readonly { id: string; input: unknown }[]): Promise<Diffs> { const none: Diffs = { sections: [], syncs: new Map(), omitted: [] }; - const dir = calls.length > 0 ? await agentsDir($) : undefined; + const host = hostOf($); + const dir = calls.length > 0 ? await agentsDir(host) : undefined; if (!dir) { return none; } - const hooksFile = await readHooksFile($, dir); + const hooksFile = await hooksFileOf(host, dir); if (!fullDiffEnabled(hooksFile)) { return none; @@ -64,7 +103,23 @@ async function fullDiffs($: EngineInterface, calls: readonly { id: string; input const note = (id: string) => `${dir}/hook-diffs/${id}.md`; const { sections, omitted } = await sectionsOfCalls({ ids, - read: (id) => $.fs.read(note(id)), + read: async (id) => { + const held = noteText.get(id); + + if (held !== undefined) { + return held; + } + + const text = await $.fs.read(note(id)); + noteText.set(id, text); + + // The rows on screen are a few dozen; the oldest note goes first. + if (noteText.size > NOTES_KEPT) { + noteText.delete(noteText.keys().next().value ?? id); + } + + return text; + }, maxChars: MAX_CHARS, }); const syncs = new Map<string, string>(); @@ -85,7 +140,7 @@ async function fullDiffs($: EngineInterface, calls: readonly { id: string; input // The idle-compaction mod. Claude Code's own idle compaction fires once, 54 minutes after the last // request, and gives up at the first gate that refuses (`near_limit` refuses while the account's last reply // carried a rate-limit warning, even after the window reset). Then the 1-hour cache expires and the next -// prompt pays for the whole context again. This timer compacts at `mods.idleCompactMinutes` (56) after the +// prompt pays for the whole context again. This timer compacts at `mods.idleCompact.minutes` (56) after the // last main-loop turn, unless a compaction already happened. A reload of the mod drops the timer; the next // turn arms it again. let idleTimer: Timer | undefined; @@ -93,13 +148,16 @@ let idleTimer: Timer | undefined; function stopIdleTimer(): void { idleTimer?.cancel(); idleTimer = undefined; + setIdleDueAt(undefined); } async function compactIfIdle($: EngineInterface): Promise<void> { idleTimer = undefined; + setIdleDueAt(undefined); - // Read again at the fire time, so `config set mods.idleCompact false` stops a timer already armed. - const settings = idleCompactSettings(await readHooksFile($, await agentsDir($))); + // Read again at the fire time, so `config set mods.idleCompact.enabled false` stops a timer already armed. + const host = hostOf($); + const settings = idleCompactSettings(await readHooksFile(host, await agentsDir(host))); const [surfaces, usage] = await Promise.all([$.session.surfaces(), $.session.usage()]); if (idleCompactSkip({ settings, surfaces: surfaces.length, tokens: usage.context.tokens }) !== undefined) { @@ -283,9 +341,258 @@ async function announceSession($: EngineInterface): Promise<void> { } } +/** A stage's generator as a stream the next stage reads, with the `result` a `next(e)` stream carries. */ +function streamOf<C, R>(stage: AsyncGenerator<C, R>): HookStream<C, R> { + let settle: (value: R) => void = () => undefined; + let fail: (error: unknown) => void = () => undefined; + const result = new Promise<R>((resolve, reject) => { + settle = resolve; + fail = reject; + }); + + // An unread result must not surface as an unhandled rejection; a stage that reads it still sees the error. + result.catch(() => undefined); + + async function* run(): AsyncGenerator<C, R> { + try { + const value = yield* stage; + settle(value); + + return value; + } catch (error) { + fail(error); + throw error; + } + } + + return Object.assign(run(), { result }); +} + +/** + * The engine as the feature files reach it. Built here because only this file may hand `$` on (see `shared.ts`); + * each closure spells the call it makes, so `claude plugin validate` reads every capability the plugin uses. + */ +function hostOf($: EngineInterface): Host { + return { + run: (argv, init) => $.process.run(argv, init), + read: (path) => $.fs.read(path), + write: (path, text) => $.fs.write(path, text), + // `$.fs.write` replaces a file and the engine has no append, so this is the one place an append runs `cat >>` + // in `sh` (making the folder first). When `$.fs` gets an append, only these lines change. + append: async (path, text) => { + const done = await $.process.run( + ["/bin/sh", "-c", 'mkdir -p "$(dirname "$1")" && cat >> "$1"', "sh", path], + { + stdin: text, + timeoutMs: 5_000, + } + ); + + if (done.exitCode !== 0) { + throw new Error(`append to ${path} failed (exit ${done.exitCode}): ${done.stderr.trim()}`); + } + }, + list: (path) => $.fs.list(path), + exists: (path) => $.fs.exists(path), + stat: (path) => $.fs.stat(path), + env: (name) => (name === "HOME" ? $.env.get("HOME") : $.env.get("GENESIS_TOOLS_HOME")), + cwd: () => $.session.cwd(), + sessionId: () => $.session.id(), + messages: () => $.session.messages(), + agents: () => $.agent.list(), + every: (ms, fn) => { + $.clock.every(ms, fn); + }, + storeGet: (key) => $.store.get(key), + storeSet: (key, value) => $.store.set(key, value), + status: (text) => $.ui.status(text), + fill: (args) => $.prompt.fill(args), + submit: (text) => $.prompt.submit({ text }), + openPane: (id, title, from) => showPane($, { id, title, from, toggle: false }), + togglePane: (id, title, from) => showPane($, { id, title, from, toggle: true }), + closePane: (id) => $.ui.close({ id }), + copy: (text) => $.ui.copy({ text }), + scroll: (requestId) => $.ui.scroll({ to: { requestId }, block: "start" }), + openUrl: (url) => $.process.run(["open", url], { timeoutMs: 10_000 }), + toast: (text) => $.ui.toast(text), + redraw: () => $.ui.invalidate("ui.render"), + }; +} + +/** Whether the terminal docks panes (fullscreen), and its height, as the band's last drawing said. */ +let docksPanes: boolean | undefined; +let terminalRows = 30; +/** + * The panes as the engine last listed them, read at every draw of the band and of a sidebar. A press decides from + * this list, because asking the engine first means an await, and an `$.ui.open` after an await is no longer the + * person's: the engine then places it only from 144 columns, and on a narrower terminal it waits undrawn. + */ +let knownPanes: readonly UiPane[] = []; + +async function notePanes($: EngineInterface): Promise<void> { + knownPanes = await $.ui.panes().catch(() => knownPanes); +} + +/** + * Shows a sidebar, or with `toggle` closes it when it is the one shown. Above the prompt it takes the keys, and Esc + * closes it. Docked beside the transcript it takes them only as `mods.sidebar.focus` says (`command`: when a slash + * command opened it; `always`; `never`), so a click on the band leaves the prompt typing. + * + * Every engine call is made before the first await, so it answers the press. A sidebar open as a tab behind another + * is closed and opened again: an open of an open id only retitles it, and only `focus` raises it. + */ +function showPane( + $: EngineInterface, + pane: { id: string; title: string; from?: "command" | "band"; toggle: boolean } +): Promise<unknown> { + const { id, title, from = "band" } = pane; + const held = knownPanes.find((known) => known.id === id); + const action = !held ? "open" : held.isShown && held.isPlaced ? (pane.toggle ? "close" : "keep") : "raise"; + + if (action === "close") { + knownPanes = knownPanes.filter((known) => known.id !== id); + return $.ui.close({ id }).finally(() => void logShow($, { id, from, action })); + } + + if (action === "raise") { + void $.ui.close({ id }); + } + + forgetFocus(id); + const rows = Math.max(12, Math.floor(terminalRows * 0.6)); + const focus = settingsNow().sidebar.focus; + const opened = + docksPanes !== true + ? $.ui.open({ id, title, focus: true, closeOnEscape: true, rows }) + : $.ui.open( + focus === "always" || (focus === "command" && from === "command") + ? { id, title, focus: true } + : { id, title } + ); + + return opened.then(async (result) => { + await notePanes($); + await logShow($, { id, from, action, ...result }); + + return result; + }); +} + +async function logShow($: EngineInterface, record: Record<string, unknown>): Promise<void> { + await logMod(hostOf($), "sidebar", { event: "show", ...record }); +} + +// The features that share events. The engine takes one unmatched hook per event from a plugin, so each event +// below has one hook that calls every feature's part in this order. +const FEATURES: Feature[] = [ + decisions, + inbox, + gitBand, + spinner, + cacheBand, + tmpUsage, + agentsBand, + hookLedger, + stallCatcher, +]; + export const register: Register = (on) => { + on("session.start", async ($, e, next) => { + const host = hostOf($); + // A reload runs `session.start` again in the middle of a turn: mark the session at once, not at the next turn. + await announceSession($); + // Times drawn with `liveAgo` / `liveLeft` redraw when their text changes. + startLiveClock(host); + // Which genesis.tools links open here, asked outside any render (a newer render aborts an older one's runs). + primeGenesisProbe(host); + + for (const feature of FEATURES) { + for (const command of feature.commands ?? []) { + await $.command.register({ + name: command.name, + description: command.description, + ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), + }); + } + + await feature.start?.(host); + } + + return next(e); + }); + + on("session.end", async ($, e, next) => { + await markEnded(hostOf($)); + + return next(e); + }); + + on("command.run", async ($, e, next) => { + const command = FEATURES.flatMap((feature) => feature.commands ?? []).find((held) => held.name === e.command); + + return command ? command.run(hostOf($), e.args ?? "") : next(e); + }); + + on("tool.call", async ($, e, next) => { + const host = hostOf($); + // Before the call runs, so its PostToolUse hook sees the mark; a no-op once this module set it. + await announceSession($); + rememberToolPaths(e); + const ends = FEATURES.flatMap((feature) => feature.beforeTool?.(host, e) ?? []); + + try { + const result = await next(e); + + for (const feature of FEATURES) { + await feature.afterTool?.(host, e); + } + + return result; + } finally { + for (const end of ends) { + end(); + } + } + }); + + // Two stages over the model's stream: the Agent arguments get the model, the main loop's reply text its links. + on("turn.step", async function* ($, e, next) { + const host = hostOf($); + const settings = await settingsOf(host); + let stream: HookStream<TurnStepChunk, TurnStepResult | undefined> = next(e); + + if (settings.agentModel.enabled) { + stream = streamOf(agentModelStep(stream, settings.agentModel.format)); + } + + if (settings.links.enabled && e.agentId === undefined) { + stream = streamOf(linksStep(host, e, stream)); + } + + return yield* stream; + }); + + on("agent.spawn", async ($, e, next) => { + const host = hostOf($); + const settings = (await settingsOf(host)).agentModel; + const result = await next(settings.enabled ? spawnWithModel(e, settings.format) : e); + recordSpawn(host, e, result); + + return result; + }); + + on("classic.*", async ($, e, next) => { + noteTranscript(e); + await noteStop(hostOf($), e); + const result = await next(e); + const kept = await ledgerClassic(hostOf($), e, result); + + return kept ? { ...result, additionalContext: kept } : result; + }); + on("turn.start", async ($, e, next) => { stopIdleTimer(); + clearStall(hostOf($), "new turn"); await announceSession($); return next(e); @@ -293,67 +600,229 @@ export const register: Register = (on) => { on("turn.complete", async ($, e, next) => { const done = await next(e); + const host = hostOf($); if (e.agentId === undefined) { stopIdleTimer(); - const settings = idleCompactSettings(await readHooksFile($, await agentsDir($))); + const settings = idleCompactSettings(await readHooksFile(host, await agentsDir(host))); if (settings.enabled) { - idleTimer = $.clock.after(settings.minutes * 60_000, () => { + const ms = settings.minutes * 60_000; + idleTimer = $.clock.after(ms, () => { void compactIfIdle($); }); + + // The band shows the countdown only when the timer will compact: a small context is skipped at fire time. + const tokens = (await $.session.usage().catch(() => undefined))?.context.tokens; + setIdleDueAt(tokens === undefined || tokens >= settings.minTokens ? Date.now() + ms : undefined); } } + for (const feature of FEATURES) { + await feature.afterTurn?.(host, e); + } + return done; }); - on("session.compact", async (_$, e, next) => { + on("session.compact", async ($, e, next) => { const result = await next(e); + const host = hostOf($); // The person's /compact, the engine's own auto or idle compaction: nothing is left to save. if (compactionEndsIdleWait({ agentId: e.agentId, trigger: e.trigger, skipped: result.skip !== undefined })) { stopIdleTimer(); } + if (result.skip === undefined) { + for (const feature of FEATURES) { + await feature.compacted?.(host, e.agentId); + } + } + return result; }); - on("ui.render", { component: "ToolGroup" }, async ($, e, next) => { - const drawn = await next(e); - - if (e.props.isExpanded) { - return drawn; + on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => { + if (e.props.hasSurvey) { + return next(e); } - const calls = e.props.calls.flatMap((call) => - call.tool === "Bash" && !call.isRunning && call.tool_use_id - ? [{ id: call.tool_use_id, input: call.input }] - : [] + docksPanes = e.viewport?.isFullscreen ?? docksPanes; + terminalRows = e.viewport?.rows ?? terminalRows; + await notePanes($); + const host = hostOf($); + const ui = $.ui.resolve(e); + const columns = e.props.bodyColumns; + const [hooks, decisionsAt, inboxAt, ...rest] = await Promise.all([ + hooksChip(host, ui), + decisionsChip(host, ui), + inboxChip(host, ui), + stallRow(host, ui), + cacheRow(host, ui), + agentsRow(host, ui, columns), + tmpRow(host, ui), + ]); + const chips = [decisionsAt, inboxAt, hooks ? { element: hooks, width: hooksChipWidth() } : null].filter( + (chip) => chip !== null + ); + // The first row gives the chips their text and a column before each. `bodyColumns` already leaves out the + // engine's five columns at the right end, which hold its `[-]`. + const reserve = chips.reduce((sum, chip) => sum + chip.width + 1, 0); + const git = await gitRows(host, ui, { columns, firstReserve: reserve }); + + // The chips share the first git row; every other checkout's row has the whole width. + return bandTree( + ui, + [ + topRow( + ui, + git[0] ?? null, + chips.map((chip) => chip.element) + ), + ...git.slice(1), + ...rest, + ], + await next(e) ); - const diffs = await fullDiffs($, calls); + }); - if (diffs.sections.length === 0 && diffs.omitted.length === 0) { - return drawn; + // Every feature's sidebar (`Feature.sidebars`), by the pane's id; another plugin's panes pass on. + on("ui.render", { component: "Pane" }, async ($, e, next) => { + const sidebar = FEATURES.flatMap((feature) => feature.sidebars ?? []).find((held) => held.id === e.requestId); + + if (!sidebar) { + return next(e); } - return drawDiffs($, $.ui.resolve(e), e.requestId, diffs, drawn); + const host = hostOf($); + await notePanes($); + const kit = { + ui: $.ui.resolve(e), + columns: Math.max(1, e.props.bodyColumns - (e.props.placement === "dock" ? 1 : 0)), + rows: e.props.scroll.bodyRows, + placement: e.props.placement, + isFocused: e.props.isFocused, + focused: focusOf(e.requestId).key, + isOnEngineStop: focusOf(e.requestId).onEngineStop, + hints: (await settingsOf(host)).sidebar.hints, + pressables: [], + }; + + return sidebar.draw(host, kit); }); - on("ui.render", { component: "ToolUse" }, async ($, e, next) => { - const drawn = await next(e); + // Where a sidebar's focus ring is, so the line under a row of Buttons says what the focused one does. + on("ui.focus", { component: "Pane" }, async ($, e, next) => { + const result = await next(e); - if (e.props.tool !== "Bash" || e.props.isRunning) { - return drawn; + if (!result.deny && FEATURES.some((feature) => feature.sidebars?.some((held) => held.id === e.requestId))) { + noteFocus(e.requestId, e.element); + $.ui.invalidate("ui.render"); } - const diffs = await fullDiffs($, [{ id: e.props.tool_use_id, input: e.props.input }]); + return result; + }); - if (diffs.sections.length === 0 && diffs.omitted.length === 0) { - return drawn; + on("prompt.autocomplete", async ($, e, next) => { + const host = hostOf($); + const mine = (await Promise.all(FEATURES.map((feature) => feature.autocomplete?.(host, e) ?? []))).flat(); + const below = await next(e); + + return mine.length > 0 ? { suggestions: [...mine, ...below.suggestions] } : below; + }); + + on("ui.render", { component: "Spinner" }, async ($, e, next) => { + if (!(await settingsOf(hostOf($))).spinner.enabled) { + return next(e); } - return drawDiffs($, $.ui.resolve(e), e.props.tool_use_id, diffs, drawn); + return next({ ...e, props: { ...e.props, suffix: progressSuffix(e.props.suffix, Date.now()) } }); + }); + + on("ui.render", { component: "ToolGroup" }, async ($, e, next) => { + const ui = $.ui.resolve(e); + const drawn = await withGroupDiffs($, e.props, e.requestId, ui, await next(e)); + const calls = e.props.calls.map((call) => ({ tool: call.tool, input: call.input })); + + return askOnRow(hostOf($), ui, drawn, { + id: e.requestId, + isRunning: e.props.isActive || e.props.isExpanded, + calls, + }); + }); + + on("ui.render", { component: "ToolUse" }, async ($, e, next) => { + const ui = $.ui.resolve(e); + const { tool, input, output, tool_use_id, isRunning } = e.props; + const drawn = await withCallDiffs($, e.props, ui, await next(e)); + + return askOnRow(hostOf($), ui, drawn, { id: tool_use_id, isRunning, calls: [{ tool, input, output }] }); }); }; +/** The band's first row: the git row at the left, the chips (decisions, inbox, hooks) at the right edge. */ +function topRow(ui: ElementTable, git: RenderElement | null, chips: readonly RenderElement[]): RenderElement | null { + if (chips.length === 0) { + return git; + } + + const { Box } = ui; + + return ( + <Box key="band-top" flexDirection="row"> + <Box flexShrink={1} minWidth={0} overflow="hidden"> + {git} + </Box> + <Box flexGrow={1} /> + <Box flexShrink={0} flexDirection="row" columnGap={1} marginLeft={1}> + {chips} + </Box> + </Box> + ); +} + +async function withGroupDiffs( + $: EngineInterface, + props: { + isExpanded: boolean; + calls: readonly { tool: string; input: unknown; isRunning: boolean; tool_use_id?: string }[]; + }, + row: string, + ui: ElementTable, + drawn: RenderElement +): Promise<RenderElement> { + if (props.isExpanded) { + return drawn; + } + + const calls = props.calls.flatMap((call) => + call.tool === "Bash" && !call.isRunning && call.tool_use_id ? [{ id: call.tool_use_id, input: call.input }] : [] + ); + const diffs = await fullDiffs($, calls); + + if (diffs.sections.length === 0 && diffs.omitted.length === 0) { + return drawn; + } + + return drawDiffs($, ui, row, diffs, drawn); +} + +async function withCallDiffs( + $: EngineInterface, + props: { tool: string; isRunning: boolean; tool_use_id: string; input: unknown }, + ui: ElementTable, + drawn: RenderElement +): Promise<RenderElement> { + if (props.tool !== "Bash" || props.isRunning) { + return drawn; + } + + const diffs = await fullDiffs($, [{ id: props.tool_use_id, input: props.input }]); + + if (diffs.sections.length === 0 && diffs.omitted.length === 0) { + return drawn; + } + + return drawDiffs($, ui, props.tool_use_id, diffs, drawn); +} diff --git a/plugins/genesis-tools-mods/hooks/sections.ts b/plugins/genesis-tools-mods/hooks/sections.ts index ea8fab3784..30ad8abff0 100644 --- a/plugins/genesis-tools-mods/hooks/sections.ts +++ b/plugins/genesis-tools-mods/hooks/sections.ts @@ -1,3 +1,5 @@ +import { isRecord, modsSettings } from "./settings"; + /** One file of a diff-hook full-diff note: its path, its line counts and its unified-diff hunks. */ export interface Section { path: string; @@ -132,37 +134,14 @@ export async function sectionsOfCalls({ return { sections, omitted }; } -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} - -/** - * A boolean of the `mods` section of the hooks file (`~/.genesis-tools/agents/hooks.json`), which - * `tools agents hooks config set mods.<key> false` writes. A file that is missing, unreadable or not JSON, - * and a value that is not a boolean, leave the default: on. - */ -function modsSwitch(hooksFile: string | undefined, key: "fullDiff" | "collapseGitSync"): boolean { - if (hooksFile === undefined) { - return true; - } - - try { - const stored: unknown = JSON.parse(hooksFile); - const mods = isRecord(stored) ? stored.mods : undefined; - const value = isRecord(mods) ? mods[key] : undefined; - - return typeof value === "boolean" ? value : true; - } catch { - return true; - } -} - +/** `mods.diff.fullDiff` of the hooks file (the flat `mods.fullDiff` of before still counts); on by default. */ export function fullDiffEnabled(hooksFile: string | undefined): boolean { - return modsSwitch(hooksFile, "fullDiff"); + return modsSettings(hooksFile).diff.fullDiff; } +/** `mods.diff.collapseGitSync` of the hooks file (the flat `mods.collapseGitSync` of before still counts). */ export function collapseGitSyncEnabled(hooksFile: string | undefined): boolean { - return modsSwitch(hooksFile, "collapseGitSync"); + return modsSettings(hooksFile).diff.collapseGitSync; } /** The command of a Bash call's input, or an empty string. */ diff --git a/plugins/genesis-tools-mods/hooks/settings.ts b/plugins/genesis-tools-mods/hooks/settings.ts new file mode 100644 index 0000000000..7d53f3ee20 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/settings.ts @@ -0,0 +1,255 @@ +// The `mods` section of the hooks file as this plugin reads it. No engine types here: the CLI's own tests import +// this file to hold its defaults equal to `src/agents/lib/hooks/config.ts`, outside the plugin's type world. + +export function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * The `mods` section of the hooks file, one object per feature (`tools agents hooks config set mods.<feature>.<field>`). + * The CLI keeps the same shape and defaults in `src/agents/lib/hooks/config.ts` (`ModsConfig`); a test holds the two + * equal, since this plugin runs outside the repository and cannot import it. + */ +export interface ModsSettings { + diff: { fullDiff: boolean; collapseGitSync: boolean }; + idleCompact: { enabled: boolean; minutes: number; minTokens: number }; + agentModel: { enabled: boolean; format: "label" | "short" | "version" }; + /** Read by the CLI's agents panel command (`hooks/agent-row.ts`), not by a hook of this plugin. */ + agentRow: { enabled: boolean; format: "label" | "short" | "version"; effort: boolean }; + decisions: { + enabled: boolean; + chip: "any" | "waiting" | "off"; + expand: "waiting" | "all" | "none"; + history: "collapsed" | "shown"; + enter: "stage-then-send" | "stage"; + }; + inbox: { enabled: boolean; chip: "any" | "unread" | "off"; expand: "unread" | "all" | "none" }; + links: { enabled: boolean }; + git: { + enabled: boolean; + labels: "full" | "minimal"; + checkouts: number; + cwd: "touched" | "always"; + expand: "first" | "all" | "none"; + name: "repo-folder" | "repo" | "folder"; + earlier: "show" | "hide"; + }; + askRow: { enabled: boolean }; + spinner: { enabled: boolean }; + cacheBand: { enabled: boolean; warnMinutes: number; cautionMinutes: number; others: boolean }; + tmpUsage: { enabled: boolean; minutes: number }; + agentsBand: { enabled: boolean; warnMinutes: number }; + hooks: { tokens: boolean; dedupe: boolean }; + sidebar: { hints: boolean; feedback: "line" | "toast" | "both"; focus: "command" | "always" | "never" }; + stall: { enabled: boolean; action: "button" | "auto"; minutes: number }; +} + +/** Every mod past the full diff and the idle compaction starts off: `tools agents hooks mods enable <name> --write`. */ +export const MODS_DEFAULTS: ModsSettings = { + diff: { fullDiff: true, collapseGitSync: true }, + idleCompact: { enabled: true, minutes: 56, minTokens: 200_000 }, + agentModel: { enabled: false, format: "label" }, + agentRow: { enabled: false, format: "version", effort: true }, + decisions: { enabled: false, chip: "any", expand: "waiting", history: "collapsed", enter: "stage-then-send" }, + inbox: { enabled: false, chip: "any", expand: "none" }, + links: { enabled: false }, + git: { + enabled: false, + labels: "full", + checkouts: 3, + cwd: "touched", + expand: "first", + name: "repo-folder", + earlier: "show", + }, + askRow: { enabled: false }, + spinner: { enabled: false }, + cacheBand: { enabled: false, warnMinutes: 10, cautionMinutes: 20, others: true }, + tmpUsage: { enabled: false, minutes: 5 }, + agentsBand: { enabled: false, warnMinutes: 60 }, + hooks: { tokens: false, dedupe: false }, + sidebar: { hints: true, feedback: "line", focus: "command" }, + stall: { enabled: false, action: "button", minutes: 5 }, +}; + +function whole(value: unknown, min: number, max: number, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : fallback; +} + +function bool(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +/** + * The part of `value` that differs from `shipped`: how the CLI's `storedOverrides` (`src/agents/lib/hooks/config.ts`) + * reads a hooks file. Its writer stores every setting plus the defaults of that day (`_shippedDefaults`), and only a + * value that differs from that copy is the user's own; the rest follows today's default. + */ +function changedOnly(value: unknown, shipped: unknown): unknown { + if (Array.isArray(value) || Array.isArray(shipped)) { + return JSON.stringify(value) === JSON.stringify(shipped) ? undefined : value; + } + + if (isRecord(value) && isRecord(shipped)) { + const out: Record<string, unknown> = {}; + + for (const [key, held] of Object.entries(value)) { + const own = changedOnly(held, shipped[key]); + + if (own !== undefined) { + out[key] = own; + } + } + + return Object.keys(out).length > 0 ? out : undefined; + } + + return value === shipped ? undefined : value; +} + +/** + * The `mods` section of a hooks file, read as the CLI reads it (`changedOnly`), so the two never disagree: on + * 2026-10-23 a file written while the new mods shipped on kept them on here, while the CLI read them as the new + * default (off) and its next write turned them off. A missing or broken file, and a value of the wrong kind, keep the + * default. + * The flat keys of before 2026-10-23 (`mods.fullDiff`, `mods.collapseGitSync`, `mods.idleCompact` as a boolean, + * `mods.idleCompactMinutes`, `mods.idleCompactMinTokens`) still count where the object leaves a field out. + */ +export function modsSettings(hooksFile: string | undefined): ModsSettings { + let held: Record<string, unknown> = {}; + + try { + const stored: unknown = hooksFile === undefined ? undefined : JSON.parse(hooksFile); + const mods = isRecord(stored) ? stored.mods : undefined; + const shipped = + isRecord(stored) && isRecord(stored._shippedDefaults) ? stored._shippedDefaults.mods : undefined; + const own = shipped === undefined ? mods : changedOnly(mods, shipped); + held = isRecord(own) ? own : {}; + } catch { + held = {}; + } + + const part = (key: string): Record<string, unknown> => { + const value = held[key]; + + return isRecord(value) ? value : {}; + }; + const d = MODS_DEFAULTS; + const diff = part("diff"); + const idle = part("idleCompact"); + const legacyIdle = typeof held.idleCompact === "boolean" ? held.idleCompact : undefined; + const model = part("agentModel"); + const row = part("agentRow"); + const cache = part("cacheBand"); + const tmp = part("tmpUsage"); + const agents = part("agentsBand"); + const hooks = part("hooks"); + const sidebar = part("sidebar"); + const stall = part("stall"); + const enabled = (key: string, fallback: boolean) => bool(part(key).enabled, fallback); + const git = part("git"); + const decisions = part("decisions"); + const inbox = part("inbox"); + + return { + diff: { + fullDiff: bool(diff.fullDiff, bool(held.fullDiff, d.diff.fullDiff)), + collapseGitSync: bool(diff.collapseGitSync, bool(held.collapseGitSync, d.diff.collapseGitSync)), + }, + idleCompact: { + enabled: bool(idle.enabled, legacyIdle ?? d.idleCompact.enabled), + minutes: whole(idle.minutes, 1, 59, whole(held.idleCompactMinutes, 1, 59, d.idleCompact.minutes)), + minTokens: whole( + idle.minTokens, + 0, + Number.MAX_SAFE_INTEGER, + whole(held.idleCompactMinTokens, 0, Number.MAX_SAFE_INTEGER, d.idleCompact.minTokens) + ), + }, + agentModel: { + enabled: bool(model.enabled, d.agentModel.enabled), + format: + model.format === "short" || model.format === "label" || model.format === "version" + ? model.format + : d.agentModel.format, + }, + agentRow: { + enabled: bool(row.enabled, d.agentRow.enabled), + format: + row.format === "short" || row.format === "label" || row.format === "version" + ? row.format + : d.agentRow.format, + effort: bool(row.effort, d.agentRow.effort), + }, + decisions: { + enabled: enabled("decisions", d.decisions.enabled), + chip: + decisions.chip === "any" || decisions.chip === "waiting" || decisions.chip === "off" + ? decisions.chip + : d.decisions.chip, + expand: + decisions.expand === "waiting" || decisions.expand === "all" || decisions.expand === "none" + ? decisions.expand + : d.decisions.expand, + history: + decisions.history === "collapsed" || decisions.history === "shown" + ? decisions.history + : d.decisions.history, + enter: + decisions.enter === "stage-then-send" || decisions.enter === "stage" + ? decisions.enter + : d.decisions.enter, + }, + inbox: { + enabled: enabled("inbox", d.inbox.enabled), + chip: inbox.chip === "any" || inbox.chip === "unread" || inbox.chip === "off" ? inbox.chip : d.inbox.chip, + expand: + inbox.expand === "unread" || inbox.expand === "all" || inbox.expand === "none" + ? inbox.expand + : d.inbox.expand, + }, + links: { enabled: enabled("links", d.links.enabled) }, + git: { + enabled: enabled("git", d.git.enabled), + labels: git.labels === "full" || git.labels === "minimal" ? git.labels : d.git.labels, + checkouts: whole(git.checkouts, 1, 6, d.git.checkouts), + cwd: git.cwd === "touched" || git.cwd === "always" ? git.cwd : d.git.cwd, + expand: git.expand === "first" || git.expand === "all" || git.expand === "none" ? git.expand : d.git.expand, + name: git.name === "repo-folder" || git.name === "repo" || git.name === "folder" ? git.name : d.git.name, + earlier: git.earlier === "show" || git.earlier === "hide" ? git.earlier : d.git.earlier, + }, + askRow: { enabled: enabled("askRow", d.askRow.enabled) }, + spinner: { enabled: enabled("spinner", d.spinner.enabled) }, + cacheBand: { + enabled: bool(cache.enabled, d.cacheBand.enabled), + warnMinutes: whole(cache.warnMinutes, 1, 59, d.cacheBand.warnMinutes), + cautionMinutes: whole(cache.cautionMinutes, 1, 59, d.cacheBand.cautionMinutes), + others: bool(cache.others, d.cacheBand.others), + }, + tmpUsage: { + enabled: bool(tmp.enabled, d.tmpUsage.enabled), + minutes: whole(tmp.minutes, 1, 120, d.tmpUsage.minutes), + }, + agentsBand: { + enabled: bool(agents.enabled, d.agentsBand.enabled), + warnMinutes: whole(agents.warnMinutes, 1, 24 * 60, d.agentsBand.warnMinutes), + }, + hooks: { tokens: bool(hooks.tokens, d.hooks.tokens), dedupe: bool(hooks.dedupe, d.hooks.dedupe) }, + sidebar: { + hints: bool(sidebar.hints, d.sidebar.hints), + feedback: + sidebar.feedback === "line" || sidebar.feedback === "toast" || sidebar.feedback === "both" + ? sidebar.feedback + : d.sidebar.feedback, + focus: + sidebar.focus === "command" || sidebar.focus === "always" || sidebar.focus === "never" + ? sidebar.focus + : d.sidebar.focus, + }, + stall: { + enabled: bool(stall.enabled, d.stall.enabled), + action: stall.action === "button" || stall.action === "auto" ? stall.action : d.stall.action, + minutes: whole(stall.minutes, 1, 120, d.stall.minutes), + }, + }; +} diff --git a/plugins/genesis-tools-mods/hooks/shared.ts b/plugins/genesis-tools-mods/hooks/shared.ts new file mode 100644 index 0000000000..2b6041c276 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/shared.ts @@ -0,0 +1,167 @@ +// Helpers every feature of this plugin shares: the host the features reach the engine through, where GenesisTools +// keeps its files, the `mods.*` switches of the hooks file, and the per-feature log the features write so their +// heuristics can be improved from real use. +// +// 🛑 Only `register.tsx` sees `$`. `claude plugin validate` refuses `$` passed into a function another file declares, +// and a `read`/`update` of an atom another file declares (checked 2026-10-23), so a feature file gets a `Host`: the +// closures `register.tsx` builds over `$`, each spelled `$.noun.method(...)` there. A feature keeps its state in +// module variables and calls `host.redraw()` after a change, which draws this plugin's `ui.render` hooks again. +import type { + AgentInfo, + CommandRunResult, + FsEntry, + FsStat, + ProcessRunInit, + ProcessRunResult, + PromptAutocompleteInput, + PromptAutocompleteSuggestion, + PromptFillArgs, + ToolCallInput, + TurnCompleteInput, + UiCopyResult, + UiScrollResult, +} from "claude-code"; +import { MODS_DEFAULTS, type ModsSettings, modsSettings } from "./settings"; +import type { Sidebar } from "./sidebar"; + +export { isRecord, MODS_DEFAULTS, type ModsSettings, modsSettings } from "./settings"; + +export interface Host { + run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>; + read: (path: string) => Promise<string>; + write: (path: string, text: string) => Promise<void>; + /** Adds `text` at the end of a file, making it and its folder when missing; rejects when the write failed. */ + append: (path: string, text: string) => Promise<void>; + list: (path: string) => Promise<FsEntry[]>; + exists: (path: string) => Promise<boolean>; + stat: (path: string) => Promise<FsStat>; + /** The validator lists every variable a module reads, so the names are fixed. */ + env: (name: "HOME" | "GENESIS_TOOLS_HOME") => Promise<string | undefined>; + cwd: () => Promise<string>; + sessionId: () => Promise<string>; + messages: () => Promise<readonly { role: string; text: string }[]>; + agents: () => Promise<AgentInfo[]>; + every: (ms: number, fn: () => void) => void; + storeGet: (key: string) => Promise<unknown>; + storeSet: (key: string, value: unknown) => Promise<void>; + status: (text: string | undefined) => void; + fill: (args: PromptFillArgs) => Promise<unknown>; + submit: (text: string) => Promise<unknown>; + /** Opens a sidebar: docked beside the transcript in fullscreen, a dialog with the keyboard (Esc closes) otherwise. */ + openPane: (id: string, title: string, from?: "command" | "band") => Promise<unknown>; + /** Closes the sidebar when it shows, else opens it: what a press on its band chip does. */ + togglePane: (id: string, title: string, from?: "command" | "band") => Promise<unknown>; + closePane: (id: string) => Promise<unknown>; + copy: (text: string) => Promise<UiCopyResult>; + /** + * Scrolls a transcript row into view, its top at the window's top: a message by its transcript `uuid`, a tool + * call by its id. The engine moves a transcript row only while the call answers the person's own press. + */ + scroll: (requestId: string) => Promise<UiScrollResult>; + /** Opens a URL with macOS `open`: a browser page, or a genesis.tools route. */ + openUrl: (url: string) => Promise<unknown>; + /** A short notice that fades by itself. */ + toast: (text: string) => void; + /** Draws this plugin's `ui.render` hooks again, so a band or pane shows the state just changed. */ + redraw: () => void; +} + +/** `<home>/.genesis-tools/agents`; `GENESIS_TOOLS_HOME` replaces the home, as it does for every tool. */ +export async function agentsDir(host: Host): Promise<string | undefined> { + const home = (await host.env("GENESIS_TOOLS_HOME")) ?? (await host.env("HOME")); + + return home ? `${home}/.genesis-tools/agents` : undefined; +} + +export async function readHooksFile(host: Host, dir: string | undefined): Promise<string | undefined> { + return dir ? host.read(`${dir}/hooks.json`).catch(() => undefined) : undefined; +} + +let cached: { at: number; value: ModsSettings } | undefined; + +/** `modsSettings` of the hooks file, read at most every 10 s: a band redraws often, and the file changes rarely. */ +export async function settingsOf(host: Host): Promise<ModsSettings> { + const now = Date.now(); + + if (cached && now - cached.at < 10_000) { + return cached.value; + } + + const value = modsSettings(await readHooksFile(host, await agentsDir(host))); + cached = { at: now, value }; + + return value; +} + +/** + * The settings as last read, without waiting: the defaults before the first read. For a press, whose engine calls must + * come before any await (`register.tsx`, `showPane`). + */ +export function settingsNow(): ModsSettings { + return cached?.value ?? MODS_DEFAULTS; +} + +/** Drops the remembered settings: a test sets up another hooks file. */ +export function forgetSettings(): void { + cached = undefined; +} + +/** Appends one JSON line, `at` first, to a `.jsonl` file (`Host.append`); a line that could not be written is dropped. */ +export async function appendJsonl(host: Host, path: string, record: Record<string, unknown>): Promise<void> { + await host.append(path, `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`).catch(() => undefined); +} + +/** + * Appends one JSON line to `<agents>/mods-log/<feature>.jsonl`, the record of what a feature did, so its heuristics + * can be checked against what the person then did. + */ +export async function logMod(host: Host, feature: string, record: Record<string, unknown>): Promise<void> { + const dir = await agentsDir(host); + + if (dir) { + await appendJsonl(host, `${dir}/mods-log/${feature}.jsonl`, record); + } +} + +/** `12s`, `4m 05s`, `1h 02m`. */ +export function shortDuration(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + + if (s < 60) { + return `${s}s`; + } + + if (s < 3600) { + return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; + } + + return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`; +} + +/** + * One feature's part in the events several features share. The engine takes one unmatched hook per event from a + * plugin, so `register.tsx` holds that hook and calls each feature's part in turn. + */ +export interface Feature { + /** On session start: start timers, take a first reading. */ + start?: (host: Host) => Promise<void>; + /** Slash commands, registered at session start and answered by `command.run`. */ + commands?: { + name: string; + description: string; + argumentHint?: string; + run: (host: Host, args: string) => Promise<CommandRunResult>; + }[]; + /** Before a tool call runs; the function it returns runs when the call has ended, however it ended. */ + beforeTool?: (host: Host, e: ToolCallInput) => (() => void) | undefined; + /** After a tool call answered. */ + afterTool?: (host: Host, e: ToolCallInput) => Promise<void>; + /** After a turn completed, the main loop's or a subagent's (`e.agentId`). */ + afterTurn?: (host: Host, e: TurnCompleteInput) => Promise<void>; + /** After a loop compacted (the main loop when `agentId` is undefined). */ + compacted?: (host: Host, agentId: string | undefined) => void | Promise<void>; + /** The sidebars it opens from the band (`sidebar.tsx`). */ + sidebars?: Sidebar[]; + /** Rows for the prompt's typeahead as the person types (`prompt.autocomplete`); none for most tokens. */ + autocomplete?: (host: Host, e: PromptAutocompleteInput) => Promise<PromptAutocompleteSuggestion[]>; +} diff --git a/plugins/genesis-tools-mods/hooks/sidebar.tsx b/plugins/genesis-tools-mods/hooks/sidebar.tsx new file mode 100644 index 0000000000..1579e7e232 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/sidebar.tsx @@ -0,0 +1,344 @@ +// The sidebar every feature of this plugin opens from the band: a pane the surface docks beside the transcript in +// fullscreen and shows as a dialog above the prompt otherwise, as Claude Code's own `/diff` mod does +// (github.com/anthropics/claude-code, mods/diff/hooks/views). A feature lists its sidebars in `Feature.sidebars`; +// `register.tsx` holds the one `Pane` hook that draws them. The parts below copy the diff mod's look: a header line +// with its controls at the right edge, list rows that are plain dim Buttons with their counts at the right edge, +// dim rules and dim notes. +// +// A Button with a `hint` says what it does in the sidebar's status bar: two rows of its own under a rule at the +// bottom, so nothing above moves when the focus ring does. It shows, in order: what the last press did (`pressed()`: +// busy, done or failed) until the ring moves or 20 seconds pass, the focused Button's hint (Tab, the arrows, a click; +// the engine runs no plugin code on mere hover), else how to reach the hints. Docked, the body fills the pane, so the +// bar sits at its foot. `mods.sidebar.hints` and `mods.sidebar.feedback` switch them. +import type { ElementTable, RenderElement } from "claude-code"; +import { type Host, settingsOf } from "./shared"; + +/** What a sidebar draws with: the surface's elements, the body's size and seat, and where its focus ring is. */ +export interface Kit { + ui: ElementTable; + columns: number; + rows: number; + placement: "dock" | "inline"; + /** Whether the pane holds the keyboard. */ + isFocused?: boolean; + /** The key of the element the pane's focus ring is on. */ + focused?: string; + /** The ring moved onto one of the engine's own stops (the close mark, another pane's tab). */ + isOnEngineStop?: boolean; + /** Whether a focused Button's `hint` is drawn (`mods.sidebar.hints`). */ + hints?: boolean; + /** Every Button drawn in this pass, for the status bar. */ + pressables?: Pressable[]; +} + +export interface Sidebar { + id: string; + title: string; + draw: (host: Host, kit: Kit) => RenderElement | Promise<RenderElement>; +} + +/** One action or row: its address, its text, what a press does, and what it does in words. */ +export interface Pressable { + key: string; + label: string; + onPress: () => void; + isDim?: boolean; + isPrimary?: boolean; + /** A list row drawn bold in this color: the one picked of several. */ + color?: string; + /** The status bar's line while the focus ring is on this Button: what a press does, as a sentence. */ + hint?: string; + /** One digit or lowercase letter that presses it while its site holds the keys (not while a field has them). */ + hotkey?: string; +} + +const FEEDBACK_MS = 20_000; +const STATUS_ROWS = 2; + +type Tone = "busy" | "ok" | "error"; + +/** What the last press did, for the status bar. */ +let feedback: { key: string; text: string; tone: Tone; at: number } | undefined; +const focusedIn = new Map<string, string | undefined>(); + +/** Notes where a pane's focus ring went (`ui.focus`); a move off the last pressed Button ends its feedback. */ +export function noteFocus(paneId: string, element: string | undefined): void { + focusedIn.set(paneId, element); + + if (feedback && feedback.tone !== "busy" && feedback.key !== element) { + feedback = undefined; + } +} + +/** The key the pane's focus ring is on; `onEngineStop` when a move took it to the engine's close mark or a tab. */ +export function focusOf(paneId: string): { key?: string; onEngineStop: boolean } { + const key = focusedIn.get(paneId); + + return { key, onEngineStop: focusedIn.has(paneId) && key === undefined }; +} + +/** Forgets where a pane's ring was: it opens again with the ring on nothing. */ +export function forgetFocus(paneId: string): void { + focusedIn.delete(paneId); +} + +/** + * Says what a press did: in the status bar, as a toast, or both (`mods.sidebar.feedback`). `busy` is for the + * moment between the press and the result (a `gh` read takes a second); it never goes to a toast. + */ +export async function pressed(host: Host, key: string, text: string, tone: Tone | boolean = "ok"): Promise<void> { + const kind: Tone = tone === true ? "ok" : tone === false ? "error" : tone; + const mode = (await settingsOf(host)).sidebar.feedback; + + if (mode !== "toast" || kind === "busy") { + feedback = { key, text, tone: kind, at: Date.now() }; + } + + if (mode !== "line" && kind !== "busy") { + host.toast(`${kind === "ok" ? "✓" : "✗"} ${text}`); + } + + host.redraw(); +} + +/** The status bar's text: the last press's result, else the focused Button's hint, else how to reach the hints. */ +function statusOf(kit: Kit): { text: string; tone: Tone | "hint" | "rest"; label?: string } { + const done = feedback; + + // A busy line that no result replaced (the work threw) gives way after a minute. + if (done && Date.now() - done.at < (done.tone === "busy" ? 60_000 : FEEDBACK_MS)) { + return { text: done.text, tone: done.tone }; + } + + const focused = kit.pressables?.find((action) => action.key === kit.focused); + + if (kit.hints !== false && focused?.hint) { + return { text: focused.hint, tone: "hint", label: focused.label }; + } + + if (kit.isFocused && kit.isOnEngineStop) { + return { text: "Enter closes this sidebar. Tab moves on, Esc gives the keys back.", tone: "hint", label: "✕" }; + } + + return { + text: kit.isFocused + ? "Tab moves between buttons and this line says what each does. Enter presses, Esc gives the keys back." + : "Click in this pane (or open it with its slash command), then Tab: this line says what each button does.", + tone: "rest", + }; +} + +/** The status bar: a rule, then two fixed rows. */ +function statusBar(kit: Kit): RenderElement { + const { Box, Text } = kit.ui; + const status = statusOf(kit); + const mark = { busy: "… ", ok: "✓ ", error: "✗ ", hint: "", rest: "" }[status.tone]; + const color = { busy: "yellow", ok: "green", error: "red", hint: undefined, rest: undefined }[status.tone]; + + return ( + <Box key="sidebar-status" flexDirection="column" flexShrink={0}> + {divider(kit)} + <Box height={STATUS_ROWS} overflow="hidden"> + <Text wrap="wrap" color={color} dimColor={status.tone === "rest"}> + {status.label ? <Text bold>{`${status.label} `}</Text> : null} + {`${mark}${status.text}`} + </Text> + </Box> + </Box> + ); +} + +/** The rows the status bar takes: its rule and its two lines. */ +export const STATUS_BAR_ROWS = 1 + STATUS_ROWS; + +/** + * The sidebar's body. With a Button that has a hint, the status bar follows it. A body that says how many rows it + * takes (`bodyRows`, every line one row) and fits with the bar gets the bar pinned to the pane's foot, so it stays + * in view; a taller body scrolls, the bar after it. One column of room at the right when docked, as the diff mod leaves. + */ +export function sidebarFrame(kit: Kit, body: RenderElement, bodyRows?: number): RenderElement { + const { Box } = kit.ui; + const hasBar = Boolean(kit.pressables?.some((action) => action.hint)); + const docked = kit.placement === "dock"; + const pinned = hasBar && docked && bodyRows !== undefined && bodyRows + STATUS_BAR_ROWS <= kit.rows; + + return ( + <Box flexDirection="column" paddingRight={docked ? 1 : 0} {...(pinned ? { height: kit.rows } : {})}> + {body} + {pinned ? <Box flexGrow={1} /> : null} + {hasBar ? statusBar(kit) : null} + </Box> + ); +} + +/** One row of a counted body: whatever it holds, cut to one row. */ +export function line(kit: Kit, key: string, content: RenderElement | null): RenderElement { + const { Box } = kit.ui; + + return ( + <Box key={key} height={1} overflow="hidden" flexDirection="row"> + {content} + </Box> + ); +} + +/** Text wrapped at word breaks into lines of `width` columns, at most `max` of them, the last cut with `…`. */ +export function wrapWords(text: string, width: number, max: number): string[] { + const room = Math.max(4, width); + const lines: string[] = []; + let current = ""; + + for (const word of text.split(/\s+/).filter(Boolean)) { + const next = current ? `${current} ${word}` : word; + + if ([...next].length <= room) { + current = next; + continue; + } + + if (current) { + lines.push(current); + } + + current = [...word].length > room ? `${[...word].slice(0, room - 1).join("")}…` : word; + } + + if (current) { + lines.push(current); + } + + if (lines.length <= max) { + return lines; + } + + const kept = lines.slice(0, max); + const last = [...(kept[max - 1] ?? "")]; + kept[max - 1] = `${last.slice(0, Math.max(0, room - 1)).join("")}…`; + + return kept; +} + +/** The columns a row of Buttons takes: `[ label ]` each, one column between. */ +export function buttonsWidth(actions: readonly Pick<Pressable, "label">[]): number { + return actions.reduce((sum, action, index) => sum + [...action.label].length + 4 + (index ? 1 : 0), 0); +} + +/** The first line: what the sidebar counts, a spacer, its controls at the right edge. */ +export function headerRow(kit: Kit, left: RenderElement, controls: readonly Pressable[] = []): RenderElement { + const { Box } = kit.ui; + + return ( + <Box flexDirection="row" height={1}> + {left} + <Box flexGrow={1} /> + <Box flexDirection="row" columnGap={1} marginLeft={1}> + {controls.map((control) => actionButton(kit, control))} + </Box> + </Box> + ); +} + +/** + * One list row: a plain Button as wide as its label, a spacer, then the tail at the right edge. The tail keeps its + * width; a label too long for the rest wraps beside it. + */ +export function listRow(kit: Kit, row: Pressable, tail: RenderElement | null = null): RenderElement { + const { Box, Button, Text } = kit.ui; + kit.pressables?.push(row); + + return ( + <Box key={`${row.key}:row`} flexDirection="row"> + <Box flexShrink={1} minWidth={0}> + <Button key={row.key} plain dimColor={row.isDim} onPress={row.onPress}> + {row.color ? ( + <Text color={row.color} bold> + {row.label} + </Text> + ) : ( + row.label + )} + </Button> + </Box> + <Box flexGrow={1} /> + {tail ? ( + // Shrinks first: a long tail (a staged answer, a long status) once took the row and drew the label + // one character per line. + <Box flexShrink={4} minWidth={0} marginLeft={1}> + {tail} + </Box> + ) : null} + </Box> + ); +} + +export function divider(kit: Kit): RenderElement { + const { Text } = kit.ui; + + return ( + <Text dimColor wrap="truncate-end"> + {"─".repeat(Math.max(1, kit.columns))} + </Text> + ); +} + +export function dimNote(kit: Kit, text: string, key?: string): RenderElement { + const { Text } = kit.ui; + + return ( + <Text key={key} dimColor wrap="truncate-end"> + {text} + </Text> + ); +} + +/** A heading inside the body: bold, with a dim note after it. */ +export function sectionTitle(kit: Kit, title: string, note = ""): RenderElement { + const { Text } = kit.ui; + + return ( + <Text wrap="truncate-end"> + <Text bold>{title}</Text> + <Text dimColor>{note ? ` ${note}` : ""}</Text> + </Text> + ); +} + +export function actionButton(kit: Kit, action: Pressable): RenderElement { + const { Button } = kit.ui; + kit.pressables?.push(action); + + return ( + <Button + key={action.key} + dimColor={action.isDim} + {...(action.isPrimary ? ({ variant: "primary" } as const) : {})} + {...(action.hotkey ? { hotkey: action.hotkey } : {})} + onPress={action.onPress} + > + {action.label} + </Button> + ); +} + +/** A row of action Buttons, wrapped when the body is narrow. */ +export function actionRow(kit: Kit, actions: readonly Pressable[]): RenderElement { + const { Box } = kit.ui; + + return ( + <Box flexDirection="row" flexWrap="wrap" columnGap={1}> + {actions.map((action) => actionButton(kit, action))} + </Box> + ); +} + +/** `1 tool`, `3 tools`. */ +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** A bar of `width` cells, full to `share` (0 to 1), as the diff mod's todo bar draws one. */ +export function barText(share: number, width: number): string { + const full = Math.round(Math.min(1, Math.max(0, share)) * width); + + return `${"█".repeat(full)}${"░".repeat(width - full)}`; +} diff --git a/plugins/genesis-tools-mods/hooks/spinner.tsx b/plugins/genesis-tools-mods/hooks/spinner.tsx new file mode 100644 index 0000000000..918f562b0c --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/spinner.tsx @@ -0,0 +1,68 @@ +// Live progress on the spinner line: while the main loop waits on a tool, the line names the tool, what it runs and +// for how long, and how many subagents are working (`Sauteing… · Bash bun run test 2m 10s · 2 agents`). A timer +// draws the line again every 2 s while anything runs. `mods.spinner.enabled` switches it. +import { type Feature, type Host, isRecord, settingsOf, shortDuration } from "./shared"; + +const running = new Map<string, { label: string; at: number }>(); +let agents = 0; +const BUSY = new Set(["pending", "running", "waiting"]); + +export function toolLabel(tool: string, input: unknown): string { + const held = isRecord(input) ? input : {}; + const what = [held.description, held.command, held.file_path, held.pattern, held.url].find( + (value): value is string => typeof value === "string" && value.trim() !== "" + ); + const short = what ? what.replace(/\s+/g, " ").trim() : ""; + + return short ? `${tool} ${short.length > 40 ? `${short.slice(0, 40)}…` : short}` : tool; +} + +/** The suffix after the spinner word: the engine's own, then the longest running tool and the busy agents. */ +export function progressSuffix(base: string, now: number): string { + const parts: string[] = []; + const oldest = [...running.values()].sort((a, b) => a.at - b.at)[0]; + + if (oldest) { + const more = running.size > 1 ? ` +${running.size - 1}` : ""; + parts.push(`${oldest.label} ${shortDuration(now - oldest.at)}${more}`); + } + + if (agents > 0) { + parts.push(`${agents} agent${agents === 1 ? "" : "s"}`); + } + + return parts.length ? `${base} · ${parts.join(" · ")}` : base; +} + +async function beat(host: Host): Promise<void> { + if (!(await settingsOf(host)).spinner.enabled) { + return; + } + + const list = await host.agents().catch(() => []); + agents = list.filter((agent) => BUSY.has(agent.status)).length; + + if (running.size > 0 || agents > 0) { + host.redraw(); + } +} + +export const spinner: Feature = { + async start(host) { + host.every(2_000, () => { + void beat(host); + }); + }, + beforeTool(_host, e) { + if (e.agentId !== undefined) { + return undefined; + } + + const id = e.tool_use_id; + running.set(id, { label: toolLabel(e.tool, e), at: Date.now() }); + + return () => { + running.delete(id); + }; + }, +}; diff --git a/plugins/genesis-tools-mods/hooks/stall.tsx b/plugins/genesis-tools-mods/hooks/stall.tsx new file mode 100644 index 0000000000..a4005c5041 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/stall.tsx @@ -0,0 +1,204 @@ +// The stall catcher (`mods.stall`). A main-loop reply that ends on what it was about to do ("Next I'll run the tests.") +// while nothing can wake the session (no background shell, subagent, monitor or workflow, no scheduled wake-up) does +// not go on by itself. The classic Stop hook carries the reply's last message and both lists, so the band then shows +// the closing sentence with `continue` and `dismiss`; `action: auto` presses continue by itself after `minutes`. A +// tool call or a new turn of the main loop takes it down. It never fires while anything runs: a Stop input without the +// task list counts as unknown, and the session's agent list (`$.agent.list()`) is asked too, before the row shows and +// again before an auto continue. The sentence tests are the session-analyzer's stall scorer +// (`scripts/stalls-shared.ts`), calibrated on real transcripts: the last sentence, not the last line, and a +// conditional ("I'll report when it fires"), a negated ("I won't rewrite …") or a gated reply ("waiting for your +// answer") is not a stall. About 70% of the 402 stall endings it found were resumed later by a task notice, which is +// why a pending task or wake-up keeps the band quiet. Each finding and each press goes to `mods-log/stall.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; +import { liveLeft } from "./live"; +import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; + +const INTENT = + /^\W*(i'?ll\b|i will\b|i am going to\b|i'?m going to\b|let me\b|next,? i\b|now i'?ll\b|starting (that|it|now)\b|running (it|that) now\b|(moving )?on to (the|my|its|it)\b|now (the|for) \w+\b|up next\b|i dispatch\b|dispatching\b|doing (that|this|it) next\b)/i; +const CONDITIONAL = + /\b(when|once|as soon as|after|before|if|unless|in case|the moment|while|until|as they|as it|every time|each time|whenever|from (here|now) on|going forward)\b/i; +const NEGATED = /\bi (will|'?ll|am|'?m) (not|never)\b|\bi won'?t\b/i; +const GATED = + /\b(is running|are running|still running|still working|still writing|still building|still gathering|still out\b|has not reported|have not reported|hasn'?t reported|no report yet|a (background )?watcher|watcher is|poller is|is polling|waiting (on|for)|awaiting|in flight|ci (is|on)\b|agents? (are|is) still|still owe me|when (it|they|the)\b|once (it|they|the)\b|as soon as|your (call|answer|decision|go|word)|needs your|need from you|click it\b|say the word)\b/i; + +interface Stall { + sentence: string; + at: number; +} + +let stall: Stall | undefined; + +/** The last sentence of a reply: its last line with text, list and emphasis marks dropped, then its final sentence. */ +export function lastSentence(text: string): string { + const lines = text.trim().split("\n"); + const last = + [...lines] + .reverse() + .find((row) => row.trim() && !/^[-*•|>#`\s]*$/.test(row)) + ?.trim() ?? ""; + const cleaned = last.replace(/^[-*•>\s]+/, "").replace(/\*\*/g, ""); + const parts = cleaned.split(/(?<=[.!?])\s+/).filter(Boolean); + + return (parts.at(-1) ?? cleaned).trim(); +} + +/** The closing sentence when the reply stopped on what it meant to do next, else undefined. */ +export function stallSentence(reply: string): string | undefined { + const sentence = lastSentence(reply); + + if (!INTENT.test(sentence) || CONDITIONAL.test(sentence) || NEGATED.test(sentence) || GATED.test(reply)) { + return undefined; + } + + return sentence; +} + +/** What the continue press sends: the stopping sentence, so the model knows what it left undone. */ +export function continueText(sentence: string): string { + return `Continue. Your last reply ended on "${sentence}", and nothing runs in the background to wake you, so do it now.`; +} + +/** An agent in these states can still wake the session (a subagent's report, a teammate's message). */ +const BUSY = new Set(["pending", "running", "waiting"]); + +function countOf(value: unknown): number { + return Array.isArray(value) ? value.length : 0; +} + +/** How many of the session's agents still work, or undefined when the list could not be read. */ +async function busyAgents(host: Host): Promise<number | undefined> { + const list = await host.agents().catch(() => undefined); + + return list?.filter((agent) => BUSY.has(agent.status)).length; +} + +/** Reads a classic `Stop` input: a stall shows; anything else, or work that will wake the session, takes it down. */ +export async function noteStop(host: Host, input: unknown): Promise<void> { + if (!isRecord(input) || input.hook_event_name !== "Stop" || !(await settingsOf(host)).stall.enabled) { + return; + } + + const reply = typeof input.last_assistant_message === "string" ? input.last_assistant_message : ""; + const sentence = stallSentence(reply); + + if (!sentence) { + clearStall(host, "answered"); + return; + } + + // Without the task list there is no telling whether a shell or a monitor still runs, so it counts as running. + const tasks = Array.isArray(input.background_tasks) ? input.background_tasks.length : undefined; + const crons = countOf(input.session_crons); + const agents = await busyAgents(host); + + if (tasks === undefined || agents === undefined || tasks || crons || agents) { + clearStall(host, "waits"); + await logMod(host, "stall", { + event: "waits", + sentence, + tasks: tasks ?? "unknown", + crons, + agents: agents ?? "unknown", + }); + return; + } + + stall = { sentence, at: Date.now() }; + host.redraw(); + await logMod(host, "stall", { event: "stall", sentence }); +} + +/** Takes the band row down: the session went on (a tool call, a new turn) or its last reply is no stall. */ +export function clearStall(host: Host, why: string): void { + if (!stall) { + return; + } + + const held = stall; + stall = undefined; + host.redraw(); + void logMod(host, "stall", { event: "cleared", why, sentence: held.sentence, afterMs: Date.now() - held.at }); +} + +/** The stall the band shows now; a test reads it. */ +export function stallNow(): Readonly<Stall> | undefined { + return stall; +} + +/** A press on the band row, or the auto continue: `dismiss` only takes the row down. */ +export async function pressStall(host: Host, how: "continue" | "auto" | "dismiss"): Promise<void> { + const held = stall; + + if (!held) { + return; + } + + stall = undefined; + host.redraw(); + await logMod(host, "stall", { event: how, sentence: held.sentence, afterMs: Date.now() - held.at }); + + if (how !== "dismiss") { + await host.submit(continueText(held.sentence)); + } +} + +export const stallCatcher: Feature = { + async start(host) { + host.every(5_000, () => { + void (async () => { + const settings = (await settingsOf(host)).stall; + + if (!stall || settings.action !== "auto" || Date.now() - stall.at < settings.minutes * 60_000) { + return; + } + + const agents = await busyAgents(host); + + if (agents === undefined || agents > 0) { + clearStall(host, "an agent works"); + return; + } + + await pressStall(host, "auto"); + })(); + }); + }, + beforeTool(host, e) { + if (e.agentId === undefined) { + clearStall(host, "tool call"); + } + + return undefined; + }, +}; + +/** The band row: the sentence the reply stopped on, `continue` and `dismiss`; null while there is none. */ +export async function stallRow(host: Host, ui: ElementTable): Promise<RenderElement | null> { + const held = stall; + const settings = (await settingsOf(host)).stall; + + if (!held || !settings.enabled) { + return null; + } + + const { Box, Button, Text } = ui; + const auto = settings.action === "auto" ? ` · continues in ${liveLeft(held.at + settings.minutes * 60_000)}` : ""; + + return ( + <Box key="stall-band" flexDirection="row" gap={1}> + <Box flexShrink={1} minWidth={0}> + <Text color="yellow" wrap="truncate-end"> + {`⏸ stopped on "${held.sentence}" · nothing runs in the background${auto}`} + </Text> + </Box> + <Box flexShrink={0} flexDirection="row" columnGap={1}> + <Button key="stall-continue" variant="primary" onPress={() => void pressStall(host, "continue")}> + continue + </Button> + <Button key="stall-dismiss" dimColor onPress={() => void pressStall(host, "dismiss")}> + dismiss + </Button> + </Box> + </Box> + ); +} diff --git a/plugins/genesis-tools-mods/hooks/tmp-usage.tsx b/plugins/genesis-tools-mods/hooks/tmp-usage.tsx new file mode 100644 index 0000000000..a37b070951 --- /dev/null +++ b/plugins/genesis-tools-mods/hooks/tmp-usage.tsx @@ -0,0 +1,455 @@ +// The /tmp row: what this session keeps on disk and the free space on the system disk, every `mods.tmpUsage.minutes`. +// The session's share is its scratch folder (`/tmp/cc/<project>/<session>`), Claude Code's task output for it +// (`/tmp/claude-<uid>/<project folder>/<session>`), and the git worktrees it made: one its subagents got +// (`isolation: "worktree"`, named in their `.meta.json`), one under its scratch folder's `worktrees/`, or one locked +// to it (`hooks/worktree-lock.ts` in the genesis-tools plugin). A worktree held 3.6 GB where the scratch folder held +// 392 MB (2026-10-23), so a row that counted the folder alone read ten times too small. All of /tmp/cc is not +// measured: `du` over it took 75 s and 38 s of system CPU at 67 GB, too much for a band; `du` over one worktree took +// 2 s. A press on the row opens the Session files sidebar. Each reading goes to `mods-log/tmp.jsonl`. +import type { ElementTable, RenderElement } from "claude-code"; +import type { SessionWorktree, TmpReading, TmpUsage } from "../types"; +import { sessionDirOf } from "./agents-band"; +import { liveAgo } from "./live"; +import { bareDescription } from "./model-label"; +import { type Feature, type Host, isRecord, logMod, settingsOf } from "./shared"; +import { actionButton, type Kit, line, type Pressable, plural, pressed, sidebarFrame } from "./sidebar"; + +let usage: TmpUsage = null; +let measuring: Promise<void> | undefined; +const LOW_FREE_KB = 10 * 1024 * 1024; +export const TMP_PANE = "mods-tmp"; + +async function kbOf(host: Host, argv: string[], column: number): Promise<number | null> { + const done = await host.run(argv, { timeoutMs: 60_000 }).catch(() => undefined); + const lines = done?.stdout.trim().split("\n") ?? []; + const value = Number(lines[lines.length - 1]?.trim().split(/\s+/)[column]); + + return Number.isFinite(value) && lines.length > 0 ? value : null; +} + +async function folderKb(host: Host, path: string): Promise<number | null> { + return (await host.exists(path)) ? kbOf(host, ["du", "-sk", path], 0) : 0; +} + +/** The main checkout's folder name, so every worktree of one repository shares its /tmp/cc project folder. */ +async function projectOf(host: Host): Promise<string> { + const cwd = await host.cwd(); + const done = await host + .run(["git", "-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { timeoutMs: 5_000 }) + .catch(() => undefined); + const common = done?.exitCode === 0 ? done.stdout.trim() : ""; + const main = common.endsWith("/.git") ? common.slice(0, -5) : cwd; + + return main.split("/").filter(Boolean).pop() ?? "unknown"; +} + +/** `/private/tmp/x` and `/tmp/x` are one folder; git prints the first, the scratch convention writes the second. */ +export function samePath(path: string): string { + return path.replace(/^\/private(?=\/tmp\/)/, "").replace(/\/+$/, ""); +} + +/** The worktrees `git worktree list --porcelain` names as locked by this session (`… session <id> …`). */ +export function lockedToSession(porcelain: string, sessionId: string): string[] { + return porcelain + .split("\n\n") + .map((block) => { + const path = /^worktree (.+)$/m.exec(block)?.[1]; + const reason = /^locked (.*)$/m.exec(block)?.[1] ?? ""; + + return path && reason.includes(`session ${sessionId}`) ? samePath(path) : undefined; + }) + .filter((path): path is string => path !== undefined); +} + +/** The branch and the count of changed files from `git status --porcelain --branch`. */ +export function statusOf(porcelain: string): { branch: string; changes: number } { + const lines = porcelain.split("\n").filter(Boolean); + const head = lines[0]?.startsWith("## ") ? lines[0].slice(3) : ""; + const branch = head.startsWith("HEAD (no branch)") ? "detached" : (head.split("...")[0] ?? "").trim(); + + return { branch: branch || "?", changes: lines.filter((row) => !row.startsWith("## ")).length }; +} + +/** The session's whole share in KB: its scratch, its task output and every worktree outside the scratch folder. */ +export function sessionTotalKb(reading: Pick<TmpReading, "scratch" | "tasks" | "worktrees">): number | null { + if (reading.scratch.kb === null) { + return null; + } + + const inside = (path: string) => path.startsWith(`${reading.scratch.path}/`); + const outside = reading.worktrees.filter((tree) => !inside(tree.path)); + + return outside.reduce((sum, tree) => sum + (tree.kb ?? 0), reading.scratch.kb + (reading.tasks.kb ?? 0)); +} + +/** The worktrees this session's subagents got, from `<session>/subagents/*.meta.json`. */ +async function subagentWorktrees(host: Host): Promise<Map<string, string>> { + const found = new Map<string, string>(); + const dir = await sessionDirOf(host); + const entries = dir ? await host.list(`${dir}/subagents`).catch(() => []) : []; + + for (const entry of entries) { + if (!entry.name.endsWith(".meta.json")) { + continue; + } + + const meta: unknown = await host + .read(`${dir}/subagents/${entry.name}`) + .then((text) => JSON.parse(text)) + .catch(() => undefined); + + if (isRecord(meta) && typeof meta.worktreePath === "string") { + const what = typeof meta.description === "string" ? bareDescription(meta.description) : "a subagent"; + found.set(samePath(meta.worktreePath), `subagent: ${what}`); + } + } + + return found; +} + +/** Every worktree this session made or holds, with where it came from. Ones gone from disk are left out. */ +async function worktreesOf(host: Host, scratch: string): Promise<SessionWorktree[]> { + const from = await subagentWorktrees(host); + const scratchTrees = await host.list(`${scratch}/worktrees`).catch(() => []); + + for (const entry of scratchTrees) { + if (entry.kind === "dir") { + from.set(`${scratch}/worktrees/${entry.name}`, "made in its scratch folder"); + } + } + + const listed = await host + .run(["git", "-C", await host.cwd(), "worktree", "list", "--porcelain"], { timeoutMs: 10_000 }) + .catch(() => undefined); + const locked = new Set(lockedToSession(listed?.stdout ?? "", await host.sessionId())); + + for (const path of locked) { + if (!from.has(path)) { + from.set(path, "locked to this session"); + } + } + + const trees: SessionWorktree[] = []; + + for (const [path, source] of from) { + if (!(await host.exists(`${path}/.git`))) { + continue; + } + + const status = await host + .run(["git", "-C", path, "status", "--porcelain", "--branch"], { timeoutMs: 10_000 }) + .catch(() => undefined); + trees.push({ + path, + source, + ...(status?.exitCode === 0 ? statusOf(status.stdout) : { branch: "?", changes: 0 }), + isLocked: locked.has(path), + kb: await folderKb(host, path), + }); + } + + return trees.sort((a, b) => (b.kb ?? 0) - (a.kb ?? 0)); +} + +async function measure(host: Host): Promise<void> { + const settings = (await settingsOf(host)).tmpUsage; + + if (!settings.enabled) { + return; + } + + // lint-rules-ignore: /tmp/cc/<project>/<session> is the agents' scratch folder by convention, not a temp file. + const scratch = `/tmp/cc/${await projectOf(host)}/${await host.sessionId()}`; + const sessionDir = await sessionDirOf(host); + const uid = (await host.run(["id", "-u"], { timeoutMs: 5_000 }).catch(() => undefined))?.stdout.trim(); + const projectFolder = sessionDir?.split("/").slice(-2, -1)[0]; + // lint-rules-ignore: Claude Code's own per-session task output folder, read only. + const tasks = uid && projectFolder ? `/tmp/claude-${uid}/${projectFolder}/${await host.sessionId()}` : ""; + const reading: TmpReading = { + at: Date.now(), + scratch: { path: scratch, kb: await folderKb(host, scratch) }, + tasks: { path: tasks, kb: tasks ? await folderKb(host, tasks) : 0 }, + worktrees: await worktreesOf(host, scratch), + freeKb: await kbOf(host, ["df", "-k", "/"], 3), + }; + + usage = reading; + host.redraw(); + await logMod(host, "tmp", { + folder: scratch, + totalKb: sessionTotalKb(reading), + scratchKb: reading.scratch.kb, + tasksKb: reading.tasks.kb, + worktrees: reading.worktrees.map((tree) => ({ path: tree.path, kb: tree.kb, source: tree.source })), + freeKb: reading.freeKb, + }); +} + +/** One reading at a time: a press during the timer's reading waits for that one. */ +function measureOnce(host: Host): Promise<void> { + measuring ??= measure(host).finally(() => { + measuring = undefined; + }); + + return measuring; +} + +export const tmpUsage: Feature = { + async start(host) { + const settings = (await settingsOf(host)).tmpUsage; + + host.every(settings.minutes * 60_000, () => { + void measureOnce(host); + }); + void measureOnce(host); + }, + sidebars: [{ id: TMP_PANE, title: "Session files", draw: tmpPane }], + commands: [ + { + name: "mod-tmp", + description: "The Session files sidebar: this session's scratch folder, task output and worktrees, by size", + async run(host) { + await host.togglePane(TMP_PANE, "Session files", "command"); + + return {}; + }, + }, + ], +}; + +export function sizeText(kb: number | null): string { + if (kb === null) { + return "?"; + } + + if (kb < 1024) { + return `${kb}K`; + } + + if (kb < 1024 * 1024) { + return `${Math.round(kb / 1024)}M`; + } + + return `${(kb / 1024 / 1024).toFixed(1)}G`; +} + +/** `session 4.0G · 1 worktree`: what the band's button says. */ +function sessionText(reading: TmpReading): string { + const trees = reading.worktrees.length ? ` · ${plural(reading.worktrees.length, "worktree")}` : ""; + + return `session ${sizeText(sessionTotalKb(reading))}${trees}`; +} + +/** The band's /tmp row, or null while off or not measured yet. A press opens the Session files sidebar. */ +export async function tmpRow(host: Host, ui: ElementTable): Promise<RenderElement | null> { + const held = usage; + + if (!held || !(await settingsOf(host)).tmpUsage.enabled) { + return null; + } + + const { Box, Button, Text } = ui; + const low = held.freeKb !== null && held.freeKb < LOW_FREE_KB; + + return ( + <Box key="tmp-band" flexDirection="row" gap={1}> + <Text dimColor>tmp:</Text> + <Button key="tmp-open" plain onPress={() => void host.togglePane(TMP_PANE, "Session files")}> + <Text dimColor>{sessionText(held)}</Text> + </Button> + <Text dimColor>·</Text> + <Text color={low ? "red" : undefined} dimColor={!low} bold={low}> + {`disk free ${sizeText(held.freeKb)}`} + </Text> + </Box> + ); +} + +/** A path with the home as `~`. */ +function tilde(path: string, home: string | undefined): string { + return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +/** Opens a folder in Finder and says so in the status bar. */ +async function reveal(host: Host, key: string, path: string): Promise<void> { + const run = await host.openUrl(path).catch((error: unknown) => error); + const code = isRecord(run) && typeof run.exitCode === "number" ? run.exitCode : -1; + + await pressed( + host, + key, + code === 0 ? `Opened ${path} in Finder.` : `open ${path} failed (exit ${code}).`, + code === 0 + ); +} + +async function copyPath(host: Host, key: string, path: string): Promise<void> { + const done = await host.copy(path); + + await pressed( + host, + key, + done.isCopied ? `Copied ${path}` : `Copy failed: ${done.reason ?? "no clipboard"}.`, + done.isCopied + ); +} + +/** A folder line: its name as a Button that opens it in Finder, its size at the right edge. */ +function folderLine(kit: Kit, key: string, label: string, path: string, kb: number | null, hint: string, host: Host) { + const { Box, Button, Text } = kit.ui; + const row: Pressable = { key, label, hint, onPress: () => void reveal(host, key, path) }; + kit.pressables?.push(row); + + return line( + kit, + key, + <Box flexDirection="row" flexGrow={1}> + <Box flexShrink={1} minWidth={0}> + <Button key={key} plain onPress={row.onPress}> + {label} + </Button> + </Box> + <Box flexGrow={1} /> + <Box flexShrink={0} marginLeft={1}> + <Text bold>{sizeText(kb)}</Text> + </Box> + </Box> + ); +} + +/** The Session files sidebar: the total, the scratch folder, the task output, then each worktree by size. */ +async function tmpPane(host: Host, kit: Kit): Promise<RenderElement> { + const { Box, Text } = kit.ui; + const held = usage; + const home = await host.env("HOME"); + const reload: Pressable = { + key: "tmp-refresh", + label: "refresh", + isDim: true, + hint: "Measure the scratch folder, the task output and every worktree again (du, about 2 s a worktree).", + onPress: () => { + void pressed(host, "tmp-refresh", "Measuring…", "busy") + .then(() => measureOnce(host)) + .then(() => pressed(host, "tmp-refresh", usage ? `Measured: ${sessionText(usage)}.` : "Measured.")); + }, + }; + const lines: RenderElement[] = [ + line( + kit, + "tmp-head", + <Box flexDirection="row" flexGrow={1}> + <Box flexShrink={1} minWidth={0}> + <Text wrap="truncate-end"> + <Text bold>Session files</Text> + <Text dimColor> + {held ? ` · ${sizeText(sessionTotalKb(held))} · read ${liveAgo(held.at)}` : " · measuring…"} + </Text> + </Text> + </Box> + <Box flexGrow={1} /> + <Box flexShrink={0}>{actionButton(kit, reload)}</Box> + </Box> + ), + line(kit, "tmp-gap", null), + ]; + + if (!held) { + return sidebarFrame(kit, <Box flexDirection="column">{lines}</Box>, lines.length); + } + + lines.push( + folderLine( + kit, + "tmp-scratch", + "scratch folder", + held.scratch.path, + held.scratch.kb, + `Show ${tilde(held.scratch.path, home)} in Finder: the session's scratch files (/tmp/cc).`, + host + ) + ); + + if (held.tasks.path && held.tasks.kb) { + lines.push( + folderLine( + kit, + "tmp-tasks", + "task output", + held.tasks.path, + held.tasks.kb, + `Show ${held.tasks.path} in Finder: what Claude Code kept of background shells and subagents.`, + host + ) + ); + } + + lines.push( + line(kit, "tmp-gap-2", null), + line( + kit, + "tmp-trees", + <Text wrap="truncate-end"> + <Text bold>Worktrees</Text> + <Text dimColor> + {held.worktrees.length + ? ` · ${held.worktrees.length} · ${sizeText(held.worktrees.reduce((sum, tree) => sum + (tree.kb ?? 0), 0))}` + : " · none"} + </Text> + </Text> + ) + ); + + if (!held.worktrees.length) { + lines.push( + line( + kit, + "tmp-none", + <Text dimColor wrap="truncate-end"> + No subagent worktree, none in the scratch folder, none locked to this session. + </Text> + ) + ); + } + + held.worktrees.forEach((tree, index) => { + const at = `tmp-tree-${index}`; + const name = tree.path.slice(tree.path.lastIndexOf("/") + 1); + const state = tree.changes ? `${plural(tree.changes, "changed file")}` : "clean"; + // The status bar holds two rows: the path goes in the copy hint, which says nothing else. + const removal = tree.isLocked + ? "It is locked to this session until the session ends." + : tree.changes + ? "It has changed files, so git worktree remove refuses it." + : "It is clean: git worktree remove <path> frees it."; + + lines.push( + folderLine(kit, at, name, tree.path, tree.kb, `Show it in Finder. ${removal}`, host), + line( + kit, + `${at}-what`, + <Text wrap="truncate-end"> + <Text dimColor>{" "}</Text> + <Text color={tree.changes ? "yellow" : undefined} dimColor={!tree.changes}> + {`${tree.branch} · ${state}${tree.isLocked ? " · locked" : ""}`} + </Text> + </Text> + ), + line(kit, `${at}-from`, <Text dimColor wrap="truncate-end">{` ${tree.source}`}</Text>), + line( + kit, + `${at}-actions`, + <Box paddingLeft={2} flexDirection="row" columnGap={1}> + {actionButton(kit, { + key: `${at}-copy`, + label: "copy path", + isDim: true, + hint: `Copy ${tilde(tree.path, home)}`, + onPress: () => void copyPath(host, `${at}-copy`, tree.path), + })} + </Box> + ) + ); + }); + + return sidebarFrame(kit, <Box flexDirection="column">{lines}</Box>, lines.length); +} diff --git a/plugins/genesis-tools-mods/tests/agent-model.test.tsx b/plugins/genesis-tools-mods/tests/agent-model.test.tsx new file mode 100644 index 0000000000..b534786eaf --- /dev/null +++ b/plugins/genesis-tools-mods/tests/agent-model.test.tsx @@ -0,0 +1,125 @@ +import type { AgentSpawnInput, On, TurnStepChunk } from "claude-code"; +import { expect, mock, test } from "claude-code/testing"; + +// Run with `claude plugin test plugins/genesis-tools-mods`, with the fold tests. + +/** The engine's own spawn, beneath the plugin: records the description the plugin passed down. */ +function engine(on: On, seen: string[]): void { + mock.env(on, { HOME: "/home/test" }); + // Every mod past the full diff starts off; this test turns its own on through the hooks file. + on("fs.read", async (_$, e) => { + if (e.path === "/home/test/.genesis-tools/agents/hooks.json") { + return { value: JSON.stringify({ mods: { agentModel: { enabled: true } } }) }; + } + + throw new Error("no file"); + }); + on("agent.spawn", async (_$, e) => { + seen.push(e.description); + + return { model: "claude-sonnet-5-5", agentId: `agent-${seen.length}` }; + }); +} + +function spawn(description: string, extra: Partial<AgentSpawnInput> = {}): AgentSpawnInput { + return { + tool_use_id: `toolu_${description.replace(/\W/g, "")}`, + prompt: "Reply with the word ok.", + description, + subagentType: "general-purpose", + provider: { plugin: "engine", tier: "core" }, + parentModel: "claude-opus-5-5[1m]", + background: true, + fork: false, + ...extra, + }; +} + +test("a spawn that names its model shows it in the description", async ($, on) => { + const seen: string[] = []; + engine(on, seen); + + await $.agent.spawn(spawn("Pair one", { model: "sonnet" })); + await $.agent.spawn(spawn("Pair two", { model: "claude-haiku-4-5-20251001" })); + + expect(seen).toEqual(["sonnet · Pair one", "haiku-4-5 · Pair two"]); +}); + +test("a fork shows the parent's model, which it always inherits", async ($, on) => { + const seen: string[] = []; + engine(on, seen); + + await $.agent.spawn(spawn("Fork it", { fork: true, model: "haiku" })); + + expect(seen).toEqual(["opus-5-5[1m] · Fork it"]); +}); + +test("a spawn that names no model keeps its description", async ($, on) => { + const seen: string[] = []; + engine(on, seen); + + await $.agent.spawn(spawn("Explore the repo")); + + expect(seen).toEqual(["Explore the repo"]); +}); + +/** The model's response beneath the plugin: an Agent call that names a model, an Agent call that does not, a Bash call. */ +function response(on: On): void { + mock.env(on, { HOME: "/home/test" }); + // Every mod past the full diff starts off; this test turns its own on through the hooks file. + on("fs.read", async (_$, e) => { + if (e.path === "/home/test/.genesis-tools/agents/hooks.json") { + return { value: JSON.stringify({ mods: { agentModel: { enabled: true } } }) }; + } + + throw new Error("no file"); + }); + on("turn.step", async function* () { + yield { kind: "tool", index: 0, id: "toolu_a", name: "Agent" }; + yield { kind: "input", index: 0, json: '{"description":"Pair one",' }; + yield { kind: "input", index: 0, json: '"prompt":"ok","model":"sonnet"}' }; + yield { kind: "tool", index: 1, id: "toolu_b", name: "Agent" }; + yield { kind: "input", index: 1, json: '{"description":"Explore","prompt":"ok"}' }; + yield { kind: "tool", index: 2, id: "toolu_c", name: "Bash" }; + yield { kind: "input", index: 2, json: '{"command":"ls"}' }; + yield { kind: "stop", stopReason: "tool_use", usage: null }; + + return { + turnId: "t1", + index: 0, + answer: "", + toolUses: [ + { name: "Agent", input: { description: "Pair one", prompt: "ok", model: "sonnet" } }, + { name: "Agent", input: { description: "Explore", prompt: "ok" } }, + { name: "Bash", input: { command: "ls" } }, + ], + stopReason: "tool_use", + usage: null, + }; + }); +} + +test("an Agent call's streamed arguments carry its model in the description, so the launch block shows it", async ($, on) => { + response(on); + + const stream = $.turn.step({ turnId: "t1", index: 0, model: "claude-haiku-5-5", messageCount: 1 }); + const chunks: TurnStepChunk[] = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + // The stream is what the screen draws and the transcript records. The harness gives a stub at the bottom no + // step result, so the hook's patch of `toolUses` is checked live only. + expect( + chunks.map((chunk) => (chunk.kind === "input" ? `input ${chunk.index}: ${chunk.json}` : chunk.kind)) + ).toEqual([ + "tool", + 'input 0: {"description":"sonnet · Pair one","prompt":"ok","model":"sonnet"}', + "tool", + 'input 1: {"description":"Explore","prompt":"ok"}', + "tool", + 'input 2: {"command":"ls"}', + "stop", + ]); +}); diff --git a/plugins/genesis-tools-mods/tests/features.test.tsx b/plugins/genesis-tools-mods/tests/features.test.tsx new file mode 100644 index 0000000000..ab04a5869d --- /dev/null +++ b/plugins/genesis-tools-mods/tests/features.test.tsx @@ -0,0 +1,1057 @@ +import type { On, TurnStepChunk } from "claude-code"; +import { expect, mock, test } from "claude-code/testing"; +import { lastSaidOf } from "../hooks/agents-band"; +import { answersFrom, callFor, firstAsks, questionCalls } from "../hooks/answers"; +import { quoteOf } from "../hooks/ask-row"; +import { cacheRowsOf, cacheTone } from "../hooks/cache-band"; +import { + addCheckout, + addedCheckouts, + folderSuggestions, + forgetCheckouts, + gitWriteDirs, + gitWrites, + headOf, + hideCheckout, + noteToolCall, + pickRoots, + rankByName, + removeAdded, + sessionCheckouts, + writtenFiles, +} from "../hooks/checkouts"; +import { chatDecisions, openDecisions, shownDecisions, stagedMessage, stagedText } from "../hooks/decisions"; +import { forgetGenesisProbe, type GenesisProbe, genesisProbe, probeOf } from "../hooks/genesis"; +import { + checkCounts, + checksOf, + fitLabels, + gitBand, + hubUrlOf, + labelOf, + middleCut, + namesCheckouts, + parseNumstat, + parseStatus, + prChecksOf, + wordingFor, +} from "../hooks/git-band"; +import { dedupeEntry, ledgerOf, tokensText } from "../hooks/hook-ledger"; +import { targetFor } from "../hooks/links"; +import { forgetLive, liveAgo, liveDue, liveLeft, liveSince } from "../hooks/live"; +import { bareDescription } from "../hooks/model-label"; +import { modsSettings } from "../hooks/settings"; +import { forgetSettings, type Host } from "../hooks/shared"; +import { toolLabel } from "../hooks/spinner"; +import { clearStall, continueText, lastSentence, noteStop, pressStall, stallNow, stallSentence } from "../hooks/stall"; +import { lockedToSession, samePath, sessionTotalKb, statusOf } from "../hooks/tmp-usage"; + +/** A Mac where every GenesisTools link opens, on an invented link host. */ +const ALL_LINKS: GenesisProbe = { + tools: true, + linkHost: "links.example.test", + opens: { md: true, finder: true, hub: true, "claude-find": true }, +}; +/** A Mac with neither the GenesisTools CLI nor its app. */ +const NO_LINKS: GenesisProbe = { + tools: false, + linkHost: null, + opens: { md: false, finder: false, hub: false, "claude-find": false }, +}; + +// Run with `claude plugin test plugins/genesis-tools-mods`. The pure parts of each feature first, then the link +// fixer end to end over a streamed reply. + +test("open decisions: asked, minus answered and replaced, with their options", async () => { + const list = openDecisions([ + { + role: "assistant", + text: "❓ DECISION 3: colour step\n**a)** plugin\n**b)** highlight.js\n\n❓ DECISION 4 merge?\na) now\nb) later", + }, + { role: "user", text: "D3: b)" }, + { role: "assistant", text: "Repeat of [❓ DECISION 4](https://x) stays open.\n❓ DECISION 5 name\na) one" }, + { role: "assistant", text: "❓ DECISION 6 replaces ❓ DECISION 5\na) two" }, + ]); + + expect(list.map((decision) => decision.n)).toEqual([4, 6]); + expect(list[0]?.options).toEqual([ + { letter: "a", text: "now" }, + { letter: "b", text: "later" }, + ]); + expect(stagedMessage({ "6": "a)", "4": "my own words" })).toBe("D4: my own words\nD6: a)"); +}); + +test("decisions sidebar: the log's states, chat asks the log lacks, and a chat answer the log has not recorded", async () => { + const logged = answersFrom( + JSON.stringify({ + sessionId: "s-alpha", + decisions: [ + { + number: 2, + title: "Keep the cache?", + prompt: "The cache is warm.", + status: "waiting", + choices: [ + { id: "a", label: "a) keep it", recommended: true }, + { id: "b", label: "drop it", recommended: false }, + ], + recommended: "a", + option: null, + answer: null, + at: "2026-03-01T10:00:00.000Z", + }, + { number: 1, title: "Old", prompt: "", status: "implemented", choices: [], option: "b", at: "x" }, + { + number: 4, + title: "Staged", + prompt: "", + status: "drafted", + choices: [], + draftOption: "b", + draft: "and why", + }, + { number: 3, title: "Odd", prompt: "", status: "no-such-state", choices: [] }, + ], + todos: [{ number: 1, title: "Rename the flag", state: "open", at: "2026-03-01T09:00:00.000Z" }], + entries: [ + { id: "e1", ts: 1000, tag: "message", question: "Done", answerMd: "All green", readAt: 5 }, + { + id: "e2", + ts: 2000, + tag: "directive", + question: "Why?", + answerMd: "Because", + readAt: null, + attachments: [{ type: "image", path: "/tmp/shot.png" }], + }, + ], + forms: [{ id: "ask_1", at: "2026-03-01T09:30:00.000Z", questions: [{ prompt: "Ship it?" }] }], + }), + 42 + ); + + expect(answersFrom("not json")).toBeUndefined(); + expect(answersFrom(JSON.stringify({ sessionId: "s" }))).toBeUndefined(); + expect(logged?.decisions.map((d) => [d.n, d.status, d.options.map((o) => o.text)])).toEqual([ + [2, "waiting", ["keep it", "drop it"]], + [1, "implemented", []], + [4, "drafted", []], + [3, "waiting", []], + ]); + expect(logged?.decisions[1]?.at).toBe(0); + // The store owns staging: a drafted decision carries its pick and note, and reads as one answer. + expect(stagedText(logged?.decisions[2] ?? {})).toBe("b) and why"); + expect(stagedText({ draft: "own words" })).toBe("own words"); + expect(stagedText({})).toBe(""); + // Newest entry first; an entry nobody read is new; image attachments keep their paths. + expect(logged?.entries.map((e) => [e.id, e.isUnread, e.images])).toEqual([ + ["e2", true, ["/tmp/shot.png"]], + ["e1", false, []], + ]); + expect(logged?.forms).toEqual([{ id: "ask_1", at: Date.parse("2026-03-01T09:30:00.000Z"), prompts: ["Ship it?"] }]); + + const chat = chatDecisions([ + { role: "assistant", text: "❓ DECISION 5 name it?\na) one\nb) two" }, + { role: "user", text: "D2: a)" }, + ]); + const shown = shownDecisions(logged?.decisions ?? [], chat); + + expect(shown.map((d) => [d.n, d.status])).toEqual([ + [1, "implemented"], + [2, "chat-answered"], + [3, "waiting"], + [4, "drafted"], + [5, "waiting"], + ]); + expect(shown[4]?.options.map((o) => o.letter)).toEqual(["a", "b"]); +}); + +test("jump to: a decision's first ask is its first unlinked ask in the transcript; an entry's row is the call before it", async () => { + const line = (entry: Record<string, unknown>) => JSON.stringify(entry); + const lines = [ + line({ type: "user", uuid: "u1", message: { content: [{ type: "text", text: "❓ DECISION 1 in a prompt" }] } }), + line({ + type: "assistant", + uuid: "a1", + message: { content: [{ type: "text", text: "Repeat [❓ DECISION 7](x)" }] }, + }), + line({ + type: "assistant", + uuid: "a2", + message: { content: [{ type: "text", text: "❓ **DECISION 7** pick\n❓ DECISION 8" }] }, + }), + line({ + type: "assistant", + uuid: "a3", + message: { content: [{ type: "text", text: "❓ DECISION 7 asked again" }] }, + }), + line({ + type: "assistant", + uuid: "a4", + timestamp: "2026-03-01T10:00:00.000Z", + message: { + content: [{ type: "tool_use", id: "toolu_send", name: "mcp__genesis-tools__inbox_send", input: {} }], + }, + }), + line({ + type: "assistant", + uuid: "a5", + timestamp: "2026-03-01T10:05:00.000Z", + message: { + content: [ + { + type: "tool_use", + id: "toolu_bash", + name: "Bash", + input: { command: "tools question message hi" }, + }, + ], + }, + }), + line({ + type: "assistant", + uuid: "a6", + timestamp: "2026-03-01T10:06:00.000Z", + message: { content: [{ type: "tool_use", id: "toolu_ls", name: "Bash", input: { command: "ls" } }] }, + }), + "{torn line", + ]; + + expect([...firstAsks(lines)]).toEqual([ + [7, "a2"], + [8, "a2"], + ]); + + const calls = questionCalls(lines); + const at = Date.parse("2026-03-01T10:00:00.000Z"); + + expect(calls.map((call) => call.id)).toEqual(["toolu_send", "toolu_bash"]); + expect(callFor(at + 2_000, calls)).toBe("toolu_send"); + expect(callFor(at + 5 * 60_000 + 1_000, calls)).toBe("toolu_bash"); + // Recorded before any call, or long after the last one: no row. + expect(callFor(at - 60_000, calls)).toBeUndefined(); + expect(callFor(at + 60 * 60_000, calls)).toBeUndefined(); +}); + +test("git band: status line and CI counts", async () => { + expect(parseStatus("## feat/x...origin/feat/x [ahead 2, behind 1]\n M a.ts\n?? b.ts\n")).toEqual({ + branch: "feat/x", + ahead: 2, + behind: 1, + dirty: 2, + }); + expect(parseStatus("## HEAD (no branch)\n").branch).toBe("detached"); + expect(parseNumstat("12\t3\ta.ts\n-\t-\tlogo.png\n0\t9\tb.ts\n")).toEqual({ added: 12, removed: 12 }); + expect(parseNumstat("")).toEqual({ added: 0, removed: 0 }); + expect(middleCut("feat/2026-10-13-enhancements-unified", 30)).toBe("feat/2026-10-13…ements-unified"); + expect(middleCut("main", 30)).toBe("main"); + expect( + checkCounts([ + { conclusion: "SUCCESS" }, + { conclusion: "FAILURE" }, + { state: "PENDING" }, + { conclusion: "", status: "IN_PROGRESS" }, + { state: "SUCCESS" }, + ]) + ).toEqual({ passed: 2, failed: 1, pending: 2 }); + expect( + checksOf([ + { name: "lint", conclusion: "FAILURE", detailsUrl: "https://ci.example.test/run/1" }, + { context: "ci/deploy", state: "PENDING", targetUrl: "https://deploy.example.test/2" }, + ]) + ).toEqual([ + { name: "lint", verdict: "fail", description: "", link: "https://ci.example.test/run/1" }, + { name: "ci/deploy", verdict: "pending", description: "", link: "https://deploy.example.test/2" }, + ]); + // `gh pr checks` carries the status's own words, which the rollup leaves out. + expect( + prChecksOf( + JSON.stringify([ + { bucket: "pass", description: "", link: "https://ci.example.test/3", name: "Tests" }, + { + bucket: "fail", + description: "Deployment was blocked", + link: "https://deploy.example.test/4", + name: "Deploy", + }, + { bucket: "cancel", description: "", link: "", name: "Lint" }, + ]) + ) + ).toEqual([ + { name: "Tests", verdict: "pass", description: "", link: "https://ci.example.test/3" }, + { + name: "Deploy", + verdict: "fail", + description: "Deployment was blocked", + link: "https://deploy.example.test/4", + }, + { name: "Lint", verdict: "fail", description: "", link: "" }, + ]); + expect(prChecksOf("no checks reported on the 'x' branch")).toBeUndefined(); + expect(hubUrlOf(ALL_LINKS, { url: "https://github.com/acme/widgets/pull/42", number: 42 })).toBe( + "https://links.example.test/hub?pr=acme%2Fwidgets%2342" + ); + expect(hubUrlOf(NO_LINKS, { url: "https://github.com/acme/widgets/pull/42", number: 42 })).toBeUndefined(); + expect( + hubUrlOf(ALL_LINKS, { url: "https://gitlab.example.com/a/b/-/merge_requests/3", number: 3 }) + ).toBeUndefined(); +}); + +test("git band: the checkouts a command or an edit works in", async () => { + expect(gitWriteDirs("cd /work/wt-a && git commit -m x", "/home/test")).toEqual(["/work/wt-a"]); + expect(gitWriteDirs("git -C ~/wt-b rebase origin/main", "/home/test")).toEqual(["/home/test/wt-b"]); + expect(gitWriteDirs('cd "/work/a b"; git -c core.x=1 stash pop', "/home/test")).toEqual(["/work/a b"]); + // Read-only git, and git with no folder this hook can know, count nothing. + expect(gitWriteDirs("git -C /work/wt-a status && git -C /work/wt-a log -3", "/home/test")).toEqual([]); + expect(gitWriteDirs("git commit -m x", "/home/test")).toEqual([]); + // A folder in a variable the command set before counts; one it did not set, or set only for one command, does not. + expect( + gitWriteDirs('S=/tmp/cc/p/s\nW=$S/worktrees/audit\ngit -C "$W" switch -c fix/x origin/main', "/home/test") + ).toEqual(["/tmp/cc/p/s/worktrees/audit"]); + expect(gitWriteDirs('export W=~/wt-c; cd "${W}" && git commit -m x', "/home/test")).toEqual(["/home/test/wt-c"]); + expect(gitWriteDirs('git -C "$NOPE" commit -m x', "/home/test")).toEqual([]); + expect(gitWriteDirs('W=/work/x git -C "$W" commit -m x', "/home/test")).toEqual([]); + expect(gitWriteDirs("W=/work/x; git -C '$W' commit -m x", "/home/test")).toEqual([]); + // `git worktree add` counts the new worktree, relative paths resolved against the folder the command is in. + expect(gitWrites("git -C /work/main worktree add --no-track -b feat/y ../wt-y origin/main", "/home/test")).toEqual([ + { dir: "/work/wt-y", branches: ["feat/y"] }, + ]); + expect(gitWrites("git -C /work/main worktree add --detach /work/base origin/main", "/home/test")).toEqual([ + { dir: "/work/base", branches: [] }, + ]); + // The branches a command makes or pushes; HEAD, tags and a delete name none. + expect( + gitWrites( + "cd /work/main && git switch -c fix/a && git push -u origin fix/a HEAD:fix/b +refs/heads/fix/c", + "/home/test" + ) + ).toEqual([{ dir: "/work/main", branches: ["fix/a", "fix/b", "fix/c"] }]); + expect(gitWrites("git -C /work/main push origin --delete fix/a", "/home/test")).toEqual([ + { dir: "/work/main", branches: [] }, + ]); + expect(gitWrites("git -C /work/main push origin HEAD v1.0:refs/tags/v1.0", "/home/test")).toEqual([ + { dir: "/work/main", branches: [] }, + ]); + expect(writtenFiles({ tool: "Edit", file_path: "/work/wt-a/x.ts" })).toEqual(["/work/wt-a/x.ts"]); + expect(writtenFiles({ tool: "Read", file_path: "/work/wt-a/x.ts" })).toEqual([]); + + const touches = [ + { root: "/work/wt-b", at: 2 }, + { root: "/work/wt-a", at: 1 }, + ]; + expect(pickRoots(touches, "/work/main", { checkouts: 3, cwd: "touched" })).toEqual(["/work/wt-b", "/work/wt-a"]); + expect(pickRoots(touches, "/work/main", { checkouts: 1, cwd: "touched" })).toEqual(["/work/wt-b"]); + expect(pickRoots(touches, "/work/main", { checkouts: 3, cwd: "always" })).toEqual([ + "/work/main", + "/work/wt-b", + "/work/wt-a", + ]); + expect(pickRoots([], "/work/main", { checkouts: 3, cwd: "touched" })).toEqual(["/work/main"]); + expect(fitLabels("shop-tools", "main", 40)).toEqual({ name: "shop-tools", branch: "main" }); + // A linked worktree is named with its main checkout; a main checkout or a separate clone by its folder. + const worktree = { root: "/tmp/cc/shop/s1/worktrees/enhancements", repo: "/work/shop" }; + expect(labelOf(worktree, "repo-folder")).toBe("shop/enhancements"); + expect(labelOf(worktree, "repo")).toBe("shop"); + expect(labelOf(worktree, "folder")).toBe("enhancements"); + expect(labelOf({ root: "/work/shop", repo: "/work/shop" }, "repo-folder")).toBe("shop"); + expect(labelOf({ root: "/work/shop-poc" }, "repo-folder")).toBe("shop-poc"); + // 30 columns: the name keeps 40% (12), the branch the other 18, each its start and end. + expect(fitLabels("acme-marketplace-2.0-foundation", "feat/2026-10-13-enhancements-unified", 30)).toEqual({ + name: "acme-m…ation", + branch: "feat/2026…-unified", + }); + expect(fitLabels(undefined, "feat/2026-10-13-enhancements-unified", 20).branch).toBe("feat/2026-…s-unified"); + const busy = { + root: "/work/main", + why: "folder" as const, + at: 0, + branch: "feat/2026-10-13-enhancements-unified", + ahead: 27, + behind: 2, + dirty: 13, + added: 862, + removed: 125, + prs: [], + }; + // 110 columns hold the words; 55 hold the signs (↑27 ↓2 ●13 +862 −125) beside a cut branch. + expect(wordingFor(busy, 110, false, true)).toBe("full"); + expect(wordingFor(busy, 55, false, true)).toBe("signs"); + expect(wordingFor(busy, 110, false, false)).toBe("signs"); + expect(namesCheckouts([{ root: "/work/main" }], "/work/main")).toBe(false); + expect(namesCheckouts([{ root: "/work/wt-a" }], "/work/main")).toBe(true); +}); + +/** + * A session started in /work/main that works in the worktrees /work/wt-a and /work/wt-b; its GenesisTools journal + * also names a plan in the main checkout's ignored `.claude/plans/` and a scratch file outside every repository. + * zoxide knows /home/test/code/playground (a checkout) and /home/test/notes (none); the git band is on. + */ +function checkoutHost(): Host { + const store = new Map<string, unknown>(); + const repos = new Set(["/work/main/.git", "/work/wt-a/.git", "/work/wt-b/.git", "/home/test/code/playground/.git"]); + const dirs = new Set(["/work/wt-a", "/home/test/code/playground", "/home/test/code/play-old", "/home/test/notes"]); + const mtimes: Record<string, number> = { + "/work/main/.claude/plans/p.md": 900, + "/work/main/.claude/plans/p2.md": 950, + "/work/wt-a/src/x.ts": 200, + "/work/wt-b/y.ts": 300, + "/tmp/scratch.py": 999, + }; + const journal = JSON.stringify({ files: Object.keys(mtimes).filter((path) => !path.endsWith("p2.md")) }); + const done = (stdout: string, exitCode: number) => ({ + exitCode, + stdout, + stderr: "", + isStdoutTruncated: false, + isStderrTruncated: false, + }); + + return { + async run(argv, init) { + if (argv.join(" ") === "zoxide query --list") { + // zoxide keeps the casing a `cd` typed: the same folder twice, and one folder that is gone. + return done( + "/home/test/notes\n/home/test/code/playground\n/home/test/code/PlayGround\n/home/test/code/play-gone\n/home/test/code/play-old\n", + 0 + ); + } + + if (argv[3] !== "check-ignore") { + return done("", 1); + } + + const ignored = String(init?.stdin ?? "") + .split("\n") + .filter((path) => path.includes("/.claude/plans/")); + + return ignored.length ? done(`${ignored.join("\n")}\n`, 0) : done("", 1); + }, + async read(path) { + if (path === "/home/test/.genesis-tools/claude-code/sessions/sess-1.json") { + return journal; + } + + if (path === "/home/test/.genesis-tools/agents/hooks.json") { + return JSON.stringify({ mods: { git: { enabled: true } } }); + } + + throw new Error(`ENOENT ${path}`); + }, + async write() {}, + async append() {}, + async list(path) { + return [...dirs] + .filter((dir) => dir.startsWith(`${path}/`) && !dir.slice(path.length + 1).includes("/")) + .map((dir) => ({ + name: dir.slice(path.length + 1), + kind: "dir" as const, + size: 0, + mtimeMs: 0, + isLink: false, + })); + }, + async exists(path) { + return repos.has(path); + }, + async stat(path) { + if (dirs.has(path)) { + return { kind: "dir", size: 0, mtimeMs: 0, isLink: false }; + } + + const mtimeMs = mtimes[path]; + + if (mtimeMs === undefined) { + throw new Error(`ENOENT ${path}`); + } + + return { kind: "file", size: 1, mtimeMs, isLink: false }; + }, + async env(name) { + return name === "HOME" ? "/home/test" : undefined; + }, + async cwd() { + return "/work/main"; + }, + async sessionId() { + return "sess-1"; + }, + async messages() { + return []; + }, + async agents() { + return []; + }, + every() {}, + async storeGet(key) { + return store.get(key); + }, + async storeSet(key, value) { + store.set(key, value); + }, + status() {}, + async fill() {}, + async submit() {}, + async openPane() {}, + async togglePane() {}, + async closePane() {}, + async copy() { + return { isCopied: true }; + }, + async openUrl() {}, + async scroll() { + return {}; + }, + toast() {}, + redraw() {}, + }; +} + +test("git band: a session in the main checkout that works in two worktrees shows the two worktrees", async () => { + forgetCheckouts(); + const host = checkoutHost(); + const roots = async () => (await sessionCheckouts(host)).map((touch) => touch.root); + + // The journal's ignored plan and the scratch file outside every repository count nothing. + expect(await roots()).toEqual(["/work/wt-b", "/work/wt-a"]); + expect(await noteToolCall(host, { tool: "Write", file_path: "/work/main/.claude/plans/p2.md" })).toBe(false); + expect(await noteToolCall(host, { tool: "Bash", command: "cd /work/wt-a && git commit -m x" })).toBe(true); + expect(await roots()).toEqual(["/work/wt-a", "/work/wt-b"]); + + // A reload starts this module over; the store keeps the order, and the journal is not read again. + forgetCheckouts(); + expect(await roots()).toEqual(["/work/wt-a", "/work/wt-b"]); + + // hide drops a worked-in checkout until the session works there again. + await hideCheckout(host, "/work/wt-b"); + expect(await roots()).toEqual(["/work/wt-a"]); +}); + +test("git band: a worktree's main checkout and the branches the session worked on there", async () => { + forgetCheckouts(); + const base = checkoutHost(); + // /work/wt-b is a linked worktree of /work/main: its `.git` is a file pointing into the main checkout's `.git`. + const files: Record<string, string> = { + "/work/wt-b/.git": "gitdir: /work/main/.git/worktrees/wt-b\n", + "/work/main/.git/worktrees/wt-b/HEAD": "ref: refs/heads/fix/x\n", + }; + const host: Host = { + ...base, + async stat(path) { + return path in files ? { kind: "file", size: 1, mtimeMs: 0, isLink: false } : base.stat(path); + }, + async read(path) { + return files[path] ?? base.read(path); + }, + }; + + expect(await headOf(host, "/work/wt-b")).toEqual({ repo: "/work/main", branch: "fix/x" }); + expect(await headOf(host, "/work/wt-a")).toEqual({ repo: "/work/wt-a", branch: undefined }); + // wt-b is the newest checkout already (the journal), so the band's rows do not move. + expect(await noteToolCall(host, { tool: "Bash", command: 'W=/work/wt-b\ngit -C "$W" commit -m x' })).toBe(false); + expect((await sessionCheckouts(host))[0]).toMatchObject({ + root: "/work/wt-b", + repo: "/work/main", + branches: ["fix/x"], + }); + + // A push of another branch adds it; a later switch puts the checked-out branch first, and none is lost. + await noteToolCall(host, { tool: "Bash", command: "cd /work/wt-b && git push origin HEAD:fix/y" }); + files["/work/main/.git/worktrees/wt-b/HEAD"] = "ref: refs/heads/fix/z\n"; + await noteToolCall(host, { tool: "Edit", file_path: "/work/wt-b/y.ts" }); + expect((await sessionCheckouts(host))[0]?.branches).toEqual(["fix/z", "fix/x", "fix/y"]); + + // A reload keeps them. + forgetCheckouts(); + expect((await sessionCheckouts(host))[0]?.branches).toEqual(["fix/z", "fix/x", "fix/y"]); +}); + +test("git band: folders added by hand, and the folders proposed for them", async () => { + forgetCheckouts(); + forgetSettings(); + const host = checkoutHost(); + + expect(rankByName(["/a/old-play", "/a/notes", "/b/Playground", "/c/play/x"], "play")).toEqual([ + "/b/Playground", + "/a/old-play", + "/c/play/x", + ]); + // One row per folder, in its casing on disk, and none for a folder that is gone. + expect(await folderSuggestions(host, "play")).toEqual([ + { path: "/home/test/code/playground", from: "zoxide", isGit: true }, + { path: "/home/test/code/play-old", from: "zoxide", isGit: false }, + ]); + expect(await folderSuggestions(host, "~/code/pl")).toEqual([ + { path: "/home/test/code/play-old", from: "folder", isGit: false }, + { path: "/home/test/code/playground", from: "folder", isGit: true }, + ]); + + expect(await addCheckout(host, "~/notes")).toEqual({ error: "/home/test/notes is in no git checkout" }); + expect(await addCheckout(host, "~/nowhere")).toEqual({ error: "/home/test/nowhere is not a folder" }); + expect(await addCheckout(host, "~/code/playground")).toEqual({ root: "/home/test/code/playground" }); + expect(await addedCheckouts(host)).toEqual(["/home/test/code/playground"]); + // Before the session works anywhere its own folder stays, then the added ones. + expect(pickRoots([], "/work/main", { checkouts: 1, cwd: "touched" }, await addedCheckouts(host))).toEqual([ + "/work/main", + "/home/test/code/playground", + ]); + // Added folders come first and do not count against `checkouts`. + expect( + pickRoots( + [{ root: "/work/wt-a", at: 1 }], + "/work/main", + { checkouts: 1, cwd: "touched" }, + await addedCheckouts(host) + ) + ).toEqual(["/home/test/code/playground", "/work/wt-a"]); + + const typed = "/mod-git add playg"; + const rows = await gitBand.autocomplete?.(host, { text: typed, cursor: typed.length, token: "playg", start: 13 }); + expect(rows).toEqual([ + { text: "/home/test/code/playground", label: "~/code/playground", description: "git checkout · zoxide" }, + ]); + expect(await gitBand.autocomplete?.(host, { text: "playg", cursor: 5, token: "playg", start: 0 })).toEqual([]); + + expect(await removeAdded(host, "playground")).toBe("/home/test/code/playground"); + expect(await addedCheckouts(host)).toEqual([]); +}); + +test("agents sidebar: an agent's last words from its transcript's end, past a byte-cut first line", async () => { + const row = (text: string) => + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text }, { type: "tool_use" }] } }); + const tail = [ + 'cut": "half a row"}', + row("Reading the tests."), + JSON.stringify({ type: "user", message: { content: [{ type: "tool_result" }] } }), + row("Found it: the timeout is 5 s."), + JSON.stringify({ type: "assistant", message: { content: [{ type: "tool_use" }] } }), + ].join("\n"); + + expect(lastSaidOf(tail)).toBe("Found it: the timeout is 5 s."); + expect(lastSaidOf("")).toBe(""); + expect(bareDescription("(H) Sleeper one")).toBe("Sleeper one"); + expect(bareDescription("haiku-5-5 · Sleeper one")).toBe("Sleeper one"); + expect(bareDescription("Fix the (B) case")).toBe("Fix the (B) case"); +}); + +test("hook ledger: a repeated entry and a repeated area block become receipts", async () => { + const held = new Map<string, number>(); + const area = (path: string) => `Contents of ${path} (area rules):\n\n${"rule text ".repeat(40)}`; + const first = `The Bash call touched files.\n\n${area("/r/a.md")}\n\n${area("/r/b.md")}`; + + expect(dedupeEntry(first, held, 1).dropped).toEqual([]); + + const second = dedupeEntry(`The Read call touched files.\n\n${area("/r/b.md")}\n\n${area("/r/c.md")}`, held, 2); + expect(second.dropped).toEqual(["/r/b.md"]); + expect(second.text).toContain("Contents of /r/c.md (area rules)"); + expect(second.text).toContain("area rules /r/b.md repeated"); + expect(second.droppedChars).toBeGreaterThan(300); + + const whole = dedupeEntry(first, held, 3); + expect(whole.text.startsWith("[genesis-tools-mods:")).toBe(true); + expect(dedupeEntry("short", new Map([["5:0", 1]]), 4).text).toBe("short"); + expect(tokensText(38_200 * 4)).toBe("~38.2k"); + + const stored = { main: { fires: 2, chars: 90 }, agents: { fires: 0, chars: 0 }, dropped: { fires: 0, chars: 0 } }; + expect(ledgerOf({ ...stored, byEvent: { PostToolUse: { fires: 2, chars: 90 }, Bad: 3 } })).toEqual({ + ...stored, + byEvent: { PostToolUse: { fires: 2, chars: 90 } }, + }); + expect(ledgerOf({ main: 1 })).toBeUndefined(); +}); + +test("cache band: own row first, others only inside the warning window", async () => { + const minute = 60_000; + const now = 100 * minute; + const marks = [ + { sessionId: "other-late", label: "b", lastAt: now - 55 * minute, ttlMs: 60 * minute }, + { sessionId: "own", label: "a", lastAt: now - 10 * minute, ttlMs: 60 * minute }, + { sessionId: "other-soon", label: "c", lastAt: now - 58 * minute, ttlMs: 60 * minute }, + { sessionId: "other-fresh", label: "d", lastAt: now - 5 * minute, ttlMs: 60 * minute }, + { sessionId: "other-ended", label: "e", lastAt: now - 58 * minute, ttlMs: 60 * minute, ended: true }, + { sessionId: "other-cold", label: "f", lastAt: now - 70 * minute, ttlMs: 60 * minute }, + ]; + + expect(cacheRowsOf(marks, "own", 10 * minute, now).map((row) => row.sessionId)).toEqual([ + "own", + "other-soon", + "other-late", + ]); + + // A compaction after the last turn: the own row says so, and another session gives no warning. + const compacted = [ + { sessionId: "own", label: "a", lastAt: now - 50 * minute, ttlMs: 60 * minute, compactedAt: now - 2 * minute }, + { sessionId: "other", label: "b", lastAt: now - 55 * minute, ttlMs: 60 * minute, compactedAt: now - minute }, + ]; + expect(cacheRowsOf(compacted, "own", 10 * minute, now)).toEqual([ + { + sessionId: "own", + label: "a", + expiresAt: now + 10 * minute, + own: true, + compacted: true, + compactedAt: now - 2 * minute, + }, + ]); + + // Dim above the caution window, yellow inside it, red inside the warning. + expect(cacheTone(25 * minute, 10 * minute, 20 * minute)).toBeUndefined(); + expect(cacheTone(15 * minute, 10 * minute, 20 * minute)).toBe("yellow"); + expect(cacheTone(4 * minute, 10 * minute, 20 * minute)).toBe("red"); + // A caution under the warning means no yellow stage. + expect(cacheTone(15 * minute, 10 * minute, 5 * minute)).toBeUndefined(); +}); + +test("genesis probe: a run cut off by a newer render is not remembered", async () => { + forgetGenesisProbe(); + let calls = 0; + const json = JSON.stringify({ + router: { linkHost: "links.example.test" }, + links: { hub: { opens: true } }, + }); + const host: Host = { + ...checkoutHost(), + async run() { + calls += 1; + + if (calls === 1) { + throw new Error("HooksError: genesis-tools-mods: $.process.run(tools) aborted"); + } + + return { exitCode: 0, stdout: json, stderr: "", isStdoutTruncated: false, isStderrTruncated: false }; + }, + }; + + expect((await genesisProbe(host)).opens.hub).toBe(false); + // The abort said nothing about this Mac: the next call runs the probe again and finds the hub. + expect((await genesisProbe(host)).opens.hub).toBe(true); + expect(calls).toBe(2); + forgetGenesisProbe(); +}); + +test("live times: the text, and when it next changes", async () => { + const now = 1_000_000; + + forgetLive(); + expect(liveAgo(now - 14_250, { now })).toBe("14s ago"); + // "14s" reads "15s" in 750 ms. + expect(liveDue()).toBe(now + 750); + expect(liveSince(now - 125_000, now)).toBe("2m 05s"); + expect(liveLeft(now + 4 * 60_000 + 500, { now })).toBe("4m 00s"); + expect(liveDue()).toBe(now + 500); + + // Minutes only change once a minute, so a dim chip does not redraw every second. + forgetLive(); + expect(liveLeft(now + 4 * 60_000 + 500, { now, unit: "minute" })).toBe("5m"); + expect(liveDue()).toBe(now + 500); + forgetLive(); + expect(liveAgo(now - 3 * 60_000 - 1_000, { now, unit: "minute" })).toBe("3m ago"); + expect(liveDue()).toBe(now + 59_000); +}); + +test("ask quote and spinner label", async () => { + expect(quoteOf([{ tool: "Bash", input: { command: "ls -la" }, output: { stdout: "a\nb" } }])).toBe( + "> Bash: ls -la\n> a\n> b\n\n" + ); + expect(toolLabel("Bash", { command: "bun run test", description: "Run the suite" })).toBe("Bash Run the suite"); + expect(toolLabel("Read", {})).toBe("Read"); +}); + +test("link targets by kind, and only links this Mac opens", async () => { + expect(targetFor(ALL_LINKS, "/r/My Notes/a.md", "file", 3, 9)).toBe( + "https://links.example.test/md/open?path=/r/My%20Notes/a.md&range=3-9" + ); + expect(targetFor(ALL_LINKS, "/r/src", "dir")).toBe("https://links.example.test/finder/reveal?target=/r/src"); + expect(targetFor(ALL_LINKS, "/r/src/a.ts", "file", 12)).toBe("cursor://file/r/src/a.ts:12"); + expect(targetFor(ALL_LINKS, "/r/src/a (1).ts", "file")).toBe("/r/src/a%20%281%29.ts"); + // No router: a note opens in the editor at its line, or by its path; a folder gets no link. + expect(targetFor(NO_LINKS, "/r/My Notes/a.md", "file", 3, 9)).toBe("cursor://file/r/My%20Notes/a.md:3"); + expect(targetFor(NO_LINKS, "/r/My Notes/a.md", "file")).toBe("/r/My%20Notes/a.md"); + expect(targetFor(NO_LINKS, "/r/src", "dir")).toBeUndefined(); +}); + +test("the probe out of tools genesis-tools probe --json", async () => { + const opens = { opens: true }; + const json = JSON.stringify({ + router: { linkHost: "links.example.test" }, + links: { md: opens, finder: { opens: false }, hub: opens, "claude-find": opens }, + }); + + expect(probeOf(json)).toEqual({ + tools: true, + linkHost: "links.example.test", + opens: { md: true, finder: false, hub: true, "claude-find": true }, + }); + // No link host: nothing opens, whatever the rows say. + expect(probeOf(JSON.stringify({ router: {}, links: { md: opens } }))?.opens.md).toBe(false); + expect(probeOf("Usage: genesis-tools [options]")).toBeUndefined(); +}); + +/** + * The machine beneath the plugin: a repository at /repo with three paths and one commit, and GenesisTools with + * every link kind working (`links: "all"`) or not installed at all (`"none"`). + */ +function machine(on: On, links: "all" | "none" = "all"): void { + forgetGenesisProbe(); + mock.env(on, { HOME: "/home/test" }); + const files: Record<string, "file" | "dir"> = { + "/repo/src/a.ts": "file", + "/repo/docs": "dir", + "/repo/notes/plan.md": "file", + }; + + // Every mod past the full diff starts off; the link fixer is turned on through the hooks file. + on("fs.read", async (_$, e) => { + if (e.path === "/home/test/.genesis-tools/agents/hooks.json") { + return { value: JSON.stringify({ mods: { links: { enabled: true }, decisions: { enabled: true } } }) }; + } + + throw new Error("no file"); + }); + on("fs.stat", async (_$, e) => { + const kind = files[e.path]; + + if (!kind) { + throw new Error(`ENOENT ${e.path}`); + } + + return { value: { kind, size: 1, mtimeMs: 1, isLink: false } }; + }); + on("session.cwd", async () => ({ value: "/repo" })); + on("session.id", async () => ({ value: "sess-1" })); + on("session.messages", async () => ({ + // The second message is this reply itself, past the request's `messageCount` of 1: it asks nothing earlier. + value: [ + { role: "assistant", text: "❓ DECISION 3 pick one\na) x", toolUses: [] }, + { role: "assistant", text: "not ❓ DECISION 9.", toolUses: [] }, + ], + })); + on("process.run", async (_$, e) => { + const argv = e.argv.join(" "); + const answer = (stdout: string, exitCode = 0) => ({ + value: { exitCode, stdout, stderr: "", isStdoutTruncated: false, isStderrTruncated: false }, + }); + + if (argv === "tools genesis-tools probe --json") { + if (links === "none") { + throw new Error("spawn tools ENOENT"); + } + + const opens = { opens: true }; + return answer( + JSON.stringify({ + router: { linkHost: "links.example.test" }, + links: { md: opens, finder: opens, hub: opens, "claude-find": opens }, + }) + ); + } + + if (argv.endsWith("rev-parse --show-toplevel")) { + return answer("/repo\n"); + } + + if (argv.endsWith("remote get-url origin")) { + return answer("git@github.com:acme/widgets.git\n"); + } + + if (argv.endsWith("pr view 42 --json title,url")) { + return answer('{"title":"feat: widgets","url":"https://github.com/acme/widgets/pull/42"}'); + } + + if (argv.endsWith("cat-file -t abc1234f")) { + return answer("commit\n"); + } + + if (argv.includes("/mods-log/")) { + return answer(""); + } + + return answer("", 1); + }); + on("turn.step", async function* () { + yield { kind: "text", index: 0, text: "See src/a.ts:12 and `docs/` and **abc1" }; + yield { + kind: "text", + index: 0, + text: "234f**.\nRepeat ❓ DECISION 3 now, not ❓ DECISION 9.\n```\nsrc/a.ts\n```\n", + }; + yield { + kind: "text", + index: 0, + text: "Already [a](src/a.ts), https://b.c/src/a.ts#9, missing/x.ts, notes/plan.md, PR #42, #43", + }; + yield { kind: "stop", stopReason: "end_turn", usage: null }; + + return { turnId: "t1", index: 0, answer: "", toolUses: [], stopReason: "end_turn", usage: null }; + }); +} + +test("the link fixer links what resolves, as the reply streams, and leaves the rest", async ($, on) => { + machine(on); + + const stream = $.turn.step({ turnId: "t1", index: 0, model: "claude-haiku-5-5", messageCount: 1 }); + const chunks: TurnStepChunk[] = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + const text = chunks.map((chunk) => (chunk.kind === "text" ? chunk.text : "")).join(""); + + expect(text).toBe( + [ + "See [src/a.ts:12](cursor://file/repo/src/a.ts:12) and [docs/](https://links.example.test/finder/reveal?target=/repo/docs) and [**abc1234f**](https://links.example.test/hub?repo=acme%2Fwidgets&commit=abc1234f).", + "Repeat [❓ DECISION 3](https://links.example.test/claude/find/sess-1/decision/3) now, not ❓ DECISION 9.", + "```", + "src/a.ts", + "```", + "Already [a](src/a.ts), https://b.c/src/a.ts#9, missing/x.ts, [notes/plan.md](https://links.example.test/md/open?path=/repo/notes/plan.md), PR [#42 (feat: widgets)](https://github.com/acme/widgets/pull/42), #43", + ].join("\n") + ); + expect(chunks.at(-1)?.kind).toBe("stop"); +}); + +test("without GenesisTools the link fixer writes no genesis.tools link", async ($, on) => { + machine(on, "none"); + + const stream = $.turn.step({ turnId: "t1", index: 0, model: "claude-haiku-5-5", messageCount: 1 }); + let text = ""; + + for await (const chunk of stream) { + text += chunk.kind === "text" ? chunk.text : ""; + } + + expect(text).not.toContain("links.example.test"); + expect(text).toContain("[src/a.ts:12](cursor://file/repo/src/a.ts:12) and `docs/` and **abc1234f**."); + expect(text).toContain("Repeat ❓ DECISION 3 now"); + expect(text).toContain("[notes/plan.md](/repo/notes/plan.md)"); +}); + +test("stall catcher: the closing sentence that stops on intent, and the endings that are no stall", async () => { + expect(lastSentence("Done.\n\n- **Let me** check the logs.\n")).toBe("Let me check the logs."); + expect(stallSentence("The suite passes. Next I'll run the tests.")).toBe("Next I'll run the tests."); + expect(stallSentence("Committed it.\n\nI'll fix the two findings next.")).toBe("I'll fix the two findings next."); + // Gated on an event, a decision not to act, work in flight elsewhere, or a finished turn. + expect(stallSentence("I'll report the verdict when it fires.")).toBeUndefined(); + expect(stallSentence("I won't rewrite the history while others push.")).toBeUndefined(); + expect(stallSentence("The review agent is still running. I'll fix its findings next.")).toBeUndefined(); + expect(stallSentence("All 24 tests pass, and the commit is in.")).toBeUndefined(); + expect(continueText("Next I'll run the tests.")).toContain('"Next I\'ll run the tests."'); +}); + +test("stall catcher: a stop with nothing pending shows the row, a pending task does not, a press continues", async () => { + forgetSettings(); + const sent: string[] = []; + const base = checkoutHost(); + let agents: Awaited<ReturnType<Host["agents"]>> = []; + const host: Host = { + ...base, + async agents() { + return agents; + }, + async read(path) { + return path === "/home/test/.genesis-tools/agents/hooks.json" + ? JSON.stringify({ mods: { stall: { enabled: true } } }) + : base.read(path); + }, + async submit(text) { + sent.push(text); + return undefined; + }, + }; + const stop = (extra: Record<string, unknown>) => ({ + hook_event_name: "Stop", + last_assistant_message: "Rebased. Next I'll run the tests.", + background_tasks: [], + session_crons: [], + ...extra, + }); + + await noteStop( + host, + stop({ background_tasks: [{ id: "b1", type: "shell", status: "running", description: "x" }] }) + ); + expect(stallNow()).toBeUndefined(); + await noteStop(host, stop({ session_crons: [{ id: "c1" }] })); + expect(stallNow()).toBeUndefined(); + await noteStop(host, { ...stop({}), hook_event_name: "SubagentStop" }); + expect(stallNow()).toBeUndefined(); + // No task list means unknown, and a subagent at work can still report back: neither fires. + const { background_tasks: _dropped, ...noList } = stop({}); + await noteStop(host, noList); + expect(stallNow()).toBeUndefined(); + agents = [{ id: "a1", description: "x", type: "general-purpose", status: "running" }]; + await noteStop(host, stop({})); + expect(stallNow()).toBeUndefined(); + agents = [{ id: "a1", description: "x", type: "general-purpose", status: "completed" }]; + + await noteStop(host, stop({})); + expect(stallNow()?.sentence).toBe("Next I'll run the tests."); + clearStall(host, "tool call"); + expect(stallNow()).toBeUndefined(); + + await noteStop(host, stop({})); + await pressStall(host, "dismiss"); + expect(sent).toEqual([]); + await noteStop(host, stop({})); + await pressStall(host, "continue"); + expect(stallNow()).toBeUndefined(); + expect(sent).toEqual([continueText("Next I'll run the tests.")]); + forgetSettings(); +}); + +test("session files: worktrees locked to the session, their state, and the total without double counts", async () => { + expect(samePath("/private/tmp/cc/p/s/worktrees/a/")).toBe("/tmp/cc/p/s/worktrees/a"); + const porcelain = [ + "worktree /work/main\nHEAD 1\nbranch refs/heads/master", + "worktree /work/wt-a\nHEAD 2\nbranch refs/heads/a\nlocked claude session sess-1 pid 12 since 2026-10-23T10:00:00Z", + "worktree /private/tmp/cc/p/other/worktrees/b\nHEAD 3\nlocked claude session sess-2 pid 13 since 2026-10-23T10:00:00Z", + ].join("\n\n"); + expect(lockedToSession(porcelain, "sess-1")).toEqual(["/work/wt-a"]); + expect(statusOf("## feat/x...origin/feat/x [ahead 2]\n M a.ts\n?? b.ts\n")).toEqual({ + branch: "feat/x", + changes: 2, + }); + expect(statusOf("## HEAD (no branch)\n")).toEqual({ branch: "detached", changes: 0 }); + const tree = (path: string, kb: number) => ({ path, source: "", branch: "b", changes: 0, isLocked: false, kb }); + expect( + sessionTotalKb({ + scratch: { path: "/tmp/cc/p/s", kb: 400 }, + tasks: { path: "/tmp/claude-1/p/s", kb: 12 }, + // The one inside the scratch folder is already in its 400. + worktrees: [tree("/work/.claude/worktrees/agent-1", 3000), tree("/tmp/cc/p/s/worktrees/w", 100)], + }) + ).toBe(3412); +}); + +test("mods settings: a value equal to the file's shipped copy follows today's default, as the CLI reads it", async () => { + const file = (git: boolean, shippedGit: boolean) => + JSON.stringify({ + mods: { git: { enabled: git }, tmpUsage: { enabled: true, minutes: 7 } }, + _shippedDefaults: { mods: { git: { enabled: shippedGit }, tmpUsage: { enabled: false, minutes: 5 } } }, + }); + + // Written while git shipped on: the `true` is that day's default, not the user's, so today's default (off) holds. + expect(modsSettings(file(true, true)).git.enabled).toBe(false); + expect(modsSettings(file(true, false)).git.enabled).toBe(true); + expect(modsSettings(file(true, true)).tmpUsage).toEqual({ enabled: true, minutes: 7 }); + // A hand-written file has no shipped copy: every value is the user's. + expect(modsSettings(JSON.stringify({ mods: { git: { enabled: true } } })).git.enabled).toBe(true); + // The decisions and inbox sidebars: a known choice is kept, anything else keeps the default. + const sidebars = modsSettings( + JSON.stringify({ + mods: { + decisions: { enabled: true, chip: "waiting", expand: "sometimes" }, + inbox: { enabled: true, chip: "unread", expand: "all" }, + }, + }) + ); + expect(sidebars.decisions).toEqual({ + enabled: true, + chip: "waiting", + expand: "waiting", + history: "collapsed", + enter: "stage-then-send", + }); + expect(sidebars.inbox).toEqual({ enabled: true, chip: "unread", expand: "all" }); + expect(modsSettings(undefined).inbox).toEqual({ enabled: false, chip: "any", expand: "none" }); +}); diff --git a/plugins/genesis-tools-mods/tests/fold.test.tsx b/plugins/genesis-tools-mods/tests/fold.test.tsx index 155b1fa549..aae2403a75 100644 --- a/plugins/genesis-tools-mods/tests/fold.test.tsx +++ b/plugins/genesis-tools-mods/tests/fold.test.tsx @@ -169,7 +169,7 @@ test("a git pull's diff starts hidden behind one button, which shows it and hide await ui.unmount(); }); -test("mods.collapseGitSync off draws a git pull like any other call", async ($, on) => { +test("mods.diff.collapseGitSync off (the flat key of before) draws a git pull like any other call", async ($, on) => { engine(on, { [NOTE_PATH]: NOTE, "/home/test/.genesis-tools/agents/hooks.json": '{"mods":{"collapseGitSync":false}}', diff --git a/plugins/genesis-tools-mods/types/index.d.ts b/plugins/genesis-tools-mods/types/index.d.ts index 8f4f0c4329..9b8af93676 100644 --- a/plugins/genesis-tools-mods/types/index.d.ts +++ b/plugins/genesis-tools-mods/types/index.d.ts @@ -4,8 +4,155 @@ export type FlippedFiles = string[]; /** The git-sync calls whose hidden diff the person opened: `<row>|<call>`, the newest 500. */ export type ShownCalls = string[]; +/** One option of a decision: its letter and its text. */ +export interface DecisionOption { + letter: string; + text: string; +} + +/** A `❓ DECISION N` the assistant asked and nobody answered or replaced yet. */ +export interface OpenDecision { + n: number; + title: string; + options: DecisionOption[]; +} + +/** The open decisions `/decisions` lists, as last read from the session's messages. */ +export type OpenDecisions = OpenDecision[]; + +/** What the person staged per decision number: `a)` for an option, or their own words. */ +export type StagedDecisions = Record<string, string>; + +/** One CI check of a pull request. */ +export interface BandCheck { + name: string; + verdict: "pass" | "fail" | "pending"; + /** What the check said about itself (`Deployment was blocked`); empty when it said nothing. */ + description: string; + /** Its page: the run, or the status's target. */ + link: string; +} + +/** One pull request the git band shows. */ +export interface BandPr { + number: number; + title: string; + url: string; + state: string; + draft: boolean; + passed: number; + failed: number; + pending: number; + checks: BandCheck[]; + /** `/mod-git pr add` keeps it in the checkout's row whatever branch is checked out. */ + tracked: boolean; + /** The PR of the branch the checkout has checked out. */ + own: boolean; + /** Taken out of the band by the sidebar's `hide`; the sidebar still lists it. */ + hidden: boolean; + /** The branch of an earlier PR: one this session made or worked on in the checkout, now not checked out there. */ + from?: string; +} + +/** One checkout's row of the git band, as the last refresh found it. */ +export interface GitBand { + /** The checkout's git root. */ + root: string; + /** Why the band shows it: added by hand, worked in by the session, or the session's own folder. */ + why: "added" | "worked" | "folder"; + at: number; + branch: string; + ahead: number; + behind: number; + dirty: number; + /** Lines added and removed in tracked files against HEAD, staged and unstaged together. */ + added: number; + removed: number; + prs: BandPr[]; + /** The main checkout it belongs to: the root itself, or for a linked worktree the folder that holds its `.git`. */ + repo?: string; + /** The worktree folder is gone; the branch and its PRs are read from the main checkout (`mods.git.earlier`). */ + gone?: boolean; +} + +/** The git band of this session, one row per checkout it shows; empty until the first refresh. */ +export type GitBandRows = GitBand[]; + +/** `/mod-git on|off` for this session; `null` follows `mods.git.enabled`. */ +export type GitBandSwitch = boolean | null; + +/** One session whose prompt cache runs out soon, as the cache band shows it. */ +export interface CacheRow { + sessionId: string; + label: string; + expiresAt: number; + own: boolean; + /** The session compacted after its last turn: its next prompt starts a new, small context, so no countdown. */ + compacted: boolean; + /** When it compacted, while `compacted`. */ + compactedAt?: number; +} + +/** The rows of the cache band: this session's, then other sessions under the warning threshold. */ +export type CacheRows = CacheRow[]; + +/** The last /tmp reading: KB of this session's scratch folder and free KB on the system disk. */ +/** A git worktree this session made or holds: where it is, where it came from, its branch, changes, lock and size. */ +export interface SessionWorktree { + path: string; + /** `subagent: <description>`, `made in its scratch folder` or `locked to this session`. */ + source: string; + branch: string; + changes: number; + isLocked: boolean; + kb: number | null; +} + +/** A folder and its size in KB (`null` when `du` failed). */ +export interface SizedFolder { + path: string; + kb: number | null; +} + +export interface TmpReading { + at: number; + scratch: SizedFolder; + /** Claude Code's task output for the session; an empty path when it could not be named. */ + tasks: SizedFolder; + worktrees: SessionWorktree[]; + freeKb: number | null; +} + +export type TmpUsage = TmpReading | null; + +/** A subagent this session spawned, as `agent.spawn` answered: its model, what it was for, when it started. */ +export interface SpawnedAgent { + model: string; + description: string; + at: number; +} + +export type SpawnedAgents = Record<string, SpawnedAgent>; + +/** What the hooks handed the model, per loop (`main` or a subagent id summed as `agents`). */ +export interface HookCount { + fires: number; + chars: number; +} + +/** The hook ledger: what settings hooks injected this session, and what the dedupe dropped as a repeat. */ +export interface HookLedger { + main: HookCount; + agents: HookCount; + byEvent: Record<string, HookCount>; + dropped: HookCount; +} + declare module "claude-code" { interface PluginState { - "genesis-tools-mods": { flipped: FlippedFiles; shown: ShownCalls }; + "genesis-tools-mods": { + flipped: FlippedFiles; + shown: ShownCalls; + }; } } diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index 68d79199bf..b94238768f 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.114", + "version": "1.0.118", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/hooks/area-rules.test.ts b/plugins/genesis-tools/hooks/area-rules.test.ts index e8b365c5a2..9c15a42f33 100644 --- a/plugins/genesis-tools/hooks/area-rules.test.ts +++ b/plugins/genesis-tools/hooks/area-rules.test.ts @@ -2,7 +2,17 @@ import { afterAll, describe, expect, it } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { decide, frontmatterPaths, isBroadGlob, pathTokens, readRules, tsconfigAliases } from "./area-rules.ts"; +import { + agentTranscript, + compactedSince, + decide, + frontmatterPaths, + isBroadGlob, + markCompaction, + pathTokens, + readRules, + tsconfigAliases, +} from "./area-rules.ts"; const root = mkdtempSync(join(tmpdir(), "area-rules-")); mkdirSync(join(root, ".git")); @@ -135,3 +145,97 @@ describe("decide", () => { expect(decision).toEqual({ add: [], native: [] }); }); }); + +const row = (fields: Record<string, unknown>) => `${JSON.stringify(fields)}\n`; + +describe("compaction", () => { + const transcripts = join(root, "transcripts"); + mkdirSync(join(transcripts, "s1", "subagents"), { recursive: true }); + const at = Date.parse("2026-10-13T18:12:20.000Z"); + const stamp = (offsetMs: number) => new Date(at + offsetMs).toISOString(); + + it("maps a subagent to its own transcript and another harness to none", () => { + const main = join(transcripts, "s1.jsonl"); + expect(agentTranscript({ transcript_path: main })).toBe(main); + expect(agentTranscript({ transcript_path: main, agent_id: "a1" })).toBe( + join(transcripts, "s1", "subagents", "agent-a1.jsonl") + ); + expect(agentTranscript({ transcript_path: "/x/.codex/sessions/rollout-1.jsonl" })).toBeUndefined(); + }); + + it("finds the agent's own boundary, and keeps state only when the transcript proves there was none", () => { + const compacted = join(transcripts, "compacted.jsonl"); + writeFileSync( + compacted, + row({ type: "user", timestamp: stamp(-600_000) }) + + row({ type: "system", subtype: "compact_boundary", timestamp: stamp(80) }) + + row({ type: "user", timestamp: stamp(5_000) }) + ); + const other = join(transcripts, "other.jsonl"); + writeFileSync( + other, + row({ type: "user", timestamp: stamp(-600_000) }) + row({ type: "user", timestamp: stamp(5_000) }) + ); + const older = join(transcripts, "older.jsonl"); + writeFileSync(older, row({ type: "system", subtype: "compact_boundary", timestamp: stamp(-3_600_000) })); + + expect(compactedSince(compacted, at)).toBe(true); + expect(compactedSince(other, at)).toBe(false); + expect(compactedSince(older, at)).toBe(false); + expect(compactedSince(join(transcripts, "missing.jsonl"), at)).toBe(true); + expect(compactedSince(undefined, at)).toBe(true); + }); + + it("marks every agent once and keeps the earliest compaction", () => { + const once = markCompaction({ main: ["a.md"], a1: ["b.md"] }, 1); + expect(markCompaction(once, 2)).toEqual({ + main: ["a.md"], + a1: ["b.md"], + "@compact:main": ["1"], + "@compact:a1": ["1"], + }); + }); + + it("a compaction re-adds the areas of the agent that compacted, and only of it (real hook processes)", async () => { + const mainSearch = { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "rg x src/du" }, + }; + const subSearch = { ...mainSearch, agent_id: "a1", tool_input: { command: "rg x src/utils/ai" } }; + + for (const compacting of ["subagent", "main"]) { + const session = `s-${compacting}`; + const main = join(transcripts, `${session}.jsonl`); + const sub = join(transcripts, session, "subagents", "agent-a1.jsonl"); + mkdirSync(join(transcripts, session, "subagents"), { recursive: true }); + for (const file of [main, sub]) { + writeFileSync(file, row({ type: "user", timestamp: new Date(Date.now() - 600_000).toISOString() })); + } + + const run = async (payload: Record<string, unknown>) => { + const child = Bun.spawn(["bun", join(import.meta.dir, "area-rules.ts")], { + stdin: new TextEncoder().encode( + JSON.stringify({ session_id: session, transcript_path: main, cwd: root, ...payload }) + ), + stdout: "pipe", + env: { ...process.env, GENESIS_TOOLS_HOME: join(root, "home") }, + }); + const out = await new Response(child.stdout).text(); + await child.exited; + return [...out.matchAll(/Contents of \S+?([^/\s]+\/CLAUDE\.md|[^/\s]+\.md) \(area rules\)/g)].map( + (m) => m[1] + ); + }; + + expect(await run(mainSearch)).toEqual(["du/CLAUDE.md"]); + expect(await run(subSearch)).toEqual(["ai.md"]); + await run({ hook_event_name: "SessionStart", source: "compact" }); + const boundary = row({ type: "system", subtype: "compact_boundary", timestamp: new Date().toISOString() }); + writeFileSync(compacting === "main" ? main : sub, boundary, { flag: "a" }); + + expect(await run(mainSearch)).toEqual(compacting === "main" ? ["du/CLAUDE.md"] : []); + expect(await run(subSearch)).toEqual(compacting === "main" ? [] : ["ai.md"]); + } + }); +}); diff --git a/plugins/genesis-tools/hooks/area-rules.ts b/plugins/genesis-tools/hooks/area-rules.ts index 34b65f9c61..5083578957 100644 --- a/plugins/genesis-tools/hooks/area-rules.ts +++ b/plugins/genesis-tools/hooks/area-rules.ts @@ -8,13 +8,26 @@ // // PostToolUse on Grep, Glob and Bash: this hook finds the project paths the call touched and adds each matching // area file to the context once per agent. On Read, Write and Edit it only records the areas Claude Code loads by -// itself, so it does not add them a second time. SessionStart after a compaction forgets what was added, because -// the compaction summary does not carry the files. - -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +// itself, so it does not add them a second time. After a compaction the agent that compacted forgets what was +// added, because the compaction summary does not carry the files. + +import { + appendFileSync, + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + statSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { parseLenientJson } from "./agents-talk-hint"; +import { harnessOf } from "./harness"; import { lockWorktreesForToolUse } from "./worktree-lock"; const SafeJSON = JSON; @@ -22,9 +35,16 @@ const SafeJSON = JSON; /** A response spanning more areas than this is a broad search: only the areas its input names are added. */ const BROAD_SEARCH_AREAS = 2; const MAX_SCAN_CHARS = 200_000; +/** State key prefix: `@compact:<agent>` holds the time of a compaction that agent has not been checked against. */ +const COMPACT_MARK = "@compact:"; +/** How much of a transcript's end the compaction check reads. A boundary sat up to 670 KB before the next rows. */ +const TRANSCRIPT_TAIL_BYTES = 4_000_000; +/** A compact_boundary row was stamped at most 0.8 s before its SessionStart hook (146 compactions); the margin. */ +const COMPACT_SLACK_MS = 10_000; export interface HookPayload { session_id?: string; + transcript_path?: string; cwd?: string; hook_event_name?: string; source?: string; @@ -248,6 +268,98 @@ function writeState(sessionId: string, state: Record<string, string[]>): void { writeFileSync(file, SafeJSON.stringify(state)); } +/** + * SessionStart `compact` names the session but not the agent that compacted: a subagent's compaction carries the + * parent's session id and transcript and no `agent_id` (the hook log of 2026-10-13 18:12, session 4578e02a). So + * every agent is marked here and checked against its own transcript at its next tool call. Wiping every agent + * caused 59 of the 60 repeated injections measured from 2026-10-13 to 2026-10-23. + */ +export function markCompaction(state: Record<string, string[]>, now: number): Record<string, string[]> { + const marked = { ...state }; + for (const agent of Object.keys(state)) { + if (!agent.startsWith(COMPACT_MARK) && !marked[`${COMPACT_MARK}${agent}`]) { + marked[`${COMPACT_MARK}${agent}`] = [String(now)]; + } + } + + return marked; +} + +/** The agent's own Claude Code transcript; undefined for another harness, whose transcript has no boundary rows. */ +export function agentTranscript(payload: HookPayload): string | undefined { + const transcript = payload.transcript_path; + if (!transcript || harnessOf({ transcript_path: transcript }) !== "claude") { + return undefined; + } + + if (!payload.agent_id) { + return transcript; + } + + return join(dirname(transcript), basename(transcript, ".jsonl"), "subagents", `agent-${payload.agent_id}.jsonl`); +} + +/** + * Whether the transcript holds a compaction at or after `since`. When the read tail does not reach back past + * `since`, or the file cannot be read, the answer is true: a repeated area costs context, a lost one costs rules. + */ +export function compactedSince(transcript: string | undefined, since: number): boolean { + if (!transcript) { + return true; + } + + let fd: number; + + try { + fd = openSync(transcript, "r"); + } catch { + // No transcript at this path: assume the compaction was this agent's. + return true; + } + + try { + const size = fstatSync(fd).size; + const length = Math.min(size, TRANSCRIPT_TAIL_BYTES); + const buffer = Buffer.alloc(length); + readSync(fd, buffer, 0, length, size - length); + // The first line of a partial tail is cut; the rest are whole rows. + const start = length < size ? buffer.indexOf(10) + 1 : 0; + const from = since - COMPACT_SLACK_MS; + for (let at = buffer.indexOf('"compact_boundary"', start); at !== -1; ) { + const lineStart = buffer.lastIndexOf(10, at) + 1; + const lineEnd = buffer.indexOf(10, at); + const end = lineEnd === -1 ? length : lineEnd; + const row = parseRow(buffer.toString("utf8", Math.max(lineStart, start), end)); + if ( + row?.type === "system" && + row.subtype === "compact_boundary" && + Date.parse(String(row.timestamp)) >= from + ) { + return true; + } + + at = buffer.indexOf('"compact_boundary"', end); + } + + const firstStamp = Date.parse( + buffer.toString("utf8", start, Math.min(length, start + 64_000)).match(/"timestamp":"([^"]+)"/)?.[1] ?? "" + ); + return length < size && !(firstStamp <= from); + } finally { + closeSync(fd); + } +} + +function parseRow(line: string): Record<string, unknown> | null { + try { + const row: unknown = SafeJSON.parse(line); + return isRecord(row) ? row : null; + } catch { + // A row cut by a concurrent append. + return null; + } +} + function textOf(value: unknown): string { return typeof value === "string" ? value : SafeJSON.stringify(value ?? ""); } @@ -322,8 +434,10 @@ if (import.meta.main) { } if (payload.hook_event_name === "SessionStart") { - if (payload.source === "compact" || payload.source === "clear") { + if (payload.source === "clear") { writeState(sessionId, {}); + } else if (payload.source === "compact") { + writeState(sessionId, markCompaction(readState(sessionId), Date.now())); } process.exit(0); @@ -337,9 +451,13 @@ if (import.meta.main) { const rules = readRules(root); const state = readState(sessionId); const agent = payload.agent_id ?? "main"; - const seen = state[agent] ?? []; + const mark = `${COMPACT_MARK}${agent}`; + const compactedAt = state[mark] ? Number(state[mark][0]) : null; + delete state[mark]; + const seen = + compactedAt !== null && compactedSince(agentTranscript(payload), compactedAt) ? [] : (state[agent] ?? []); const decision = decide({ payload, root, rules, seen, aliases: tsconfigAliases(root) }); - if (decision.add.length === 0 && decision.native.length === 0) { + if (decision.add.length === 0 && decision.native.length === 0 && compactedAt === null) { process.exit(0); } diff --git a/plugins/genesis-tools/skills/handoff-to/SKILL.md b/plugins/genesis-tools/skills/handoff-to/SKILL.md index a184769f03..98793c348f 100644 --- a/plugins/genesis-tools/skills/handoff-to/SKILL.md +++ b/plugins/genesis-tools/skills/handoff-to/SKILL.md @@ -83,10 +83,26 @@ Set a model explicitly; an Astra parent should not make every worker Astra. |---|---|---| | Mechanical extraction, formatting, simple lookups, or a fully specified small edit | `gpt-6-luna` | low | | Bounded repository exploration, tracing an established call path, or gathering evidence | `gpt-6-luna` | medium | -| Normal implementation, reproducible debugging, and code review | `gpt-6.1-sol` | medium; high for complex reviews | +| Normal implementation and reproducible debugging | `gpt-6.1-sol` | medium | +| Code review of a PR or branch | `gpt-6.1-sol` | `--mode review --base <ref>`: high up to 15 files and 800 lines, xhigh above | | Difficult cross-system reasoning, ambiguous failures, or an evidence-backed escalation | `gpt-6-astra` | high | `gpt-6.1-sol` accepts `low` to `max` effort only; `none` and `minimal` return an error, as on Astra. + +**Effort is the review's depth: spawn every Codex review with `--mode review --base <ref>`.** Measured on +2026-10-22 with nine Sol reviews at `medium`: 1 to 4 minutes each, 8 to 26 tool calls, and 358 to 2,399 +reasoning tokens, also for a 125-file, 6,200-line PR. The findings were real, but no worker could have read +every file. `--mode review` now does the depth work the brief used to ask for: it computes the manifest +(`git diff --numstat <base>...HEAD`), gives the worker its tier's review contract (read every hunk, callers and +tests, probes, the `Files reviewed: n of N` line), picks the tier from the diff size when `--effort` is omitted +(high up to about 15 files and 800 lines, xhigh above; `max` only when you name it for a risky cross-system PR; +`low` and `medium` need `--allow-shallow`), and proposes a split by folder above about 60 files (`--paths`). +After each turn it gates the depth (coverage against the manifest, reasoning tokens per 100 changed lines, +finding shape): a miss steers the same session once with the unread files, a second miss reruns one tier higher +in a new session (`<name>-<tier>`), and after that the review is `shallow`, never done. The brief names the +milestone, the PR and the negative constraints; it no longer restates depth rules. See every review run with +`tools codex reviews [--since 24h] [--pr <n>] [--label <text>] [--verdict shallow]`, and one in depth with +`tools codex review-depth <name>`. Astra is the escalation tier, not the default for exploration. Keep the cheaper worker's findings and failed verification when escalating. If a model is unavailable, disclose the fallback and select the nearest suitable tier instead of silently choosing Astra. diff --git a/plugins/genesis-tools/skills/handoff-to/references/codex.md b/plugins/genesis-tools/skills/handoff-to/references/codex.md index 62852ab47e..87208d6cc2 100644 --- a/plugins/genesis-tools/skills/handoff-to/references/codex.md +++ b/plugins/genesis-tools/skills/handoff-to/references/codex.md @@ -15,6 +15,12 @@ The `tools codex spawn` command accepts an explicit model string. Availability s depends on the selected account and backend. Verify the recorded model after dispatch; do not treat a requested model or the parent's config as proof of what ran. Code-review workers should explicitly select Sol; `review_model` does not route arbitrary workers. +Spawn a review with `--mode review --base <ref>` (or let it read the branch's PR target or upstream) and omit +`--effort`: the harness picks high or xhigh from the diff size, injects the manifest and the tier's contract, and +gates the depth after each turn (parent skill, "Effort is the review's depth"). At `medium` a review spent about +two thousand reasoning tokens however large the diff was, so `low` and `medium` need `--allow-shallow`. Every run +lands in `~/.genesis-tools/codex/reviews.jsonl`: `tools codex reviews` lists them, `tools codex review-depth <name>` +shows one. ## Choose the orchestration layer @@ -114,7 +120,7 @@ tools codex spawn --name <task> --account work \ Named login defaults to a new vault-owned grant. `tools codex login work`, `tools ai codex login work`, and `tools ai accounts login work --provider codex` use one core. `--broker` is a compatibility alias. Explicit `--auth-file`/`--home` and `--import-native` are native-file opt-ins, not worker-startup repairs. Do not run login, import or migration without authorization; stop and report auth failures. -Other flags: `--model` / `--effort`, `--mode review|task`, `--session <id>` when no parent session can be discovered (resolution order: the explicit flag, then `$GENESIS_AGENTS_SESSION`, then `$GT_RENDEZVOUS_SESSION`, and only then the host session id of whoever runs the spawn — Claude Code, Codex or grok. An assigned swarm always outranks an inherited host id, so a nested worker cannot re-parent its children), `--no-agents` to disable the bus (do not — the bus is the point), `--project <id|name|root>` to set the thread's app-server project (without it the project that owns the cwd is used, and a worktree falls back to its main checkout). ⚠️ Codex Desktop ignores that id while its `localProjectTaskMembership` flag is off: it groups by cwd, and it sees a worker's thread only after it re-reads its thread list (`src/codex/README.md`). +Other flags: `--model` / `--effort`, `--mode review|task` (`review` with `--base <ref>`, `--allow-shallow`, `--paths <path...>`), `--session <id>` when no parent session can be discovered (resolution order: the explicit flag, then `$GENESIS_AGENTS_SESSION`, then `$GT_RENDEZVOUS_SESSION`, and only then the host session id of whoever runs the spawn — Claude Code, Codex or grok. An assigned swarm always outranks an inherited host id, so a nested worker cannot re-parent its children), `--no-agents` to disable the bus (do not — the bus is the point), `--project <id|name|root>` to set the thread's app-server project (without it the project that owns the cwd is used, and a worktree falls back to its main checkout). ⚠️ Codex Desktop ignores that id while its `localProjectTaskMembership` flag is off: it groups by cwd, and it sees a worker's thread only after it re-reads its thread list (`src/codex/README.md`). Worker records live in `~/.genesis-tools/codex/sessions/<name>.*` (event log, metadata and daemon log). With `--account`, the worker stays bound to that GenesisTools account; without it, native authentication follows the effective home. Worker event logs are separate from native conversation history. diff --git a/plugins/genesis-tools/skills/macos-control/SKILL.md b/plugins/genesis-tools/skills/macos-control/SKILL.md index 5722099f00..712806da55 100644 --- a/plugins/genesis-tools/skills/macos-control/SKILL.md +++ b/plugins/genesis-tools/skills/macos-control/SKILL.md @@ -35,12 +35,22 @@ Check the command named by help, not just exit zero. Do not guess command plural Read [automation-playbooks.md](references/automation-playbooks.md) before browser input, modal recovery, a Jev demonstration or multi-app execution. It contains complete examples and the failure-to-fix table. Read [native-cli.md](references/native-cli.md) for selector/snapshot/coordinate/plan contracts. [providers.md](references/providers.md) maps the standalone API, MCP and REPL. +## Don't take the user's focus + +The user keeps working while an agent drives their Mac. A call that brings an app to the front or moves the pointer interrupts them, and the keys they type next land in the agent's window. + +- **The background is the default.** `click` without `physical` or `prepare` uses AXPress or a window-addressed click. Use `press_key({..., activate:false})`, `set_value` without `prepare`, `fill_form({..., activate:false})` and `launch_app({path, activate:false})` (or `bundle_id`). `scroll` posts to the window. Open URLs and files with `open -g`, never with a bare `open -a` or `open -R`. +- **These take focus or the pointer:** `prepare:true`, `physical:true` on a browser page, `press_key` and `launch_app` with their default `activate:true`, `drag({background:false})`, and hover. Use them only for a visible demonstration the user asked for. +- **Under an agent, ax-tool refuses them while the user works.** A key, click, move or scroll in the last 5 s gives refusal `user_active` with `dispatchState:"not_started"`: nothing was sent. Do not retry in front; take the background path the error names. For a demonstration the user asked for, pass `allow_foreground:true` (CLI `--allow-foreground`, or `GENESIS_CONTROL_ALLOW_FOREGROUND=1` for a whole run). `GENESIS_CONTROL_FOREGROUND_IDLE=<seconds>` sets the quiet time, and `0` turns the check off. A person running a command is never refused. +- **A background click can do nothing.** A window-addressed click into an app that is not in front lands in a window that is not key. A SwiftUI control there (a sheet's "Cancel") takes it as the activation click and does nothing, yet the call returns `ok:true` with an `effectNote`. AXPress (a click without `physical`) acts without focus, but it sends no mouse event, so an app's NSEvent monitor does not see it. Check the postcondition after every background click. +- **Start apps with `launch_app`.** It goes through LaunchServices, as a Finder double-click does, so the app is its own process: its menu-bar item and its privacy prompts belong to it, not to the terminal. Do not run an app's binary from a shell, and do not open it through Finder's `AXOpen` (AX -25205 on a list item, and `/Applications` exceeds the observation budget). + ## Execute the requested task 1. Observe running apps and current windows. Pick a unique observed `window_id`; indexes reorder. Without one, `get_app_state` takes the only real window and ignores popups (a translate bubble, a hover card); with two real windows it refuses and names each `window_id`. Use `scope:"chrome", image:false` for browser tabs and address controls, `scope:"window"` for page content. Never turn a read timeout, oversized tree or permission error into evidence that a window is empty. When a page or an open panel exceeds the snapshot limit, observe one element with `get_app_state({app, window_id, query, role})`: the whole window is walked, only matches and their ancestors become rows, the refs work with `click`/`press_key`, and the state text says how many subtrees depth left unwalked. 2. Check for a visible `AXSheet` or explicitly modal container before choosing background controls. Snapshot dispatch blocks targets behind it. If the fast tree has empty roles/geometry, it is incomplete evidence: the backend falls back to normal AX reads. Inspect the dialog's text and act only within the user's authorization; no automatic acceptance of installs, authentication or permissions. 3. Construct bounded candidates locally. Exact identity/filtering comes first; Jev decides meaning when requested. Distinguish repeated labels by subtree, URL, role and nearby text. A `Source` link for React differs from TypeScript Source; an update page may contain two `Info` buttons. Do not send supplied form values to Jev. -4. Act through the newest reference. Use `prepare:true` for observed browser controls or focused keyboard input. A prepared native action with its original target fingerprint can automatically recover from a confirmed `not_started` stale/focus refusal, at most twice within a 1.5-second admission retry window. Every attempt uses the remaining original deadline, including action readback. This repeats admission, not completed workflow steps or Jev decisions. When a retry finds the target changed or gone, the call reports `effect:"unknown"` with every attempt's refusal (and what held focus): no attempt posted the input, but the UI moved after the first attempt focused it, so observe before repeating. Native AXPress/value writes can remain unfocused; high-level tasks prepare automatically where needed. For a visible demonstration, deliberately foreground the exact window and keep cursor feedback on. +4. Act through the newest reference, in the background (see above). `prepare:true` focuses the window first: use it only for a visible demonstration the user asked for, or for a browser control that ignores background input while the user is away. A prepared native action with its original target fingerprint can automatically recover from a confirmed `not_started` stale/focus refusal, at most twice within a 1.5-second admission retry window. Every attempt uses the remaining original deadline, including action readback. This repeats admission, not completed workflow steps or Jev decisions. When a retry finds the target changed or gone, the call reports `effect:"unknown"` with every attempt's refusal (and what held focus): no attempt posted the input, but the UI moved after the first attempt focused it, so observe before repeating. Native AXPress/value writes can remain unfocused; high-level tasks prepare automatically where needed. For a visible demonstration, deliberately foreground the exact window and keep cursor feedback on. 5. Verify a fresh postcondition. Dispatch is not completion. Use exact URL/title/value/state where available; `expected_url` pins compound browser tasks. Unknown delivery stops mutations. Read fresh state to discover what happened; do not repeat the input just because readback failed. ## Contracts to remember @@ -49,6 +59,8 @@ Read [automation-playbooks.md](references/automation-playbooks.md) before browse - **Fill without taking the user's focus.** `set_value({app, element_ref, value})` without `prepare`, or `fill_form({..., activate:false})`, writes AXValue inside the app: no activation, no clipboard, no keys, so the user can keep typing. It works on Chromium/Electron pages too (measured on T3 Code, 2026-10-13); the readback waits up to 1 s because Chromium applies the write late. The cursor label shows the text written (a secure field shows bullets). A field that saves its draft on blur or Return keeps the text unsaved while the app is in the back; fill that one in front with `paste({..., replace:true, prepare:true})`. - **Jev abstention stays an abstention.** Do not lower thresholds or retry the same question until it accepts. Improve genuinely missing evidence, narrow an ambiguous scope, or return a host packet. An independently justified exact host selection must be reported separately from a model choice. Host replies revalidate fresh evidence and consume no extra model call. - **Bound every loop.** `assist_task`, `fill_form`, `run_workflow` and `await_condition` share deadlines and request/action caps. Waits may use exact local evidence without Jev. Supply an `evidence_scope` to ignore changing unrelated panels; missing/ambiguous scope stops. +- **Name the window.** `click`, `press_key`, `set_value`, `type_text`, `paste`, `scroll`, `drag` and `find` take the `window_id` that `get_app_state` took, and refuse with `WRONG_WINDOW` when the app's latest observation is another window. `list_windows` returns `{windows:[…]}`, not an array. `drag` needs a `get_app_state` observation first, because its coordinates are that observation's pixels. +- **A changing label refuses a pointer click.** A physical click on a panel whose label ticks (a running clock) can refuse with "observed target changed"; AXPress on the same button is pinned by identity and works. Right after a sheet button, the tree can briefly show no `AXSheet` while the sheet is still up, so wait for the change with `await_condition` or a fresh `get_app_state` before the next click. - **Scrolling never takes focus, and can move like a person.** `control scroll --direction` posts to the window, not the frontmost app. To reproduce scroll bugs use `--time` (with `--repeat`/`--alternate`), not a loop of single wheel events; details in [native-cli.md](references/native-cli.md#scrolling-control-scroll). - **Native dropdowns are exact.** `AXPopUpButton` selects one enabled observed menu title and reads it back. Duplicate/missing choices refuse. `AXInvalid` stops subsequent form writes where the app exposes it. Arbitrary custom web dropdowns need their own observed workflow. - **Reference freshness and visibility are different.** Pin process/window/document, reobserve after changes, and use one-use pixel evidence for coordinates. Multiple displays can have negative origins and different scales. API coordinates default to screenshot pixels; native `act --coords` uses global screen points. diff --git a/scripts/benchmarks/README.md b/scripts/benchmarks/README.md index ac5e2623e0..38aa57512a 100644 --- a/scripts/benchmarks/README.md +++ b/scripts/benchmarks/README.md @@ -35,3 +35,6 @@ re-deriving how it was produced. - [`clones/`](clones/) — the `tools du` / APFS clone-detection microbenches and their runners. Predates these conventions and uses relative imports rather than `@app/benchmark/lib`. +- [`agent-sessions/`](agent-sessions/) — `session-listing.ts` (the session list and each provider's discovery) and + `read-backward.ts` (`readSessionBackward` on real Claude transcripts, the decision Stop hook's read; `--before + <checkout>` runs an older commit's code beside the current one, with a check that both read the same entries). diff --git a/scripts/benchmarks/agent-sessions/read-backward-child.ts b/scripts/benchmarks/agent-sessions/read-backward-child.ts new file mode 100644 index 0000000000..1c316396cd --- /dev/null +++ b/scripts/benchmarks/agent-sessions/read-backward-child.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env bun +/** + * One fresh process that reads Claude sessions from their end through the real door, `readSessionBackward`, as the + * decision Stop hook does (8 MB cap, `until` = an entry older than a cut). Prints one JSON object on stdout. Driven + * by `read-backward.ts`; run alone to look at one sample. + * + * bun read-backward-child.ts <checkout> <cut ISO time> <transcript...> + * + * The module is imported from `<checkout>`, so another worktree's copy (an older commit) runs its own code: Bun + * resolves each file's path aliases through the tsconfig nearest to it. + */ +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; + +const [checkout, cutIso, ...transcripts] = process.argv.slice(2); + +if (!checkout || !cutIso || transcripts.length === 0) { + process.stderr.write("usage: read-backward-child.ts <checkout> <cut ISO time> <transcript...>\n"); + process.exit(2); +} + +const { readSessionBackward } = await import(join(checkout, "src/utils/agent-sessions/session-backward.ts")); +const cut = Date.parse(cutIso); +/** The hook's predicate: stop at the first entry older than the cut. */ +const olderThanCut = (entry: { timestamp?: string }) => { + const at = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN; + return Number.isFinite(at) && at < cut; +}; +const SHAPES = { + // The hook's read: back to the cut, or the 8 MB cap. + hook: { maxBytes: 8 * 1024 * 1024, until: olderThanCut }, + // The worst case for the per-entry loop: no entry accepted, so every entry of the newest 16 MB is tested. + full: { maxBytes: 16 * 1024 * 1024, until: () => false }, +}; +const WARM_PASSES = 5; +const metrics: Record<string, number> = {}; + +for (const [shape, options] of Object.entries(SHAPES)) { + const warm: number[] = []; + + for (let pass = 0; pass <= WARM_PASSES; pass++) { + let entries = 0; + let found = 0; + const started = process.cpuUsage(); + + for (const transcriptPath of transcripts) { + const read = await readSessionBackward({ kind: "claude", transcriptPath, ...options }); + entries += read.entries.length; + found += read.found ? 1 : 0; + } + + const cpu = process.cpuUsage(started); + const cpuMs = (cpu.user + cpu.system) / 1000; + + if (pass === 0) { + metrics[`${shape}.cold.cpuMs`] = cpuMs; + // The check: both arms must read the same entries and stop at the same places. + metrics[`${shape}.entries`] = entries; + metrics[`${shape}.found`] = found; + } else { + warm.push(cpuMs); + } + } + + warm.sort((a, b) => a - b); + metrics[`${shape}.warm.cpuMs`] = warm[Math.floor(warm.length / 2)] ?? Number.NaN; +} + +process.stdout.write(`${SafeJSON.stringify(metrics, { strict: true })}\n`); diff --git a/scripts/benchmarks/agent-sessions/read-backward.ts b/scripts/benchmarks/agent-sessions/read-backward.ts new file mode 100644 index 0000000000..b11854ad07 --- /dev/null +++ b/scripts/benchmarks/agent-sessions/read-backward.ts @@ -0,0 +1,144 @@ +#!/usr/bin/env bun +/** + * What reading a session from its end costs: `readSessionBackward` on real Claude transcripts, the door the decision + * Stop hook calls on every turn end while `decisions.chatAnswers` is on. + * + * SHAPES (both in each child, see `read-backward-child.ts`): + * - `hook`: the hook's read, 8 MB cap, back to the first entry older than a cut (the cut is set an hour ago, so + * the busiest transcripts stop early and the quiet ones read to the cap or their start). + * - `full`: no entry accepted, 16 MB cap: every entry of the newest 16 MB goes through `until`, the worst case for + * the per-entry loop (2026-10-25: `findLastIndex` became a plain loop in 296e7870f2). + * + * ARMS, interleaved per run, each a fresh process: `current` is this checkout; `--before <checkout>` adds a second + * checkout (a worktree at an older commit) whose own code runs. `<arm>.<shape>.entries` and `.found` are the check: + * both arms must read the same entries. + * + * The transcripts are the live ones on this Mac (read-only): the largest few and a medium one, so record the + * baseline and the comparison close together. + * + * Usage: + * bun scripts/benchmarks/agent-sessions/read-backward.ts --runs 7 + * bun scripts/benchmarks/agent-sessions/read-backward.ts --runs 7 --before /tmp/cc/<project>/<session>/worktrees/before + * bun scripts/benchmarks/agent-sessions/read-backward.ts --baseline | --compare + */ +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { nativeSessionRoots } from "@genesiscz/utils/providers/session-paths"; +import { finishBench, parseBenchArgs } from "../fs/shared"; + +const BASELINE_NAME = "agent-sessions-read-backward"; +const REPO = join(import.meta.dir, "..", "..", ".."); +const CHILD = join(import.meta.dir, "read-backward-child.ts"); +const args = parseBenchArgs(process.argv.slice(2)); +const beforeAt = process.argv.indexOf("--before"); +const before = beforeAt > 0 ? process.argv[beforeAt + 1] : undefined; +const arms: Array<{ name: string; checkout: string }> = [ + { name: "current", checkout: REPO }, + ...(before ? [{ name: "before", checkout: before }] : []), +]; + +/** The four largest Claude transcripts plus one of 2-8 MB (read whole). */ +function pickTranscripts(): string[] { + const files: Array<{ path: string; size: number }> = []; + + for (const root of nativeSessionRoots("claude")) { + let projects: string[] = []; + + try { + projects = readdirSync(root); + } catch { + continue; + } + + for (const project of projects) { + let names: string[] = []; + + try { + names = readdirSync(join(root, project)); + } catch { + continue; + } + + for (const name of names) { + if (name.endsWith(".jsonl")) { + const path = join(root, project, name); + files.push({ path, size: statSync(path).size }); + } + } + } + } + + files.sort((a, b) => b.size - a.size); + const medium = files.find((file) => file.size > 2 * 2 ** 20 && file.size < 8 * 2 ** 20); + return [...files.slice(0, 4), ...(medium ? [medium] : [])].map((file) => file.path); +} + +const transcripts = pickTranscripts(); + +if (transcripts.length === 0) { + throw new Error("no Claude transcripts found under the native session roots"); +} + +const cut = new Date(Date.now() - 60 * 60_000).toISOString(); + +async function sample(checkout: string): Promise<Record<string, number>> { + const proc = Bun.spawn(["bun", "run", CHILD, checkout, cut, ...transcripts], { + cwd: REPO, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (code !== 0) { + throw new Error(`read-backward-child failed (exit ${code}) for ${checkout}: ${stderr.trim().slice(-500)}`); + } + + const metrics: Record<string, number> = SafeJSON.parse(stdout.trim().split("\n").pop() ?? "{}", { strict: true }); + return metrics; +} + +const runs: Array<Record<string, number>> = []; + +for (let run = 0; run < args.warmup + args.runs; run++) { + const merged: Record<string, number> = {}; + + for (const arm of run % 2 === 0 ? arms : [...arms].reverse()) { + for (const [key, value] of Object.entries(await sample(arm.checkout))) { + merged[`${arm.name}.${key}`] = value; + } + } + + if (run >= args.warmup) { + runs.push(merged); + } +} + +for (const run of runs) { + for (const key of Object.keys(run)) { + const [, shape, field] = key.split("."); + + if (before && key.startsWith("current.") && (field === "entries" || field === "found")) { + const other = run[`before.${shape}.${field}`]; + + if (other !== run[key]) { + throw new Error(`check failed: ${key} = ${run[key]} but before.${shape}.${field} = ${other}`); + } + } + } +} + +await finishBench({ + name: BASELINE_NAME, + title: "readSessionBackward on real Claude transcripts (CPU ms, median)", + args, + runs, + lowerIsBetter: Object.keys(runs[0] ?? {}).filter((key) => key.endsWith("Ms")), + floor: Object.fromEntries(Object.keys(runs[0] ?? {}).map((key) => [key, key.endsWith("Ms") ? 10 : 0])), + notes: `${transcripts.length} live Claude transcripts, read-only; cut ${cut}${before ? `; before = ${before}` : ""}`, + context: { transcripts, cut, arms }, +}); diff --git a/scripts/benchmarks/baselines/agent-sessions-read-backward.json b/scripts/benchmarks/baselines/agent-sessions-read-backward.json new file mode 100644 index 0000000000..5a1e1d63f0 --- /dev/null +++ b/scripts/benchmarks/baselines/agent-sessions-read-backward.json @@ -0,0 +1,22 @@ +{ + "name": "agent-sessions-read-backward", + "capturedAt": "2026-10-25T21:05:56.353Z", + "commit": "7023872e3c", + "loadAvg": [ + 15.1826171875, + 24.6220703125, + 26.3828125 + ], + "hostname": "MacBook-Pro-2262.local", + "metrics": { + "current.hook.cold.cpuMs": 18.535, + "current.hook.entries": 6, + "current.hook.found": 5, + "current.hook.warm.cpuMs": 4.591, + "current.full.cold.cpuMs": 200.86, + "current.full.entries": 11418, + "current.full.found": 0, + "current.full.warm.cpuMs": 189.931 + }, + "notes": "5 live Claude transcripts, read-only; cut 2026-10-25T20:05:50.102Z | commit 7023872e3c | uptime 22:05 up 6 days, 12:59, 36 users, load averages: 15.18 24.62 26.38" +} diff --git a/scripts/ci/check-tool-commands.test.ts b/scripts/ci/check-tool-commands.test.ts index 54df1f9e0a..3d856983fa 100644 --- a/scripts/ci/check-tool-commands.test.ts +++ b/scripts/ci/check-tool-commands.test.ts @@ -147,6 +147,21 @@ describe("checkTree", () => { expect(errors[0]).toContain("src/other/index.ts:2"); }); + // `tools agents hooks mods enable|disable` registers both words in one loop. + test("accepts the words of a const that is a choice of two literals, and still rejects others", () => { + const root = tree({ + tools: "const TOOL_ALIASES = new Map([]);", + "src/foo/index.ts": + 'for (const on of [true, false]) {\n const verb = on ? "enable" : "disable";\n program.command(verb);\n}', + "src/other/index.ts": 'toolCommand("foo enable");\ntoolCommand("foo disable");\ntoolCommand("foo toggle");', + }); + + const errors = checkTree(root, ["src/foo/index.ts", "src/other/index.ts"]); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("src/other/index.ts:3"); + }); + test("accepts a command registered in a @genesiscz/utils module the tool imports", () => { const root = tree({ tools: "const TOOL_ALIASES = new Map([]);", diff --git a/scripts/ci/check-tool-commands.ts b/scripts/ci/check-tool-commands.ts index 4a0112b112..b6d6686589 100644 --- a/scripts/ci/check-tool-commands.ts +++ b/scripts/ci/check-tool-commands.ts @@ -330,10 +330,32 @@ function propertyKey(name: ts.PropertyName): string | undefined { return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined; } +/** The string literals a value can be: a literal, or both arms of a `cond ? "a" : "b"` of literals. */ +function literalValues(node: ts.Expression | undefined): string[] { + const value = stringValue(node); + + if (value !== undefined) { + return [value]; + } + + const inner = node ? unwrap(node) : undefined; + + if (inner && ts.isConditionalExpression(inner)) { + const arms = [stringValue(inner.whenTrue), stringValue(inner.whenFalse)]; + + return arms.every((arm) => arm !== undefined) ? (arms as string[]) : []; + } + + return []; +} + /** Records the command words a source file registers; returns the modules it imports, static and dynamic. */ function scanSourceFile(path: string, into: Registrations): string[] { const { source } = parseSource(path, readFileSync(path, "utf8")); const modules: string[] = []; + // `const verb = on ? "enable" : "disable"; mods.command(verb)` registers both words. A name whose value is + // not a literal stays unknown, as before. + const constants = new Map<string, string[]>(); const add = (declared: string | undefined): void => { const word = declared?.split(/\s+/)[0]; @@ -348,6 +370,14 @@ function scanSourceFile(path: string, into: Registrations): string[] { }; walk(source, (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const values = literalValues(node.initializer); + + if (values.length > 0) { + constants.set(node.name.text, values); + } + } + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { const specifier = stringValue(node.moduleSpecifier); @@ -373,7 +403,11 @@ function scanSourceFile(path: string, into: Registrations): string[] { const [first] = node.arguments; if (name && REGISTERING_CALLS.has(name)) { - add(stringValue(first)); + const named = first && ts.isIdentifier(first) ? constants.get(first.text) : undefined; + + for (const value of named ?? [stringValue(first)]) { + add(value); + } } else if (name === "aliases" && first && ts.isArrayLiteralExpression(first)) { for (const element of first.elements) { add(stringValue(element)); diff --git a/scripts/ci/harness-ratchet.ts b/scripts/ci/harness-ratchet.ts new file mode 100644 index 0000000000..7202db35ce --- /dev/null +++ b/scripts/ci/harness-ratchet.ts @@ -0,0 +1,93 @@ +/* + * The job that empties this list: handoff h_2ehgo4gz, "lint-ratchets-migration" (genesis-tools MCP handoff_get + * { name: "lint-ratchets-migration" }). Remove a file here in the same commit that migrates it. + */ +/** + * The files that still branch on a harness name, read a hook payload field themselves, or find a running agent by its + * command line, counted 2026-10-25 when lint-rules `no-harness-name-branch`, `no-hook-payload-read` and + * `no-agent-name-in-command-match` were added (❓ DECISION 22 b, a Haiku survey, + * HarnessBranching, in the notes of 2026-10-25). Ratchets: no file may join a list, and + * `lint-rules.test.ts` fails when a listed file no longer offends. A harness difference belongs in its provider + * (`ProviderPlugin`, `NativeSessionReader`), a payload field in `src/agents/lib/hooks/payload.ts`, and process + * matching in `src/utils/process/classify.ts`. + */ +export const HARNESS_BRANCH_RATCHET: ReadonlySet<string> = new Set<string>([ + "scripts/history/schema-benchmark.ts", + "src/agents/commands/hooks.ts", + "src/agents/lib/changes/blame.ts", + "src/agents/lib/hooks/diff-log.ts", + "src/agents/lib/hooks/diff/capture.ts", + "src/agents/lib/hooks/diff/run.ts", + "src/agents/lib/hooks/guard.ts", + "src/ai-spend/lib/monitor.ts", + "src/ai-spend/lib/reports/commands.ts", + "src/ai-spend/lib/reports/load.ts", + "src/ai-spend/lib/reports/native.ts", + "src/ai-spend/lib/reports/period.ts", + "src/ai-spend/lib/reports/reviews.ts", + "src/ai-spend/lib/reports/session.ts", + "src/ai-spend/lib/series.ts", + "src/ai-spend/lib/workers/stats.ts", + "src/ai/commands/agent/wait.ts", + "src/ai/lib/agent-message/delivery.ts", + "src/ai/lib/sessions/agent-session-rows.ts", + "src/ai/lib/sessions/resolve-transcript.ts", + "src/ai/lib/sessions/rows-cache.ts", + "src/claude/commands/cmux/pins.ts", + "src/claude/commands/resume.ts", + "src/claude/lib/history/search.ts", + "src/cmux/lib/render-resume.ts", + "src/cmux/lib/restore-in-place.ts", + "src/cmux/lib/session-agents.ts", + "src/cmux/lib/snapshot.ts", + "src/cmux/lib/tab-process.ts", + "src/cmux/lib/title-match.ts", + "src/cmux/lib/tty-capture.ts", + "src/codex/lib/message-delivery.ts", + "src/codex/lib/review/import.ts", + "src/dev-dashboard/lib/tmux/claude-pane-title.ts", + "src/genesis-tools-mcp/lib/http-caller.ts", + "src/git/commands/worktree.ts", + "src/handoff/executor.ts", + "src/hub/lib/agents/index.ts", + "src/hub/lib/composer/dispatch.ts", + "src/hub/lib/insights/index.ts", + "src/hub/lib/widget/context.ts", + "src/hub/lib/widget/snapshot.ts", + "src/learn-from-fable/lib/runners/index.ts", + "src/mcp-manager/commands/list.ts", + "src/mcp-manager/commands/rename.ts", + "src/question/lib/decisions/deliver.ts", + "src/question/lib/decisions/read.ts", + "src/question/lib/decisions/store.ts", + "src/utils/ai/openai/native-session-import.ts", + "src/utils/ai/transcripts/around.ts", + "src/utils/ai/transcripts/live.ts", + "src/utils/ai/transcripts/load.ts", + "src/utils/ai/transcripts/native-scan.ts", + "src/utils/ai/transcripts/render/compact.ts", + "src/utils/ai/transcripts/resolve.ts", + "src/utils/ai/transcripts/session-title.ts", + "src/utils/ai/transcripts/subagents.ts", + "src/utils/ai/transcripts/turn-index.ts", + "src/utils/cli/failures/caller.ts", + "src/utils/worker/contract.ts", +]); + +export const HOOK_PAYLOAD_READ_RATCHET: ReadonlySet<string> = new Set<string>([ + "src/agents/lib/hooks/decisions.ts", + "src/agents/lib/hooks/diff/capture.ts", + "src/agents/lib/hooks/diff/run.ts", + "src/agents/lib/hooks/guard.ts", + "src/ai-spend/lib/reports/statusline.ts", + "src/utils/ai/providers/plugins/anthropic-sub/statusline.ts", +]); + +export const AGENT_PROCESS_MATCH_RATCHET: ReadonlySet<string> = new Set<string>([ + "scripts/benchmarks/process/call-sites.ts", + "src/claude/lib/teams/status.ts", + "src/cmux/lib/tab-process.ts", + "src/codex/lib/spawn.ts", + "src/question/lib/decisions/deliver.ts", + "src/utils/cmux/tmux-host.ts", +]); diff --git a/scripts/ci/lint-rules.test.ts b/scripts/ci/lint-rules.test.ts index 4b9061ac7c..1d2db53a93 100644 --- a/scripts/ci/lint-rules.test.ts +++ b/scripts/ci/lint-rules.test.ts @@ -5,8 +5,18 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { Lang, parse } from "@ast-grep/napi"; import { keptExecutable } from "@genesiscz/utils/test/kept-executable"; -import { BARE_REQUIRE_RATCHET, checkSource, namesToolCommand, pluginsDisabledFor } from "./lint-rules"; +import { AGENT_PROCESS_MATCH_RATCHET, HARNESS_BRANCH_RATCHET, HOOK_PAYLOAD_READ_RATCHET } from "./harness-ratchet"; +import { + BARE_REQUIRE_RATCHET, + checkSource, + namesToolCommand, + namesTranscriptFormat, + pluginsDisabledFor, +} from "./lint-rules"; +import { EFFORT_LIST_RATCHET, MODEL_ID_LITERAL_RATCHET, MODEL_PRICE_TABLE_RATCHET } from "./model-facts-ratchet"; +import { NATIVE_HOME_CLAUDE_RATCHET, NATIVE_HOME_CODEX_GROK_RATCHET } from "./native-home-ratchet"; import { TOOL_COMMAND_RATCHET } from "./tool-command-ratchet"; +import { TRANSCRIPT_FORMAT_RATCHET } from "./transcript-format-ratchet"; /** * These pin the behaviour of the six GritQL plugins this checker replaced. @@ -484,3 +494,124 @@ describe("no-bare-require-in-utils", () => { expect(stale).toEqual([]); }); }); + +describe("no-transcript-format-outside-readers", () => { + const rule = "no-transcript-format-outside-readers"; + + test("flags a record type or a raw-line probe outside the readers, and passes the readers and other chat formats", () => { + expect(rules('if (row.type === "continued-in") {}', "src/claude/lib/x.ts")).toContain(rule); + expect(rules('const at = text.indexOf(\'"type":"turn_context"\');', "src/hub/lib/x.ts")).toContain(rule); + expect(rules('if (line.includes(\'"type":"user"\')) {}', "src/agents/lib/hooks/x.ts")).toContain(rule); + expect( + rules('if (row.type === "continued-in") {}', "src/utils/agent-sessions/readers/claude.ts") + ).not.toContain(rule); + // An LLM chat message is not a transcript record, and a bare role names nothing provider-specific. + expect(rules('const m = \'{"role":"user","content":"hi"}\';', "src/jev/lib/x.ts")).not.toContain(rule); + expect(rules('const role = "assistant";', "src/ask/lib/x.ts")).not.toContain(rule); + }); + + test("every ratchet entry still names the transcript format, so a migrated file leaves the list", async () => { + const stale: string[] = []; + + for (const file of TRANSCRIPT_FORMAT_RATCHET) { + const root = parse(file.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript, await Bun.file(file).text()).root(); + const literals = root.findAll({ rule: { any: [{ kind: "string" }, { kind: "template_string" }] } }); + + if (!literals.some((node) => namesTranscriptFormat(node.text()))) { + stale.push(file); + } + } + + expect(stale).toEqual([]); + }); +}); + +describe("the agent and model ratchet rules (❓ DECISION 22 b)", () => { + test("each rule flags its shape outside the owners and passes the owner and a look-alike", () => { + const cases: { rule: string; hit: [string, string]; owner?: [string, string]; miss: [string, string] }[] = [ + { + rule: "no-hand-built-claude-home", + hit: ['const p = join(homedir(), ".claude", "projects");', "src/hub/lib/x.ts"], + owner: ['const p = join(homedir(), ".claude");', "src/utils/providers/session-paths.ts"], + miss: ['const p = join(repoRoot, ".claude", "rules");', "src/hub/lib/x.ts"], + }, + { + rule: "no-hand-built-codex-grok-home", + hit: ['const p = join(homedir(), ".codex");', "src/hub/lib/x.ts"], + owner: ['const p = join(homedir(), ".codex");', "src/utils/providers/session-paths.ts"], + miss: ['const p = join(repoRoot, ".codex-review");', "src/hub/lib/x.ts"], + }, + { + rule: "no-model-id-literal-outside-catalog", + hit: ['export const DEFAULT_MODEL = "claude-opus-5";', "src/ai/lib/x.ts"], + owner: ['const id = "claude-opus-5-5";', "src/utils/ai/catalog/static.ts"], + miss: ['const hint = "Try: opus, claude-sonnet-5-5, haiku";', "src/ai/lib/x.ts"], + }, + { + rule: "no-model-price-table", + hit: ["const P = { opus: { input: 15, output: 75 } };", "src/ai-spend/lib/x.ts"], + miss: ["const P = { opus: { ...base } };", "src/ai-spend/lib/x.ts"], + }, + { + rule: "no-effort-vocabulary-list", + hit: ['export const E = ["low", "medium", "high", "xhigh"] as const;', "src/utils/worker/x.ts"], + owner: ['export const E = ["low", "high", "xhigh"];', "src/utils/ai/catalog/efforts.ts"], + miss: ['const args = ["--effort", "xhigh", "--yes"];', "src/claude/lib/x.ts"], + }, + { + rule: "no-harness-name-branch", + hit: ['if (node.harness === "claude") {}', "src/hub/lib/x.ts"], + owner: ['if (kind === "codex") {}', "src/utils/agent-sessions/x.ts"], + miss: ['if (theme.name === "claude") {}', "src/hub/lib/x.ts"], + }, + { + rule: "no-hook-payload-read", + hit: ["const t = payload.raw.transcript_path;", "src/agents/lib/hooks/x.ts"], + owner: ["const t = raw.transcript_path;", "src/agents/lib/hooks/payload.ts"], + miss: ['const fixture = { transcript_path: "/x.jsonl" };', "src/agents/lib/hooks/x.ts"], + }, + { + rule: "no-agent-name-in-command-match", + hit: ["const live = /claude/i.test(process.command);", "src/question/lib/x.ts"], + owner: ["const live = /claude/i.test(process.command);", "src/utils/process/classify.ts"], + miss: ['const t = text.includes("claude");', "src/timely/x.ts"], + }, + ]; + + for (const { rule, hit, owner, miss } of cases) { + expect({ rule, found: rules(...hit).includes(rule) }).toEqual({ rule, found: true }); + expect({ rule, miss: rules(...miss).includes(rule) }).toEqual({ rule, miss: false }); + + if (owner) { + expect({ rule, owner: rules(...owner).includes(rule) }).toEqual({ rule, owner: false }); + } + } + }); + + test("every file on a ratchet list still offends its rule, so a migrated file leaves the list", async () => { + const lists: [string, ReadonlySet<string>][] = [ + ["no-transcript-format-outside-readers", TRANSCRIPT_FORMAT_RATCHET], + ["no-hand-built-claude-home", NATIVE_HOME_CLAUDE_RATCHET], + ["no-hand-built-codex-grok-home", NATIVE_HOME_CODEX_GROK_RATCHET], + ["no-model-id-literal-outside-catalog", MODEL_ID_LITERAL_RATCHET], + ["no-model-price-table", MODEL_PRICE_TABLE_RATCHET], + ["no-effort-vocabulary-list", EFFORT_LIST_RATCHET], + ["no-harness-name-branch", HARNESS_BRANCH_RATCHET], + ["no-hook-payload-read", HOOK_PAYLOAD_READ_RATCHET], + ["no-agent-name-in-command-match", AGENT_PROCESS_MATCH_RATCHET], + ]; + const stale: string[] = []; + + for (const [rule, files] of lists) { + for (const file of files) { + const found = checkSource(file, await Bun.file(file).text(), { ratchets: false }); + + if (!found.some((finding) => finding.rule === rule)) { + stale.push(`${rule}: ${file}`); + } + } + } + + expect(stale).toEqual([]); + }); +}); diff --git a/scripts/ci/lint-rules.ts b/scripts/ci/lint-rules.ts index 1ec190da82..8e62d6a736 100644 --- a/scripts/ci/lint-rules.ts +++ b/scripts/ci/lint-rules.ts @@ -35,7 +35,11 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import { findInFiles, Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi"; import { SafeJSON } from "@genesiscz/utils/json"; +import { AGENT_PROCESS_MATCH_RATCHET, HARNESS_BRANCH_RATCHET, HOOK_PAYLOAD_READ_RATCHET } from "./harness-ratchet"; +import { EFFORT_LIST_RATCHET, MODEL_ID_LITERAL_RATCHET, MODEL_PRICE_TABLE_RATCHET } from "./model-facts-ratchet"; +import { NATIVE_HOME_CLAUDE_RATCHET, NATIVE_HOME_CODEX_GROK_RATCHET } from "./native-home-ratchet"; import { TOOL_COMMAND_RATCHET } from "./tool-command-ratchet"; +import { TRANSCRIPT_FORMAT_RATCHET } from "./transcript-format-ratchet"; export type Severity = "error" | "warn"; @@ -70,8 +74,10 @@ interface Rule { allowedFiles?: ReadonlySet<string>; } -function ruleApplies(rule: Rule, file: string): boolean { - return (rule.onlyIn?.test(file) ?? true) && !rule.allowedIn?.test(file) && !rule.allowedFiles?.has(file); +function ruleApplies(rule: Rule, file: string, ratchets = true): boolean { + return ( + (rule.onlyIn?.test(file) ?? true) && !rule.allowedIn?.test(file) && !(ratchets && rule.allowedFiles?.has(file)) + ); } /** @@ -176,6 +182,120 @@ export const BARE_REQUIRE_RATCHET: ReadonlySet<string> = new Set<string>([ "src/utils/tokens.ts", ]); +// ─── The ratchet rules of 2026-10-25 (❓ DECISION 22 b) ──────────────────────────────────────────────────────────────── +// no-transcript-format-outside-readers, no-hand-built-claude-home, no-hand-built-codex-grok-home, +// no-model-id-literal-outside-catalog, no-model-price-table, no-effort-vocabulary-list, no-harness-name-branch, +// no-hook-payload-read, no-agent-name-in-command-match. Their lists of files that still break them live in +// scripts/ci/{transcript-format,native-home,model-facts,harness}-ratchet.ts and may only shrink. +// The job that empties those lists is handoff h_2ehgo4gz, named "lint-ratchets-migration": read it with the +// genesis-tools MCP tool handoff_get { name: "lint-ratchets-migration" }. It says, rule by rule, which owner each +// violation moves into, the commit and verification rules, and where the report goes. +/** + * A literal that names a coding agent's transcript format: a Claude, Codex or Grok record type or field, or a raw-line + * probe such as `'"type":"user"'`. That knowledge belongs to the provider readers (.claude/rules/agent-transcripts.md). + */ +const TRANSCRIPT_FORMAT = + /^["'`](?:continued-in|continuedInSessionId|response_item|event_msg|session_meta|turn_context|compact_boundary|isCompactSummary|isSidechain|chat_history\.jsonl)["'`]$|\\?"(?:type|subtype)\\?"\s*:\s*\\?"(?:user|assistant|continued-in|response_item|event_msg|session_meta|turn_context|compact_boundary)\\?"/; +/** The readers own the format; the Claude SDK message types describe it; plugins are standalone; benchmarks replay it. */ +const TRANSCRIPT_FORMAT_ALLOWED = + /^(?:src\/utils\/agent-sessions\/|src\/utils\/claude\/types\.ts$|plugins\/|scripts\/benchmarks\/)/; + +export function namesTranscriptFormat(text: string): boolean { + return TRANSCRIPT_FORMAT.test(text); +} + +/** `~/.claude…`, `.claude/projects|sessions|settings.json|…`, `.claude.json`: a path into Claude's own home. */ +const CLAUDE_HOME_LITERAL = + /^["'`](?:~[/\\]\.claude(?:\.json|[/\\]|["'`])|(?:[^\s"'`]*[/\\])?\.claude(?:\.json["'`]|[/\\](?:projects|sessions|settings\.json|statusline|helpers|teams|\.credentials\.json)(?:[/\\"'`]|$)))/; +const CLAUDE_CONFIG_DIR_LITERAL = /^["'`]CLAUDE_CONFIG_DIR["'`]$/; +const BARE_CLAUDE_SEGMENT = /^["'`](?:[^\s"'`]*[/\\])?\.claude["'`]$/; +const CODEX_GROK_HOME_LITERAL = + /^["'`](?:~[/\\]\.(?:codex|grok)[/\\"'`]|(?:[^\s"'`]*[/\\])?\.(?:codex|grok)[/\\](?:sessions|archived_sessions|config\.toml|auth\.json)(?:[/\\"'`]|$))/; +const BARE_CODEX_GROK_SEGMENT = /^["'`](?:[^\s"'`]*[/\\])?\.(?:codex|grok)["'`]$/; +const CODEX_GROK_ENV_LITERAL = /^["'`](?:CODEX_HOME|GROK_HOME)["'`]$/; +/** A call argument that is the user's home: a bare `.claude` segment joined to it is the agent's home. */ +const HOME_ARGUMENT = /homedir\(\)|getHome\(\)|\bhome(?:Dir)?\b|\.HOME\b/; +const NATIVE_HOME_TESTS = + /(?:^|\/)__tests__\/|^src\/utils\/agent-sessions\/testing\/|^plugins\/|\.test\.tsx?$|test-utils\.ts$/; + +/** Whether the nearest enclosing call (five levels up at most) also takes the user's home as an argument. */ +function homeArgumentOfNearestCall(node: SgNode): boolean { + let up = node.parent(); + + for (let depth = 0; up && depth < 5; depth++) { + if (up.kind() === "call_expression") { + return HOME_ARGUMENT.test(up.field("arguments")?.text() ?? ""); + } + + up = up.parent(); + } + + return false; +} + +export function buildsClaudeHome(text: string, node: SgNode): boolean { + return ( + CLAUDE_HOME_LITERAL.test(text) || + CLAUDE_CONFIG_DIR_LITERAL.test(text) || + (BARE_CLAUDE_SEGMENT.test(text) && homeArgumentOfNearestCall(node)) + ); +} + +export function buildsCodexGrokHome(text: string, node: SgNode): boolean { + return ( + CODEX_GROK_HOME_LITERAL.test(text) || + CODEX_GROK_ENV_LITERAL.test(text) || + (BARE_CODEX_GROK_SEGMENT.test(text) && homeArgumentOfNearestCall(node)) + ); +} + +/** A whole literal that is a model id: `claude-opus-5-5`, `anthropic/claude-sonnet-5.5`, `gpt-6-sol:xhigh`, `grok-4.7`, `o3`. */ +const MODEL_ID_LITERAL = + /^(["'`])(?:(?:[\w.@-]+\/)*(?:claude-(?:opus|sonnet|haiku|fable)-[\w.-]+|gpt-(?!3-encoder)\d[\w.-]*|grok-(?:code-)?\d[\w.-]*|o[134])(?:\[1m\])?(?::(?:minimal|low|medium|high|xhigh|max))?)\1$/; +/** The catalog and the lists models.md names as owners; tests and fixtures carry ids on purpose. */ +const MODEL_FACT_OWNERS = + /(^|\/)(src\/utils\/ai\/catalog\/|src\/utils\/ai\/anthropic\/|src\/utils\/ai\/openai\/sub-models|src\/utils\/ai\/grok\/models|src\/ai-proxy\/lib\/(billing|translators)\/|src\/ai-proxy\/lib\/reasoning-effort-vocab|src\/claude\/lib\/models|scripts\/ai\/)|^plugins\/|\.test\.tsx?$|(^|\/)(testing|fixtures?|__fixtures__)\//; +/** `opus: { input: 15, …`: a price row keyed by a Claude family. */ +const FAMILY_PRICE_ENTRY = /^["']?(?:opus|sonnet|haiku|fable)["']?\s*:\s*\{\s*input\s*:\s*\d/; +const EFFORT = "(?:minimal|none|low|medium|high|xhigh|max|ultra)"; +/** An array or union made only of three or more effort words, one of them `xhigh`. */ +const EFFORT_LIST = new RegExp( + `^(?:\\[(?=[^\\]]*xhigh)\\s*(?:(["'])${EFFORT}\\1\\s*,?\\s*){3,}\\]|(?=[\\s\\S]*xhigh)(?:(["'])${EFFORT}\\2\\s*\\|\\s*){2,}(["'])${EFFORT}\\3)$` +); + +/** A literal passed to `toolCommand(...)`: a usage example in help text, not a model choice. */ +function isToolCommandArgument(node: SgNode): boolean { + const args = node.parent(); + + return args?.kind() === "arguments" && args.parent()?.field("function")?.text() === "toolCommand"; +} + +export function namesModelId(text: string, node: SgNode): boolean { + return MODEL_ID_LITERAL.test(text) && !isToolCommandArgument(node); +} + +export function isFamilyPriceEntry(text: string): boolean { + return FAMILY_PRICE_ENTRY.test(text); +} + +export function isEffortList(text: string): boolean { + return EFFORT_LIST.test(text); +} + +/** Harness branching, hook payload reads and agent process matching (Haiku survey, HarnessBranching.haiku.md). */ +const AGENT_HARNESS_LITERAL = /^(["'`])(?:claude|codex|grok)\1$/; +const AGENT_HARNESS_WORD = /claude|codex|grok/i; +const AGENT_COMPARE_OPS = new Set(["===", "!==", "==", "!="]); +const AGENT_OPERAND = /harness|provider|alias|kind|agent|backend|source|resolved|target|basename|argv0|^base$|\.base$/i; +const AGENT_SUBJECT = /command|cmd|argv|\bargs\b|\bcomm\b|process|\bproc\b/i; +const AGENT_SESSION_OWNERS = + /(?:^|\/)src\/(?:utils\/process\/|utils\/claude\/peer-message\.ts$|codex\/lib\/process-scan\.ts$|grok\/lib\/process-scan\.ts$|claude\/lib\/active-sessions\.ts$)/; +const AGENT_HARNESS_OWNERS = + /(?:^|\/)src\/(?:utils\/agent-sessions\/|utils\/ai\/providers\/|utils\/providers\/session-paths\.ts$|agents\/lib\/hooks\/(?:payload|config)\.ts$|utils\/process\/|utils\/claude\/peer-message\.ts$|codex\/lib\/process-scan\.ts$|grok\/lib\/process-scan\.ts$|claude\/lib\/active-sessions\.ts$)/; +const AGENT_PAYLOAD_READ_SRC = + "(?:\\.(?:transcript_path|hook_event_name|tool_input|tool_response|last_assistant_message|stop_hook_active)|(?:^|\\.)raw\\.(?:hookEventName|toolInput|toolResponse|transcriptPath|lastAssistantMessage|stopHookActive))$"; +const AGENT_PAYLOAD_READ = new RegExp(AGENT_PAYLOAD_READ_SRC); + const RULES: Rule[] = [ { ...STORAGE_ROOT_RULE, @@ -256,6 +376,173 @@ const RULES: Rule[] = [ allowedIn: /\.test\.tsx?$|(?:^|\/)src\/utils\/test\//, allowedFiles: BARE_REQUIRE_RATCHET, }, + { + name: "no-transcript-format-outside-readers", + severity: "error", + kinds: LITERAL_KINDS, + message: + "A transcript record type or field (Claude, Codex or Grok) outside the provider readers. Read the fact " + + "through a NativeSessionReader member (src/utils/agent-sessions/types.ts), and add a required member for " + + "all three readers when none exists (.claude/rules/agent-transcripts.md). " + + "scripts/ci/transcript-format-ratchet.ts lists the files not migrated yet.", + test: (text) => namesTranscriptFormat(text), + allowedIn: TRANSCRIPT_FORMAT_ALLOWED, + allowedFiles: TRANSCRIPT_FORMAT_RATCHET, + }, + { + name: "no-hand-built-claude-home", + severity: "error", + kinds: LITERAL_KINDS, + message: + "A path built by hand into Claude's home (~/.claude, CLAUDE_CONFIG_DIR, ~/.claude.json, the sessions " + + "registry). Use nativeHome('claude') or nativeSessionRoots('claude') from " + + "@genesiscz/utils/providers/session-paths (they honour CLAUDE_CONFIG_DIR), claudeSessionsDir() from " + + "@genesiscz/utils/claude/peer-message, or getClaudeJsonAccount() from @genesiscz/utils/claude/auth. " + + "scripts/ci/native-home-ratchet.ts lists the files not migrated yet.", + test: buildsClaudeHome, + allowedIn: new RegExp( + `(?:^|/)(?:src/utils/claude/(?:projects|auth|executable|peer-message)\\.ts|src/utils/providers/session-paths\\.ts|src/utils/env/[^/]+\\.ts|src/agents/lib/hooks/(?:install|mods)\\.ts|src/mcp-manager/utils/harnesses\\.ts|src/ai-spend/lib/drivers/test-env\\.ts)$|${NATIVE_HOME_TESTS.source}` + ), + allowedFiles: NATIVE_HOME_CLAUDE_RATCHET, + }, + { + name: "no-hand-built-codex-grok-home", + severity: "error", + kinds: LITERAL_KINDS, + message: + "A path built by hand into the Codex or Grok home (~/.codex, ~/.grok, CODEX_HOME, GROK_HOME, their " + + "sessions). Use nativeHome('codex' | 'grok') or nativeSessionRoots(kind) from " + + "@genesiscz/utils/providers/session-paths (they honour the overrides and CODEX_HOME's comma list). " + + "scripts/ci/native-home-ratchet.ts lists the files not migrated yet.", + test: buildsCodexGrokHome, + allowedIn: new RegExp( + `(?:^|/)(?:src/utils/providers/session-paths\\.ts|src/utils/env/[^/]+\\.ts|src/utils/grok/worker-paths\\.ts|src/utils/codex/worker-paths\\.ts|src/utils/agent-sessions/grok-sessions\\.ts|src/utils/agent-sessions/readers/[^/]+\\.ts|src/utils/ai/grok/paths\\.ts|src/agents/lib/hooks/codex\\.ts|src/mcp-manager/utils/harnesses\\.ts|src/ai-spend/lib/drivers/test-env\\.ts)$|${NATIVE_HOME_TESTS.source}` + ), + allowedFiles: NATIVE_HOME_CODEX_GROK_RATCHET, + }, + { + name: "no-model-id-literal-outside-catalog", + severity: "error", + kinds: LITERAL_KINDS, + message: + "A model id written into code goes stale at the next release. Take it from the catalog: byId(id, provider) " + + "from @genesiscz/utils/ai/catalog, or a family alias through resolveAnthropicSubModel() " + + "(.claude/rules/models.md). scripts/ci/model-facts-ratchet.ts lists the files not migrated yet.", + test: namesModelId, + allowedIn: MODEL_FACT_OWNERS, + allowedFiles: MODEL_ID_LITERAL_RATCHET, + }, + { + name: "no-model-price-table", + severity: "error", + kinds: ["pair"], + matcher: { rule: { kind: "pair" } }, + message: + "A price per model is a catalog fact, not a table here. Read it with effectivePricing() or pricingFor() " + + "from @genesiscz/utils/ai/catalog (.claude/rules/models.md). scripts/ci/model-facts-ratchet.ts lists the " + + "files not migrated yet.", + test: (text) => isFamilyPriceEntry(text), + allowedIn: /(^|\/)(src\/utils\/ai\/catalog\/|src\/ai-proxy\/lib\/billing\/)|\.test\.tsx?$/, + allowedFiles: MODEL_PRICE_TABLE_RATCHET, + }, + { + name: "no-effort-vocabulary-list", + severity: "error", + kinds: ["array", "union_type"], + matcher: { rule: { any: [{ kind: "array" }, { kind: "union_type" }] } }, + message: + "A list of reasoning-effort values is a model fact. Use REASONING_EFFORTS or effortRange(from, to) from " + + "@genesiscz/utils/ai/catalog/efforts and filter it per provider. scripts/ci/model-facts-ratchet.ts lists " + + "the files not migrated yet.", + test: (text) => isEffortList(text), + // `union_type` exists only in the TypeScript grammars; the JavaScript scan would refuse the matcher. + onlyIn: /\.tsx?$/, + allowedIn: + /(^|\/)(src\/utils\/ai\/catalog\/|src\/utils\/ai\/anthropic\/models|src\/ai-proxy\/lib\/reasoning-effort-vocab)|\.test\.tsx?$/, + allowedFiles: EFFORT_LIST_RATCHET, + }, + { + name: "no-harness-name-branch", + severity: "error", + kinds: LITERAL_KINDS, + message: "PROBE harness branch", + onlyIn: /^(?:src|scripts)\//, + allowedIn: AGENT_HARNESS_OWNERS, + allowedFiles: HARNESS_BRANCH_RATCHET, + test: (text, node) => { + if (!AGENT_HARNESS_LITERAL.test(text)) { + return false; + } + + const parent = node.parent(); + + if (parent?.kind() === "switch_case") { + return AGENT_OPERAND.test(parent.parent()?.parent()?.field("value")?.text() ?? ""); + } + + if ( + parent?.kind() !== "binary_expression" || + !AGENT_COMPARE_OPS.has(parent.field("operator")?.text() ?? "") + ) { + return false; + } + + const other = parent.field("right")?.text() === text ? parent.field("left") : parent.field("right"); + + return AGENT_OPERAND.test(other?.text() ?? ""); + }, + }, + { + name: "no-hook-payload-read", + severity: "error", + kinds: ["member_expression"], + matcher: { rule: { kind: "member_expression", regex: AGENT_PAYLOAD_READ_SRC } }, + message: "PROBE payload read", + onlyIn: /^(?:src|scripts)\//, + allowedIn: /(?:^|\/)src\/agents\/lib\/hooks\/payload\.ts$/, + allowedFiles: HOOK_PAYLOAD_READ_RATCHET, + test: (text) => AGENT_PAYLOAD_READ.test(text), + }, + { + name: "no-agent-name-in-command-match", + severity: "error", + kinds: ["call_expression"], + matcher: { + rule: { + kind: "call_expression", + has: { field: "function", regex: "\\.(?:test|match|search|includes|startsWith|endsWith)$" }, + }, + }, + message: "PROBE process match", + onlyIn: /^(?:src|scripts)\//, + allowedIn: AGENT_SESSION_OWNERS, + allowedFiles: AGENT_PROCESS_MATCH_RATCHET, + test: (_text, node) => { + const fn = node.field("function"); + + if (fn?.kind() !== "member_expression") { + return false; + } + + const method = fn.field("property")?.text() ?? ""; + const object = fn.field("object"); + const args = (node.field("arguments")?.children() ?? []).filter((child) => child.isNamed()); + + if (method === "test" || method === "match" || method === "search") { + if (object?.kind() !== "regex" || !AGENT_HARNESS_WORD.test(object.text())) { + return false; + } + + return AGENT_SUBJECT.test(args[0]?.text() ?? ""); + } + + if (!AGENT_HARNESS_LITERAL.test(args[0]?.text() ?? "")) { + return false; + } + + return AGENT_SUBJECT.test(object?.text() ?? ""); + }, + }, ]; const PROMPT_BACKEND_RULE = { @@ -387,6 +674,22 @@ const SWIFT_WAIT_RULE = { }; const SWIFT_BARE_WAIT = /\.wait\(\s*\)/; +const SWIFT_CLICK_COUNT_RULE = { + name: "swift-current-event-click-count", + severity: "error" as const, + message: + "`NSEvent.clickCount` raises an Objective-C exception for any event that is not a mouse click, and " + + "`NSApp.currentEvent` inside a button action can be a key or system event. One raise made a whole Genesis " + + "Markdown window ignore every click (2026-10-23). Use `MouseClick.current` or `MouseClick.count(of:)`, or " + + "`// lint-rules-ignore: <why the event is always a mouse event>`.", +}; +const SWIFT_CURRENT_EVENT_CLICK_COUNT = /currentEvent\s*[?!]?\s*\.clickCount/; + +const SWIFT_LINE_RULES = [ + { rule: SWIFT_WAIT_RULE, pattern: SWIFT_BARE_WAIT }, + { rule: SWIFT_CLICK_COUNT_RULE, pattern: SWIFT_CURRENT_EVENT_CLICK_COUNT }, +]; + /** * Process tools (2026-10-14). The process table, command lines, executables, listening sockets and working * directories come from the kernel through `@genesiscz/utils/process` (libproc on macOS), measured at 9.5 ms where @@ -696,7 +999,7 @@ function recordBusyWaitFindings(root: SgNode, record: RecordFinding): void { recordSleepSyncFindings(root.findAll(SLEEP_SYNC_IN_LOOP_MATCHER), record); } -/** Swift has no grammar in the ast-grep napi package, so its one rule is a line regex. */ +/** Swift has no grammar in the ast-grep napi package, so its rules are line regexes. */ export function checkSwiftSource(file: string, source: string): Finding[] { const lines = source.split("\n"); const findings: Finding[] = []; @@ -706,21 +1009,23 @@ export function checkSwiftSource(file: string, source: string): Finding[] { continue; } - const column = text.search(SWIFT_BARE_WAIT); + for (const { rule, pattern } of SWIFT_LINE_RULES) { + const column = text.search(pattern); - if (column === -1) { - continue; - } + if (column === -1) { + continue; + } - findings.push({ - file, - line: index + 1, - column: column + 1, - rule: SWIFT_WAIT_RULE.name, - severity: SWIFT_WAIT_RULE.severity, - message: SWIFT_WAIT_RULE.message, - text: text.trim().slice(0, 120), - }); + findings.push({ + file, + line: index + 1, + column: column + 1, + rule: rule.name, + severity: rule.severity, + message: rule.message, + text: text.trim().slice(0, 120), + }); + } } return findings; @@ -822,7 +1127,11 @@ export function pluginsDisabledFor(file: string): boolean { return /\.test\.tsx?$/.test(file) || /(^|\/)__tests__\//.test(file) || /\.data\.ts$/.test(file); } -export function checkSource(file: string, source: string): Finding[] { +/** + * `ratchets: false` reports a file a ratchet still lets through: the test that keeps each ratchet list honest asks it + * whether a listed file still offends. + */ +export function checkSource(file: string, source: string, { ratchets = true }: { ratchets?: boolean } = {}): Finding[] { if (file.endsWith(".swift")) { return checkSwiftSource(file, source); } @@ -883,14 +1192,14 @@ export function checkSource(file: string, source: string): Finding[] { const text = node.text(); for (const rule of RULES) { - if (!rule.matcher && ruleApplies(rule, file) && rule.test(text, node)) { + if (!rule.matcher && ruleApplies(rule, file, ratchets) && rule.test(text, node)) { record(node, rule); } } } for (const rule of RULES) { - if (!rule.matcher || !ruleApplies(rule, file)) { + if (!rule.matcher || !ruleApplies(rule, file, ratchets)) { continue; } @@ -1265,7 +1574,7 @@ if (import.meta.main) { const timing = `boot ${boot}ms, list ${listed - started}ms, scan ${scanned - listed}ms`; if (findings.length === 0) { - console.log(`lint-rules: OK (${files.length} files, 14 repo rules) [${timing}]`); + console.log(`lint-rules: OK (${files.length} files, 23 repo rules) [${timing}]`); } else { console.log(`\nlint-rules: ${errors} error(s), ${warnings} warning(s) across ${files.length} files`); } diff --git a/scripts/ci/model-facts-ratchet.ts b/scripts/ci/model-facts-ratchet.ts new file mode 100644 index 0000000000..86a8e855f3 --- /dev/null +++ b/scripts/ci/model-facts-ratchet.ts @@ -0,0 +1,54 @@ +/* + * The job that empties this list: handoff h_2ehgo4gz, "lint-ratchets-migration" (genesis-tools MCP handoff_get + * { name: "lint-ratchets-migration" }). Remove a file here in the same commit that migrates it. + */ +/** + * The files that still write a model fact the catalog owns (.claude/rules/models.md), counted 2026-10-25 when + * lint-rules `no-model-id-literal-outside-catalog`, `no-model-price-table` and `no-effort-vocabulary-list` were added + * (❓ DECISION 22 b, a Haiku survey, ModelFacts, in the notes of 2026-10-25). + * Ratchets: no file may join a list, and `lint-rules.test.ts` fails when a listed file no longer offends. + */ +export const MODEL_ID_LITERAL_RATCHET: ReadonlySet<string> = new Set<string>([ + ".claude/skills/babysit/scripts/review-brief.ts", + "scripts/ai-proxy/structured-output.ts", + "scripts/benchmarks/compress/read-paths.ts", + "scripts/benchmarks/statusline/current-statusline.ts", + "scripts/learn-from-fable/probe-claude-sub-concurrency.ts", + "scripts/learn-from-fable/probe-extractor-latency.ts", + "scripts/learn-from-fable/probe-judge-batch.ts", + "scripts/learn-from-fable/probe-parallel-grok.ts", + "scripts/learn-from-fable/probe-prompt-size-ceiling.ts", + "scripts/learn-from-fable/probe-raw-frames.ts", + "scripts/learn-from-fable/probe-stream-vs-plain.ts", + "scripts/learn-from-fable/probe-tighten-guards.ts", + "scripts/learn-from-fable/tighten-spec.ts", + "src/ai-proxy/lib/model-meta.ts", + "src/ai-proxy/scripts/verify-openrouter-live.ts", + "src/ai-spend/lib/workers/cost.ts", + "src/ai/commands/config/tui.ts", + "src/ai/lib/tokens.ts", + "src/claude/commands/start.ts", + "src/claude/lib/launch-env.ts", + "src/dashboard/apps/web/src/routes/api.ai-chat.ts", + "src/grok/lib/driver.ts", + "src/grok/lib/launcher.ts", + "src/learn-from-fable/lib/config.ts", + "src/telegram/lib/types.ts", + "src/utils/ai/providers/AICloudProvider.ts", + "src/utils/ai/providers/openai/AIOpenAITextToSpeechProvider.ts", + "src/utils/ai/providers/plugins/anthropic-sub/quota-headers.ts", + "src/utils/claude/account-fingerprint.ts", + "src/utils/claude/token-verify.ts", + "src/youtube/lib/config.ts", +]); + +export const MODEL_PRICE_TABLE_RATCHET: ReadonlySet<string> = new Set<string>([]); + +export const EFFORT_LIST_RATCHET: ReadonlySet<string> = new Set<string>([ + "src/ai-proxy/lib/providers/openai-subscription.ts", + "src/ai-proxy/lib/resolve-model.ts", + "src/ai-proxy/lib/types.ts", + "src/utils/agent-harness/llm.ts", + "src/utils/ai/providers/plugins/openrouter.ts", + "src/utils/worker/contract.ts", +]); diff --git a/scripts/ci/native-home-ratchet.ts b/scripts/ci/native-home-ratchet.ts new file mode 100644 index 0000000000..24423c50c9 --- /dev/null +++ b/scripts/ci/native-home-ratchet.ts @@ -0,0 +1,69 @@ +/* + * The job that empties this list: handoff h_2ehgo4gz, "lint-ratchets-migration" (genesis-tools MCP handoff_get + * { name: "lint-ratchets-migration" }). Remove a file here in the same commit that migrates it. + */ +/** + * The files that still build a path into a coding agent's own home by hand, counted 2026-10-25 when lint-rules + * `no-hand-built-claude-home` and `no-hand-built-codex-grok-home` were added (❓ DECISION 22 b, a Haiku survey, + * NativeHomes, in the notes of 2026-10-25). Ratchets: no file may join a list, and a file + * leaves it once it asks `nativeHome(kind)` or `nativeSessionRoots(kind)` (src/utils/providers/session-paths.ts). + * `lint-rules.test.ts` fails when a listed file no longer offends, so the lists cannot go stale. + */ +export const NATIVE_HOME_CLAUDE_RATCHET: ReadonlySet<string> = new Set<string>([ + "scripts/benchmarks/fs/read-child.ts", + "scripts/benchmarks/fs/walk-trees.ts", + "scripts/benchmarks/json/parse-child.ts", + "scripts/benchmarks/statusline/current-statusline.ts", + "src/agents/lib/hooks/corpus.ts", + "src/agents/lib/hooks/diff-log.ts", + "src/agents/lib/hooks/diff/capture.ts", + "src/agents/lib/hooks/diff/theme.ts", + "src/agents/lib/hooks/model.ts", + "src/ai/commands/statusline/index.ts", + "src/claude/commands/resume.ts", + "src/claude/commands/tail.ts", + "src/claude/lib/desktop/index.ts", + "src/claude/lib/keychain-session.ts", + "src/claude/lib/launch-env.ts", + "src/claude/lib/memory.ts", + "src/claude/lib/migrate-to-codex/index.ts", + "src/claude/lib/tail-list.ts", + "src/claude/lib/teams/discover.ts", + "src/claude/lib/worker/store.ts", + "src/genesis-tools-mcp/lib/http-caller.ts", + "src/learn-from-fable/commands/instruct.ts", + "src/learn-from-fable/lib/config.ts", + "src/mcp-manager/lib/doctor/discovery.ts", + "src/mcp-manager/lib/doctor/report.ts", + "src/mcp-manager/lib/doctor/types.ts", + "src/mcp-manager/utils/providers/claude.ts", + "src/mcp-manager/utils/providers/grok.ts", + "src/scripts/lib/claude-tokens.ts", + "src/utils/ai/providers/plugins/anthropic-sub/statusline.ts", + "src/utils/ai/statusline/config.ts", + "src/utils/claude/keychain.ts", + "src/utils/cli/failures/caller.ts", + "src/utils/ts/sourcemaps/sentry-config.ts", +]); + +export const NATIVE_HOME_CODEX_GROK_RATCHET: ReadonlySet<string> = new Set<string>([ + "scripts/benchmarks/fs/read-child.ts", + "scripts/benchmarks/fs/walk-trees.ts", + "src/ai/commands/config/account.ts", + "src/claude/lib/migrate-to-codex/index.ts", + "src/codex/commands/migrate-home.ts", + "src/codex/daemon.ts", + "src/codex/lib/migrate-home.ts", + "src/codex/lib/run-account-terminal.ts", + "src/codex/lib/spawn.ts", + "src/codex/lib/store.ts", + "src/grok/lib/worker.ts", + "src/mcp-manager/utils/providers/codex.ts", + "src/mcp-manager/utils/providers/grok.ts", + "src/utils/ai/openai/codex-auth.ts", + "src/utils/ai/openai/codex-models.ts", + "src/utils/ai/transcripts/resolve.ts", + "src/utils/ai/transcripts/session-title.ts", + "src/utils/cli/failures/caller.ts", + "src/utils/session-changes/codex.ts", +]); diff --git a/scripts/ci/transcript-format-ratchet.ts b/scripts/ci/transcript-format-ratchet.ts new file mode 100644 index 0000000000..ad66d7f886 --- /dev/null +++ b/scripts/ci/transcript-format-ratchet.ts @@ -0,0 +1,28 @@ +/* + * The job that empties this list: handoff h_2ehgo4gz, "lint-ratchets-migration" (genesis-tools MCP handoff_get + * { name: "lint-ratchets-migration" }). Remove a file here in the same commit that migrates it. + */ +/** + * The files outside the provider readers that still name a coding agent's transcript format (a Claude, Codex or Grok + * record type or field), counted 2026-10-25 when lint-rules `no-transcript-format-outside-readers` was added (Martin's + * ❓ DECISION 22 b). A ratchet: no file may join this list, and a file leaves it once the fact it reads is a + * `NativeSessionReader` member (.claude/rules/agent-transcripts.md). `lint-rules.test.ts` fails when a listed file no + * longer names one, so the list cannot go stale. + */ +export const TRANSCRIPT_FORMAT_RATCHET: ReadonlySet<string> = new Set<string>([ + "src/agents/lib/hooks/decisions.ts", + "src/claude/lib/usage/session-rows.ts", + "src/hub/lib/agents/codex-agents.ts", + "src/hub/lib/widget/roster-index.ts", + "src/utils/agents/adapters/claude.ts", + "src/utils/ai/openai/native-session-import.ts", + "src/utils/ai/transcripts/around.ts", + "src/utils/ai/transcripts/codex.ts", + "src/utils/ai/transcripts/native-scan.ts", + "src/utils/ai/transcripts/subagents.ts", + "src/utils/ai/transcripts/turn-state.ts", + "src/utils/ai/usage/transcripts/codex-context.ts", + "src/utils/ai/usage/transcripts/codex.ts", + "src/utils/cli/failures/caller.ts", + "src/utils/session-changes/codex.ts", +]); diff --git a/scripts/native/widget-drive.ts b/scripts/native/widget-drive.ts index faca5aafc2..6cb125e13b 100644 --- a/scripts/native/widget-drive.ts +++ b/scripts/native/widget-drive.ts @@ -13,6 +13,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; * * Commands (WidgetWindow.swift runTestCommand): `expand <edge> [group]`, `module <id> <edge> [group]`, * `hover <edge> [group]`, `unhover <edge> [group]`, `collapse`, `select <session key>`, `settings [page]`, + * `inbox <edge> [group]` (the inbox button), `session <key> <edge> [group]` (a rail session button), * `passive on|off`. Send `passive on` before `expand` on the user's own screen: an expanded panel otherwise takes the * keyboard from the app in front. * Edges: top, right, left. The face honours them only with staging on (`bun scripts/native/staging.ts on`) or in the diff --git a/src/agents/README.md b/src/agents/README.md index 613af2134d..c5d576184f 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -163,6 +163,47 @@ tools agents hooks config set shadow false --write Every writing command is a **dry run by default**; `--write` applies it. The one exception is the `config rules` picker in a terminal: saving the selection is the write. +## The question nudge + +`decisions.questionNudge` (`off`, the default; `warn`; `block`) catches a final reply that asks the user to decide +something that never reached the widget inbox. It fires when the reply's own prose (fenced code, inline code and `>` +quoted lines left out) holds a cue, the word `decision`, `decisions`, `decide` or `choose` in any case or `❓`, and a +sentence that ends in `?`, while this session posted no decision since the turn's prompt. The prompt's time is the +last transcript row that opened a turn: a Claude `user` row that is not `isMeta` and carries text, or a Codex user +message. `block` sends the agent back once per turn, quoting the question, to post it with `question_post` as type +`decision` or to say in one line that it is not a decision for the user. A second Stop of the turn always passes +(Claude's `stop_hook_active`, or the turn it already spoke in), and past `decisions.maxBlocksPerSession` blocks it +only warns. A reply that names `❓ DECISION N` stays with `decisions.stopHook`. It needs only the Stop entry, which +`doctor` lists as `Stop hook`; the widget settings (Widgets > General > Agents) switch it too. + +```bash +tools agents hooks config set decisions.questionNudge block --write +tools agents hooks install --write # only when doctor says the Stop hook is not wired +``` + +## Chat answers + +`decisions.chatAnswers` (`off`, the default; `record`; `remind`) finds answers the user typed in the chat to an open +`❓ DECISION N` of the session (`D14 b)`, `14 b`, `DECISION 14: b`, `D14: own words`, several on one line split by `,` +or `;`, or a bare `b)` right after a reply that asked one decision). At the end of each turn the Stop hook reads the +transcript from its end back to the oldest open ask, or to its previous read when that is later, through the session's +provider (`NativeSessionReader.readBackward`, so Claude, Codex and Grok each read their own format), and looks only at +user messages written after each decision was asked. `record` stores an unambiguous answer as `acknowledged` with +`answeredVia: "automatic_heuristic"` and the user's line as a comment, and says so; `remind` stores nothing and sends +the agent back to record it. An unclear one (two different answers, a letter the decision does not have, `D14` named +without an option) sends the agent back once per decision, quoting the line. A read that ends before the oldest open +ask is logged to the hooks log. It needs only the Stop entry. + +Every run that read a user message is logged to `~/.genesis-tools/agents/chat-answers.jsonl`: the open decisions, each +user message read (its start, plus any later line that names a decision or opens with an option letter), and what came +of it. `tools agents hooks chat-answers --misses` lists the messages that matched nothing while decisions were open, so +an answer the finder should have taken is found in a minute; `--session <id>` and `--json` narrow and export it. + +```bash +tools agents hooks config set decisions.chatAnswers record --write +tools agents hooks chat-answers --misses +``` + ## How a diff looks `diff.theme` picks the look. `claude` (the default) copies Claude Code's own Bash diff in the theme @@ -210,28 +251,148 @@ under the fold, so without them nothing says which command changed the files. **The full-diff mod.** `tools agents hooks mods install --write` installs the plugin `genesis-tools-mods` from this repository's marketplace (`plugins/genesis-tools-mods/`). It is a Claude Code mod: when a hook message cut lines, it draws the call's whole diff (the `hook-diffs/<call>.md` note) with Claude Code's own -diff element under the call's row, so the 10,000-character message limit no longer hides the rest. A running -session needs `/reload-plugins`. `tools agents hooks mods` shows whether it is installed; -`tools agents hooks config set mods.fullDiff false --write` turns it off without an uninstall (the mod reads +diff element under the call's row, so the 10,000-character message limit no longer hides the rest. A session +that was running at the install needs `/reload-plugins` once. A session that loads the folder through +`CLAUDE_CODE_PLUGIN_DIRS` (the shell, or the `env` block of `~/.claude/settings.json`) reloads the mods by itself +when a file in the folder is saved. `tools agents hooks mods` shows whether it is installed; +`tools agents hooks config set mods.diff.fullDiff false --write` turns it off without an uninstall (the mod reads the hooks file). It draws nothing while `diff.fullDiff` is `off`. While the plugin is enabled in the user settings -and `mods.fullDiff` is on, the diff hook prints no diff for Claude: it writes every diff to its note, and the mod +and `mods.diff.fullDiff` is on, the diff hook prints no diff for Claude: it writes every diff to its note, and the mod draws it, so it is not drawn twice (Codex and Grok keep the hook's message). Each file has a `[-]` / `[+]` button that folds its diff to its name and back, and `[ files only ]` above them folds every file; a click on the diff itself still opens the group's calls. The diff of a call that runs `git pull`, `merge`, `rebase`, `checkout`, `switch`, `reset`, `cherry-pick`, `revert`, `am` or `stash pop|apply` starts hidden behind one -`[ show diff from git pull ]` button, because it is other people's work arriving; `config set mods.collapseGitSync false +`[ show diff from git pull ]` button, because it is other people's work arriving; `config set mods.diff.collapseGitSync false --write` draws it like any other call. Its tests run with `claude plugin test plugins/genesis-tools-mods`. The mod API is early access in Claude Code (2.1.287 and later), so a release can change it; the mod then draws nothing and the hook message is unchanged. **The idle-compaction mod.** The same plugin compacts an idle Claude session 56 minutes after its last turn -(`mods.idleCompactMinutes`, 1 to 59), while the 1-hour prompt cache is still warm, so the next prompt does not +(`mods.idleCompact.minutes`, 1 to 59), while the 1-hour prompt cache is still warm, so the next prompt does not pay for the whole context again. Claude Code's own idle compaction tries once at 54 minutes and gives up at its first refused check (a rate-limit warning on the last reply is enough); the mod does not run that check, so a compaction can be spent while the account is in its warning. It skips a session that compacted after its last -turn, a context under `mods.idleCompactMinTokens` (200,000) and a session with no screen (`claude -p`), and it +turn, a context under `mods.idleCompact.minTokens` (200,000) and a session with no screen (`claude -p`), and it appends a notice that starts `Compacted while idle`. `tools agents hooks config set mods.idleCompact false --write` turns it off, also for a timer already armed. A reload of the mod drops the timer until the next turn ends. +**The daily mods.** The same plugin carries more features, each an object under `mods` in the hooks file, all off +by default. `tools agents hooks mods enable git agentsBand --write` turns mods on by name (`all` is every mod, +`disable` turns them off, `mods status` shows each one); `config set mods.<feature> true --write` does the same for one +(it means `mods.<feature>.enabled`). The flat keys of before 2026-10-23 (`mods.fullDiff`, `mods.idleCompactMinutes`, …) +are still read and still set. The plugin reads the file as the CLI does: a value equal to the file's +`_shippedDefaults` copy (the defaults on the day it was written) follows today's default. On 2026-10-23 the plugin +still read raw values, so a file written while the new mods shipped on kept them on in the sessions, the CLI read +them as off, and the next `mods enable` wrote them off. +Each feature appends what it did to `~/.genesis-tools/agents/mods-log/<feature>.jsonl`, so its heuristics can be +checked against real use. + +- `mods.agentModel` (`format` `label`, `short` or `version`): `sonnet · Pair one`, `(S) Pair one` or `(S5.5) Pair one` + on a subagent's rows. +- `mods.agentRow` (`format` `version`, `label` or `short`; `effort` on): the agents panel under the prompt names each + subagent's model and effort beside its type, `general-purpose · O5.5(xhigh) Reading foo.ts 1m 5s · ↓ 58k tokens` + (`label`: `opus-5-5 (xhigh)`, `short`: `O(xhigh)`; `mods.agentRow.effort false` drops the effort). No mod hook reaches that panel, so + this is Claude Code's `subagentStatusLine` setting: `mods enable agentRow --write` (and `hooks install --write` + while it is on) writes it into `~/.claude/settings.json`, pointing at `src/agents/bin/hook-subagent-line.ts`, and + `disable` takes it out again. A `subagentStatusLine` of your own is left alone. Claude Code runs the command every + 5 s, so the time on a row moves in 5 s steps. +- `mods.decisions` (`chip` `any`, `expand` `waiting`, `history` `collapsed`, `enter` `stage-then-send`): the + Decisions sidebar, opened by `/decisions` or the band's `decisions 1 waiting` chip. It lists every `❓ DECISION N` + of the session from the decision log (`tools question inbox --session <id> --json --all-items`) and from its + messages. The waiting ones come first; each opens to its options: a press stages an option, the field stages your + own words (a long answer grows the field over several lines). Under them, the Staged section shows the exact + message `send` submits (the CLI builds it, `stagedSendText`), with `send` (hotkey `s`), `put in prompt` and + `clear` right below. The answered, done and closed decisions follow, folded under one `show N answered` row while + `history` is `collapsed`; an opened one marks the picked option and shows the answer given. `jump to` scrolls the + transcript to where a decision was first asked; a row from before a compaction is not drawn, so it opens the + transcript search there instead (`tools claude cmux find --decision`). `chip` `waiting` shows the chip only while + a decision waits, `off` never; `expand` `all` or `none` opens every row or none. `enter` `stage-then-send`: Enter + on a staged, unchanged answer sends every staged answer (a terminal does not pass Cmd+Enter to a mod's field); + `stage` only stages. +- `mods.inbox` (`chip` `any`, `expand` `none`): the Inbox sidebar, opened by `/mod-inbox` or the band's `inbox 2 new` + chip. It lists what the session put in the GenesisTools inbox: the forms it waits on, the messages it sent, the + questions and answers it recorded, and its todos. An entry opens to its text, `jump to` (the tool call that sent + it) and its images. `new` is an entry nobody read in the widget yet. `chip` `unread` shows the chip only while + one is new; `expand` `unread` or `all` opens those rows at the start. +- `mods.links`: the main loop's reply is linked as it streams, one line at a time. A path that exists (absolute, + `~/`, relative to the working folder or the repository, or a bare file name a tool of the session named), with + an optional `:line` or `:line-line`, a commit SHA that `git cat-file` knows, and a repeated `❓ DECISION N` become + links; code fences, inline code that does not resolve, existing links and URLs stay as they are. A genesis.tools + link is written only for a kind `tools genesis-tools probe` says opens on this Mac (the plugin's `genesis.ts` + runs it once per 30 minutes; no `tools` means no such links): else a note gets its `cursor://` or plain path, and + a folder, a commit or a decision stays text. +- `mods.git` (`labels` `full` or `minimal`): a band line above the prompt with the branch, unpushed and unpulled + commits, the changed files with their added and removed lines (`↑14 to push · ↓2 to pull 4 files changed +120 −30`, + or `↑14 ↓2 ●4 +120 −30` with `minimal`), and the PRs (the branch's own and the ones `/mod-git pr add 123` tracks) + with CI counts. A press on the branch or a PR, or a bare `/mod-git`, opens the Git sidebar: each PR's title, failing and pending checks, and `open`, `hub`, `copy link` + and `track` buttons. `/mod-git on|off|toggle|refresh`. The band has one line per checkout the session works in, + the last first, up to `checkouts` (default 3, at most 6): a checkout counts once the session + writes a file in it that git does not ignore, or runs a git command that changes it (`git -C <dir> commit`, + `cd <dir> && git rebase`, `git worktree add <path>`, a folder in a variable the same command set: + `W=/tmp/x` then `git -C "$W" commit`); reads and `git status` count nothing. `name` (`repo-folder` by default) + names a linked worktree `Kalendora/enhancements` and a main checkout `Kalendora`; `repo` gives the main + checkout's name alone, `folder` the checkout's own folder. The sidebar says where a linked worktree is. + `earlier` (`show` by default) keeps the session's earlier work in view: each checkout remembers the branches the + session worked on there (checked out at a write, or made or pushed by a command: `switch -c`, `checkout -b`, + `worktree add -b`, `push origin <branch>`), so the PR of a branch it switched away from stays in the row, and a + worktree it removed keeps its row (`worktree removed`) with its last branch and that branch's PRs, read in the + main checkout. Such a row goes once the branch and its PRs are gone too. `hide` shows only what the checkouts + have now. So a session started in the main checkout that + works in two worktrees shows the two worktrees. `cwd` `touched` (the default) shows the session's own folder only + when the session worked there, or when it has worked nowhere yet; `always` shows it first. A session that has no + record yet starts from its GenesisTools file journal. `/mod-git pr add` acts on the first line's checkout. + Each line fits the band's width: the counts and the PRs keep theirs, and the checkout name (at most 40% of what + is left when both cannot fit) and the branch keep their start and end. Only the first line shares the hooks chip. + When the words do not fit, every line says its counts in signs (`↑27 ↓2 ●13 +862 −125`), then the PRs as bare + numbers. `/mod-git add <folder>` (the prompt's typeahead proposes folders, zoxide's first when it is installed: + `GenesisPlayg` finds the GenesisPlayground path) and the sidebar's field put any checkout in this session's band; + `/mod-git remove <folder>` and the sidebar's `remove` take it out, and `hide` drops a worked-in checkout until + the session works there again. In the sidebar, a PR's `track` keeps it in its checkout's line after a branch + switch (`/mod-git pr add`); the branch's own PR shows anyway, so its `hide` takes it out of the band and `show` + puts it back. The sidebar folds each checkout to one line with its state in signs (`▸ GenesisPlayground + feat/… ↑24 ●16 #98 ✓1`); Enter or a click opens it to its state, its actions and its PRs. `expand` (`first`, + `all` or `none`, default `first`) says which start open. A failing or pending check shows what it said + (`✗ Vercel · Deployment was blocked`, from `gh pr checks`) and opens its page on Enter. +- `mods.sidebar` (`hints` on, `feedback` `line`, `toast` or `both`, `focus` `command`, `always` or `never`): a + sidebar ends in a status bar of its own, pinned to the pane's foot when the content fits, which says what the + focused button does and, after a press, what happened (`… Reading 3 checkouts…`, then `✓ Read 3 checkouts + again.`). Nothing above it moves when the focus does. The engine runs no plugin code on hover, so the bar follows + the focus ring: Tab moves it, Enter presses, Esc gives the keys back. `focus` says when a docked sidebar takes the + keys as it opens: `command` (the default) when its slash command (`/mod-git`) opened it, not on a band click, so + typing stays in the prompt. A pane docks beside the transcript only in a fullscreen session; elsewhere Claude Code + draws it above the prompt. The plugin cannot choose (`$.ui.open` takes no placement). Times in the sidebars and + the band are live (`hooks/live.ts`): a time with seconds redraws each second while it is on screen, one in + minutes each minute, and nothing redraws while no live time shows. +- `mods.askRow`: `[ask]` beside a finished tool row puts the row, quoted, into the prompt. +- `mods.spinner`: the spinner line names the running tool, its time and the working subagents. +- `mods.cacheBand` (`cautionMinutes` 20, `warnMinutes` 10, `others`): the prompt cache countdown, yellow under + `cautionMinutes` and red under `warnMinutes`, and the other sessions whose cache runs out within the yellow window. A press on another session's chip focuses the cmux pane it runs in. + The own chip adds `AC in 1m 22s` while the idle compaction is armed, and says `compacted 4m ago · cache restarts` + after a compaction: the next prompt then starts a new, small context, so there is no cache to keep warm. Another + session that compacted gives no warning. +- `mods.tmpUsage` (`minutes` 5): what the session keeps on disk and the free disk (`tmp: session 4.2G · 1 worktree · + disk free 5.0G`). The session's share is its `/tmp/cc` scratch folder, Claude Code's task output for it + (`/tmp/claude-<uid>/…/<session>`) and every git worktree it made or holds: one its subagents got + (`isolation: "worktree"`), one under its scratch folder's `worktrees/`, one locked to it. A worktree inside the + scratch folder counts once. A press on the row (or `/mod-tmp`) opens the Session files sidebar: each folder and + worktree by size, with its branch, changed files, lock and source, `Finder` and `copy path`. +- `mods.agentsBand` (`warnMinutes` 60): one band row per working subagent with model, age, idle time and tool calls; + more than three fold into one `▸ 8 agents` line. A press on an agent, or `/mod-agents`, opens the Agents sidebar: + its recent tools, what it said last, and `message`, `ask about it`, `stop`, `copy id` and `copy transcript path`. +- `mods.stall` (`action` `button` or `auto`, `minutes` 5): when a main-loop reply ends on what it was about to do + (`Next I'll run the tests.`) and nothing can wake the session, the band shows `⏸ stopped on "…"` with `continue` + and `dismiss`; `continue` sends a prompt that quotes the sentence, and `auto` sends it by itself after `minutes`. + It never fires while anything runs: the Stop hook's `background_tasks` (shells, monitors, subagents, workflows) or + `session_crons` not empty, an agent of `$.agent.list()` pending, running or waiting, or a Stop input without the + task list. The sentence tests are the session-analyzer's stall scorer: the last sentence only, and a conditional + ("I'll report when it fires"), a negated ("I won't …") or a gated reply ("waiting for your answer") is no stall. + A tool call or a new turn takes the row down. Each finding and press goes to `mods-log/stall.jsonl`. +- `mods.hooks` (`tokens`, `dedupe`): a band chip counts what the settings and plugin hooks handed the model in this + session (`hooks ~38.2k · 41`, characters / 4); a press on it, or `/mod-hooks`, opens the Hooks sidebar with the + count per event, the latest injections and a dedupe switch for the session. A block already handed to the same + loop since its last compaction is replaced with a one-line receipt. +- The sidebars dock beside the transcript in fullscreen and open as a dialog above the prompt otherwise, as Claude + Code's own `/diff` panel does; a second press on the chip, or the same command again, closes one. + A file or a whole folder moved with `mv` or `git mv` is one line, `old/ → new/ (284 files)`, when the new copies match the old ones byte for byte (up to 4,000 files; a file over 4 MiB is not compared). diff --git a/src/agents/bin/hook-stop.ts b/src/agents/bin/hook-stop.ts index 165fe19206..937d8e018e 100644 --- a/src/agents/bin/hook-stop.ts +++ b/src/agents/bin/hook-stop.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; -import { decisionHooksWanted, loadHooksConfig, megabytes } from "../lib/hooks/config"; +import { decisionStopWanted, loadHooksConfig, megabytes } from "../lib/hooks/config"; import { readSessionState } from "../lib/hooks/diff/mentions"; import { gitOut } from "../lib/hooks/git"; import { hookDiag, logDecision, setDiagLogPath, setMaxLogBytes } from "../lib/hooks/log"; @@ -26,14 +26,20 @@ if (!payload || normalizeEvent(payload.event) !== "stop") { let output: { decision?: "block"; reason?: string; systemMessage?: string } | null = null; -// The decision hub's module graph is loaded only when one of its hooks is on: this entrypoint +// The decision hub's module graph is loaded only when one of its Stop checks is on: this entrypoint // now runs at the end of every turn for the unpushed reminder alone. -if (decisionHooksWanted(config)) { +if (decisionStopWanted(config)) { try { const { decisionFiles } = await import("@app/question/lib/decisions/read"); const { runDecisionStop } = await import("../lib/hooks/decisions"); - const decided = await runDecisionStop(payload, config.decisions, { log: decisionFiles() }); + // lazy: saves 126 ms cold import (the provider registry, bun probe 2026-10-25) — read only for chat answers + const readSession = + config.decisions.chatAnswers === "off" + ? undefined + : (await import("@genesiscz/utils/agent-sessions/session-backward")).readSessionBackward; + + const decided = await runDecisionStop(payload, config.decisions, { log: decisionFiles(), readSession }); if (decided) { logDecision( diff --git a/src/agents/bin/hook-subagent-line.ts b/src/agents/bin/hook-subagent-line.ts new file mode 100644 index 0000000000..9b4f09bcce --- /dev/null +++ b/src/agents/bin/hook-subagent-line.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env bun +// Claude Code's `subagentStatusLine` command: the agents panel rows with each subagent's model beside its type +// (`mods.agentRow`). The engine runs it every 5 s while a subagent is listed, with the subagents as JSON on stdin, +// and draws each `{"id","content"}` line printed here as that row. The look is the mods plugin's +// (`plugins/genesis-tools-mods/hooks/agent-row.ts`); no line leaves the engine's own row. +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { env } from "@genesiscz/utils/env"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { agentRows, finishedAt, rowInput } from "../../../plugins/genesis-tools-mods/hooks/agent-row"; +import { loadHooksConfig } from "../lib/hooks/config"; +import { hookDiag } from "../lib/hooks/log"; +import { writeJsonFile } from "../lib/hooks/write-json"; + +if (env.agents.areHooksDisabled()) { + process.exit(0); +} + +const config = loadHooksConfig(); + +if (!config.mods.agentRow.enabled) { + process.exit(0); +} + +let payload: unknown; + +try { + payload = SafeJSON.parse(await Bun.stdin.text(), { strict: true }); +} catch (err) { + hookDiag("subagentStatusLine got no JSON", { err }); + process.exit(0); +} + +const input = rowInput(payload); + +if (!input) { + process.exit(0); +} + +const now = Date.now(); +// When each finished agent was first seen finished, since the payload has no end time. Only a session's own rows +// read it, and it matters only while the panel lists the agent, so it lives in the temporary folder. +const endsPath = input.sessionId?.match(/^[\w-]+$/) + ? join(tmpdir(), "genesis-tools-agent-row", `${input.sessionId}.json`) + : undefined; +let held: Record<string, number> = {}; + +if (endsPath && existsSync(endsPath)) { + try { + const stored: unknown = SafeJSON.parse(readFileSync(endsPath, "utf8"), { strict: true }); + + if (typeof stored === "object" && stored !== null && !Array.isArray(stored)) { + held = Object.fromEntries( + Object.entries(stored).filter((entry): entry is [string, number] => typeof entry[1] === "number") + ); + } + } catch (err) { + hookDiag("Could not read the agent row end times", { err, path: endsPath }); + } +} + +const ends = finishedAt(input, held, now); + +if (endsPath && SafeJSON.stringify(ends) !== SafeJSON.stringify(held)) { + try { + mkdirSync(join(tmpdir(), "genesis-tools-agent-row"), { recursive: true }); + writeJsonFile(endsPath, ends); + } catch (err) { + hookDiag("Could not keep the agent row end times", { err, path: endsPath }); + } +} + +const rows = agentRows(input, { + format: config.mods.agentRow.format, + effort: config.mods.agentRow.effort, + now, + ends, + width: Bun.stringWidth, +}); + +process.stdout.write(rows.map((row) => `${SafeJSON.stringify(row)}\n`).join("")); diff --git a/src/agents/commands/hooks.ts b/src/agents/commands/hooks.ts index 2fef996364..fd62770e84 100644 --- a/src/agents/commands/hooks.ts +++ b/src/agents/commands/hooks.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { suggestCommand, suggestEnumFlag } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { ui } from "@genesiscz/utils/cli/ui"; +import { formatLocalDateTimeStamp } from "@genesiscz/utils/date"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; import { SHELL_RULES } from "@genesiscz/utils/shell/rules"; @@ -10,6 +11,7 @@ import type { ShellRule } from "@genesiscz/utils/shell/rules/types"; import { createBoxTable, renderCliHeader } from "@genesiscz/utils/table"; import type { Command } from "commander"; import pc from "picocolors"; +import { chatAnswerLogPath, readChatAnswerRuns, userMessageOutcome } from "../lib/hooks/chat-answer-log"; import { type CodexWiring, codexHooksPath, @@ -19,6 +21,7 @@ import { } from "../lib/hooks/codex"; import { DEFAULT_HOOKS_CONFIG, + decisionStopWanted, diffFor, type HarnessName, type HooksConfig, @@ -40,15 +43,20 @@ import { installAndPoint, readSettings, repoRoot, + type SubagentLinePlan, + stopHookWired, uninstallHooks, + wireSubagentLine, wiringStatus, } from "../lib/hooks/install"; import { claudePlugin, claudeUserSettingsPath, + MOD_SWITCHES, MODS_PLUGIN, MODS_SCOPES, type ModsScope, + modSwitchKeys, modsPluginState, modsWatchState, PLUGIN_DIRS_VAR, @@ -102,6 +110,10 @@ function printInstallResult(result: InstallResult, write: boolean, file: string) ui.raw(`${pc.bold("backup")} ${result.backup}`); } + if (result.subagentLine === "theirs" || result.subagentLine === "replaced" || result.subagentLine === "restored") { + ui.raw(`${pc.bold("subagentStatusLine")} ${subagentLineSaid(result.subagentLine, write)}`); + } + if (result.nativeDiff === "turned-off") { ui.raw( `${pc.bold("native")} Claude Code's own Bash diff ${write ? "turned off" : "would be turned off"} (bashEditDiffEnabled: false): the hook draws every Bash diff` @@ -234,8 +246,21 @@ export function registerHooksCommands(program: Command): void { hooks .command("doctor") .description("Is it wired for each harness, what is configured, which custom rules loaded") - .action(async () => { + .option("--json", "The config in effect, the Claude and Codex wiring and whether the Stop hook is wired") + .action(async (options: { json?: boolean }) => { const config = loadHooksConfig(); + const stopWired = stopHookWired(); + + if (options.json) { + out.result({ + configPath: hooksConfigPath(), + config, + wiring: { claude: wiringStatus(), codex: codexWiring().state }, + stopHookWired: stopWired, + }); + return; + } + const set = await loadRuleSet(config); renderCliHeader("Agent hooks", hooksConfigPath()); @@ -262,6 +287,10 @@ export function registerHooksCommands(program: Command): void { table.push(["native Bash diff", nativeDiffLabel(config)]); table.push(["diff per harness", perHarnessDiff(config)]); table.push(["agentsTalk.hint", String(config.agentsTalk.hint)]); + table.push(["decisions.stopHook", config.decisions.stopHook]); + table.push(["decisions.questionNudge", config.decisions.questionNudge]); + table.push(["decisions.chatAnswers", config.decisions.chatAnswers]); + table.push(["Stop hook", stopWired ? "wired" : "not wired"]); table.push(["shadow", config.shadow ? "ON (decides, says nothing)" : "off (it speaks)"]); table.push(["log", config.logPath]); table.push(["log rotates at", `${config.maxLogMB} MB, keeping one generation`]); @@ -302,6 +331,12 @@ export function registerHooksCommands(program: Command): void { ); } + if (decisionStopWanted(config) && !stopWired) { + ui.warn( + `A decision check is on, but no Stop hook of ours is wired, so it never runs. Fix: ${suggestCommand("tools agents", { replaceCommand: ["hooks", "install", "--write"] })}` + ); + } + if (codex.state !== "installed") { ui.warn( `Codex has no ${codex.state === "stale" ? "current " : ""}shell guard of ours. Fix: ${suggestCommand("tools agents", { replaceCommand: ["hooks", "install", "--harness", "codex", "--write"] })}` @@ -343,58 +378,77 @@ export function registerHooksCommands(program: Command): void { hooks .command("install") .description( - "Wire the hooks into a harness, additively. Claude: ~/.claude/settings.json; the decision hub's Stop and UserPromptSubmit hooks are wired only while decisions.stopHook, decisions.harvest or decisions.injectAnswers is on. Codex: <CODEX_HOME>/hooks.json (the guard, plus the Bash diff while it is on for Codex). Grok runs Claude's hooks" + "Wire the hooks into a harness, additively. Claude: ~/.claude/settings.json; the decision hub's Stop and UserPromptSubmit hooks are wired only while decisions.stopHook, decisions.harvest or decisions.injectAnswers is on, and the Stop hook alone while decisions.questionNudge or unpushed.enabled is on. Codex: <CODEX_HOME>/hooks.json (the guard, plus the Bash diff while it is on for Codex). Grok runs Claude's hooks" ) .option("--harness [name]", "claude (default) or codex") .option("--dist <path>", "Stable path the entries call (default: the agents dist symlink)") .option("--target <path>", "Claude only: checkout the dist symlink points at (default: this checkout)") + .option( + "--replace-subagent-line", + "Claude only: while mods.agentRow is on, replace a subagentStatusLine of someone else's (uninstall puts it back)" + ) .option("--write", "Actually write; without it this is a dry run") - .action((options: { harness?: string | boolean; dist?: string; target?: string; write?: boolean }) => { - const harness = installableHarness(options.harness, "install"); + .action( + (options: { + harness?: string | boolean; + dist?: string; + target?: string; + write?: boolean; + replaceSubagentLine?: boolean; + }) => { + const harness = installableHarness(options.harness, "install"); - if (!harness) { - return; - } + if (!harness) { + return; + } - const dist = options.dist ?? hooksDistPath(); + const dist = options.dist ?? hooksDistPath(); - if (harness === "codex") { - const result = installCodexHooks({ dist, write: Boolean(options.write) }); + if (harness === "codex") { + const result = installCodexHooks({ dist, write: Boolean(options.write) }); - ui.raw(`${pc.bold("file")} ${codexHooksPath()}`); - printInstallResult(result, Boolean(options.write), "the hooks file"); + ui.raw(`${pc.bold("file")} ${codexHooksPath()}`); + printInstallResult(result, Boolean(options.write), "the hooks file"); - if (result.changed && options.write) { - ui.raw( - pc.dim( - "Codex trusts each hook by its position and content: it asks to review a new or changed one at its next interactive start, and does not run it before that." - ) - ); - } + if (result.changed && options.write) { + ui.raw( + pc.dim( + "Codex trusts each hook by its position and content: it asks to review a new or changed one at its next interactive start, and does not run it before that." + ) + ); + } - return; - } + return; + } - const target = options.target ?? repoRoot(); - // The symlink is only ours to manage when it IS ours. A `--dist` pointing - // somewhere else is the caller's path, and silently repointing it would be a - // surprise; it is reported instead. - const managed = dist === hooksDistPath(); - const result = installAndPoint({ dist, target, write: Boolean(options.write) }); + const target = options.target ?? repoRoot(); + // The symlink is only ours to manage when it IS ours. A `--dist` pointing + // somewhere else is the caller's path, and silently repointing it would be a + // surprise; it is reported instead. + const managed = dist === hooksDistPath(); + const result = installAndPoint({ + dist, + target, + write: Boolean(options.write), + replaceSubagentLine: Boolean(options.replaceSubagentLine), + }); - ui.raw(`${pc.bold("dist")} ${dist}${managed ? ` → ${target}` : pc.dim(" (yours, not repointed)")}`); + ui.raw( + `${pc.bold("dist")} ${dist}${managed ? ` → ${target}` : pc.dim(" (yours, not repointed)")}` + ); - if (options.write) { - // Also when nothing changed: the file then gains every setting and the record. - recordInstallState({ dist, target: managed ? target : null }); - } + if (options.write) { + // Also when nothing changed: the file then gains every setting and the record. + recordInstallState({ dist, target: managed ? target : null }); + } - printInstallResult(result, Boolean(options.write), "the settings file"); + printInstallResult(result, Boolean(options.write), "the settings file"); - if (options.write) { - ui.raw(`${pc.bold("config")} ${hooksConfigPath()} (every setting, plus what was installed)`); + if (options.write) { + ui.raw(`${pc.bold("config")} ${hooksConfigPath()} (every setting, plus what was installed)`); + } } - }); + ); hooks .command("uninstall") @@ -483,6 +537,68 @@ export function registerHooksCommands(program: Command): void { out.result(filtered); }); + hooks + .command("chat-answers") + .description( + "Review the decision Stop hook's chat-answer runs (decisions.chatAnswers): each user message it read and what it made of it" + ) + .option("--session <id>", "Only this session (an id prefix)") + .option("--misses", "Only user messages that matched nothing while decisions were open") + .option("-n, --runs <count>", "How many of the newest runs", "20") + .option("--json", "Print the runs as JSON") + .action((options: { session?: string; misses?: boolean; runs?: string; json?: boolean }) => { + const count = Number(options.runs ?? "20"); + + if (!Number.isInteger(count) || count < 1) { + ui.err(`--runs must be a positive integer, got ${options.runs}`); + process.exitCode = 1; + return; + } + + const runs = readChatAnswerRuns({ session: options.session }).slice(-count); + + if (options.json) { + out.result(runs); + return; + } + + if (runs.length === 0) { + ui.info( + `No chat-answer runs in ${chatAnswerLogPath()}${options.session ? ` for ${options.session}` : ""}.` + ); + return; + } + + for (const run of runs) { + const rows = run.users + .map((message) => ({ message, outcome: userMessageOutcome(run, message) })) + .filter((row) => !options.misses || row.outcome === "no match"); + + if (rows.length === 0) { + continue; + } + + const open = run.open.map((row) => `D${row.number}`).join(", "); + ui.header( + `${formatLocalDateTimeStamp(run.at)} ${run.harness} ${run.session.slice(0, 8)} open: ${open}` + ); + + for (const { message, outcome } of rows) { + const tag = outcome === "no match" ? pc.yellow(outcome) : pc.green(outcome); + const when = message.timestamp + ? `${formatLocalDateTimeStamp(message.timestamp, { seconds: false })} ` + : ""; + ui.raw(` ${tag} ${pc.dim(when)}${message.text.replace(/\s+/g, " ").slice(0, 160)}`); + + for (const candidate of message.candidates ?? []) { + ui.raw(` ${pc.dim("…")} ${candidate}`); + } + } + } + + ui.dim(`Log: ${chatAnswerLogPath()}`); + }); + hooks .command("native-diff [state]") .description( @@ -729,14 +845,14 @@ export function registerHooksCommands(program: Command): void { /** * `hooks mods`: the Claude Code mods of this repository (the `genesis-tools-mods` plugin). They share the - * hooks file: `mods.fullDiff` switches the full-diff mod, which reads the diff hook's `hook-diffs` notes, and + * hooks file: `mods.diff.fullDiff` switches the full-diff mod, which reads the diff hook's `hook-diffs` notes, and * `mods.idleCompact` the idle-compaction mod. */ function registerMods(hooks: Command): void { const mods = hooks .command("mods") .description( - `Claude Code mods (${MODS_PLUGIN}): status, install, uninstall. Settings: mods.* in the hooks config` + `Claude Code mods (${MODS_PLUGIN}): status, enable, disable, install, uninstall. Settings: mods.* in the hooks config` ); mods.command("status", { isDefault: true }) @@ -766,13 +882,64 @@ function registerMods(hooks: Command): void { ); } - ui.raw(`mods.fullDiff: ${config.mods.fullDiff ? pc.green("on") : pc.dim("off")}`); - ui.raw(`mods.collapseGitSync: ${config.mods.collapseGitSync ? pc.green("on") : pc.dim("off")}`); - ui.raw( - `mods.idleCompact: ${config.mods.idleCompact ? pc.green("on") : pc.dim("off")}${pc.dim(` · after ${config.mods.idleCompactMinutes} min · floor ${config.mods.idleCompactMinTokens} tokens`)}` - ); + const onOff = (on: boolean) => (on ? pc.green("on") : pc.dim("off")); + const mods = config.mods; + const rows: [string, boolean, string][] = [ + ["mods.diff.fullDiff", mods.diff.fullDiff, ""], + ["mods.diff.collapseGitSync", mods.diff.collapseGitSync, ""], + [ + "mods.idleCompact", + mods.idleCompact.enabled, + `after ${mods.idleCompact.minutes} min · floor ${mods.idleCompact.minTokens} tokens`, + ], + ["mods.agentModel", mods.agentModel.enabled, `format ${mods.agentModel.format}`], + [ + "mods.agentRow", + mods.agentRow.enabled, + `agents panel rows · format ${mods.agentRow.format}${mods.agentRow.effort ? " + effort" : ""}`, + ], + [ + "mods.decisions", + mods.decisions.enabled, + `chip ${mods.decisions.chip} · open ${mods.decisions.expand} · answered ${mods.decisions.history} · Enter ${mods.decisions.enter}`, + ], + ["mods.inbox", mods.inbox.enabled, `chip ${mods.inbox.chip} · open ${mods.inbox.expand}`], + ["mods.links", mods.links.enabled, ""], + [ + "mods.git", + mods.git.enabled, + `labels ${mods.git.labels} · up to ${mods.git.checkouts} checkouts · own folder ${mods.git.cwd} · open ${mods.git.expand} · names ${mods.git.name} · earlier work ${mods.git.earlier}`, + ], + ["mods.askRow", mods.askRow.enabled, ""], + ["mods.spinner", mods.spinner.enabled, ""], + [ + "mods.cacheBand", + mods.cacheBand.enabled, + `yellow under ${mods.cacheBand.cautionMinutes} min · red under ${mods.cacheBand.warnMinutes} min · others ${mods.cacheBand.others ? "on" : "off"}`, + ], + ["mods.tmpUsage", mods.tmpUsage.enabled, `every ${mods.tmpUsage.minutes} min`], + ["mods.agentsBand", mods.agentsBand.enabled, `red past ${mods.agentsBand.warnMinutes} min`], + ["mods.hooks.tokens", mods.hooks.tokens, ""], + ["mods.hooks.dedupe", mods.hooks.dedupe, ""], + [ + "mods.sidebar.hints", + mods.sidebar.hints, + `feedback ${mods.sidebar.feedback} · takes the keys ${mods.sidebar.focus === "command" ? "when a command opens it" : mods.sidebar.focus}`, + ], + [ + "mods.stall", + mods.stall.enabled, + mods.stall.action === "auto" + ? `continues by itself after ${mods.stall.minutes} min` + : "a continue button", + ], + ]; + + for (const [name, on, detail] of rows) { + ui.raw(`${name}: ${onOff(on)}${detail ? pc.dim(` · ${detail}`) : ""}`); + } - if (config.mods.fullDiff && fullDiff === "off") { + if (mods.diff.fullDiff && fullDiff === "off") { ui.raw(pc.yellow(`diff.fullDiff is off, so no call has a full-diff note for the mod to draw.`)); } @@ -826,6 +993,27 @@ function registerMods(hooks: Command): void { ); }); + for (const on of [true, false]) { + const verb = on ? "enable" : "disable"; + + mods.command(verb) + .description(`Turn mods ${on ? "on" : "off"} by name, or all: ${Object.keys(MOD_SWITCHES).join(", ")}`) + .argument("[names...]", "The mods to switch") + .option( + "--replace-subagent-line", + "agentRow: replace a subagentStatusLine of someone else's; disable puts it back" + ) + .option("--write", "Actually write; without it this is a dry run") + .action((names: string[], options: { write?: boolean; replaceSubagentLine?: boolean }) => { + switchMods({ + names, + on, + write: Boolean(options.write), + replaceSubagentLine: Boolean(options.replaceSubagentLine), + }); + }); + } + mods.command("install") .description("Install the mods plugin from this repository's marketplace") .option("--scope <scope>", `Where Claude Code records it: ${MODS_SCOPES.join(", ")}`, "user") @@ -850,6 +1038,89 @@ function registerMods(hooks: Command): void { }); } +/** What happened to `subagentStatusLine`, for `mods enable|disable agentRow` and `install`. */ +function subagentLineSaid(plan: SubagentLinePlan, write: boolean): string { + const will = (done: string, planned: string): string => (write ? done : planned); + const held = readSettings().subagentStatusLine; + const command = + typeof held === "object" && held !== null && "command" in held + ? String(held.command) + : SafeJSON.stringify(held); + const said: Record<SubagentLinePlan, string> = { + added: will("wired", "would be wired"), + updated: will("rewired", "would be rewired"), + replaced: will( + "replaced; the command it held is kept and comes back on disable", + "would replace the command it holds, keeping it for disable to restore" + ), + removed: will("removed", "would be removed"), + restored: will("back to the command it held before", "would go back to the command it held before"), + unchanged: "already wired", + absent: "not ours, nothing to do", + theirs: `holds another command (${command}), so the agents panel rows stay the engine's. Replace it (kept, and restored on disable): ${suggestCommand("tools agents", { replaceCommand: ["hooks", "mods", "enable", "agentRow", "--replace-subagent-line", "--write"] })}`, + }; + + return said[plan]; +} + +/** `hooks mods enable|disable <names...>`: one `config set` per switch key of the named mods. */ +function switchMods({ + names, + on, + write, + replaceSubagentLine, +}: { + names: string[]; + on: boolean; + write: boolean; + replaceSubagentLine?: boolean; +}): void { + const { keys, unknown } = modSwitchKeys(names); + const verb = on ? "enable" : "disable"; + + if (names.length === 0 || unknown.length > 0) { + if (unknown.length > 0) { + ui.err(`No such mod: ${unknown.join(", ")}`); + } + + ui.raw(`Mods: all, ${Object.keys(MOD_SWITCHES).join(", ")}`); + ui.raw( + suggestCommand("tools agents", { replaceCommand: ["hooks", "mods", verb, "git", "agentsBand", "--write"] }) + ); + process.exitCode = 1; + return; + } + + for (const key of keys) { + try { + setHooksConfig(key, String(on), { write }); + } catch (err) { + logger.debug({ err, key }, "Rejected a mods switch"); + ui.err(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + + ui.raw(`${pc.bold(key)} → ${on}`); + } + + ui.raw(pc.dim(hooksConfigPath())); + + if (keys.includes("mods.agentRow.enabled")) { + // The agents panel rows are Claude Code's `subagentStatusLine`, a setting of its own, not a hook of the plugin. + const plan = wireSubagentLine({ on, write, replace: replaceSubagentLine }); + + ui.raw(`${pc.bold("subagentStatusLine")} ${subagentLineSaid(plan, write)} ${pc.dim(claudeSettingsPath())}`); + } + + if (!write) { + ui.raw(pc.yellow("dry run: nothing was written. Pass --write to apply.")); + return; + } + + ui.raw(pc.dim("A running session reads the change at its next turn; a band redraws on its next refresh.")); +} + function runPluginCommand({ args, write }: { args: string[]; write: boolean }): void { const shown = `claude plugin ${args.join(" ")}`; diff --git a/src/agents/lib/hooks/chat-answer-log.ts b/src/agents/lib/hooks/chat-answer-log.ts new file mode 100644 index 0000000000..6983953932 --- /dev/null +++ b/src/agents/lib/hooks/chat-answer-log.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; +import type { AmbiguousAnswer, ChatAnswer } from "@app/question/lib/decisions/chat-answers"; +import { readJsonlRows } from "@genesiscz/utils/jsonl"; +import { agentsDataDir } from "./config"; +import { appendHookRecord } from "./log"; + +/** + * Every run of the decision Stop hook's chat-answer step (`decisions.chatAnswers`), one JSONL record each: the open + * decisions, every user message it read, and what it made of them. It exists so a missed answer can be found later: + * `tools agents hooks chat-answers --misses` lists the messages that matched nothing while decisions were open. + */ +export function chatAnswerLogPath(): string { + return join(agentsDataDir(), "chat-answers.jsonl"); +} + +/** A user message as the log keeps it: its start, plus every line that looks like it names a decision or an option. */ +export interface LoggedUserMessage { + line: number; + timestamp?: string; + text: string; + /** Lines past the start that name a decision or open with an option letter, so a long message's answer shows. */ + candidates?: string[]; +} + +export interface ChatAnswerRun { + at: string; + harness: string; + session: string; + transcript: string; + mode: "record" | "remind"; + open: { id: string; number: number; askedAt: string }[]; + /** Where the read stopped: `from` is the time it read back to, `found` whether it got there. */ + read: { from?: string; found: boolean; entries: number; issues: number }; + users: LoggedUserMessage[]; + unambiguous: ChatAnswer[]; + ambiguous: AmbiguousAnswer[]; + /** Decision ids stored as `automatic_heuristic` in this run. */ + recorded: string[]; + /** Decision ids the agent was sent back about in this run. */ + sentBack: string[]; +} + +const START_CHARS = 300; +const CANDIDATE = + /\bD(?:ECISION)?\s*#?\d+\b|^\s*(?:[-*]\s+)?\d+\s*[:.)\-–—]?\s*\(?[a-z]{1,4}\)?(?:\s|$)|^\s*\(?[a-z]\)/i; +const MAX_CANDIDATES = 12; + +/** The user message as the log keeps it (`LoggedUserMessage`). */ +export function loggedUserMessage(entry: { line: number; timestamp?: string; text: string }): LoggedUserMessage { + const text = entry.text.trim(); + const start = text.length > START_CHARS ? `${text.slice(0, START_CHARS - 1)}…` : text; + const candidates = text + .slice(START_CHARS) + .split("\n") + .filter((line) => CANDIDATE.test(line)) + .slice(0, MAX_CANDIDATES) + .map((line) => line.trim().slice(0, 200)); + + return { + line: entry.line, + ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), + text: start, + ...(candidates.length > 0 ? { candidates } : {}), + }; +} + +export function appendChatAnswerRun(run: ChatAnswerRun, path = chatAnswerLogPath()): void { + appendHookRecord(path, { ...run }); +} + +/** The runs in the log (and its rotated generation), oldest first; a session filter matches an id prefix. */ +export function readChatAnswerRuns({ + path = chatAnswerLogPath(), + session, +}: { + path?: string; + session?: string; +} = {}): ChatAnswerRun[] { + const rows: ChatAnswerRun[] = []; + + for (const file of [`${path}.1`, path]) { + for (const row of readJsonlRows<ChatAnswerRun>(file).rows) { + if (!session || row.session?.startsWith(session)) { + rows.push(row); + } + } + } + + return rows; +} + +/** What one user message of a run led to: an answer recorded or found, an unclear mention, or nothing. */ +export function userMessageOutcome(run: ChatAnswerRun, message: LoggedUserMessage): string { + const same = (evidence: { line: number }) => evidence.line === message.line; + const answers = run.unambiguous.filter((answer) => same(answer.evidence)); + const unclear = run.ambiguous.filter((item) => same(item.evidence)); + const parts = [ + ...answers.map((answer) => { + const id = run.open.find((row) => row.number === answer.number)?.id; + const stored = id && run.recorded.includes(id) ? "recorded" : "found"; + + return `${stored} D${answer.number}${answer.option ? ` ${answer.option})` : ""}`; + }), + ...unclear.map((item) => `unclear D${item.number} (${item.reason})`), + ]; + + return parts.length > 0 ? parts.join(", ") : "no match"; +} diff --git a/src/agents/lib/hooks/config.ts b/src/agents/lib/hooks/config.ts index bd9a068fdc..6c2bd4a80a 100644 --- a/src/agents/lib/hooks/config.ts +++ b/src/agents/lib/hooks/config.ts @@ -232,6 +232,8 @@ export interface GuardConfig { export type LogCommandsPolicy = "shadow" | "always" | "never"; export type DecisionStopHook = "off" | "warn" | "block"; +export const DECISION_CHAT_ANSWERS = ["off", "record", "remind"] as const; +export type DecisionChatAnswers = (typeof DECISION_CHAT_ANSWERS)[number]; /** * The decision hub's optional hooks. Everything ships OFF, and `tools agents hooks install` only @@ -244,6 +246,23 @@ export interface DecisionsHookConfig { * times per session, then it only warns (the loop guard). */ stopHook: DecisionStopHook; + /** + * A final reply that asks the user to decide (a cue: the word decision, decisions, decide or + * choose, or ❓; and a sentence that ends in "?", both outside code and quotes) while this + * session logged no decision since the turn's prompt: `warn` tells the user, `block` sends the + * agent back once per turn to post it with question_post. A reply that names `❓ DECISION N` + * belongs to `stopHook`. Its blocks count against `maxBlocksPerSession` apart from `stopHook`'s. + * It needs only the Stop entry, so turning it on wires no UserPromptSubmit hook. + */ + questionNudge: DecisionStopHook; + /** + * Answers the user typed in the chat to an open `❓ DECISION N` (`D14 b)`), read from the session's transcript + * after the decision was asked, through its provider's `readBackward`. `record`: an unambiguous answer is stored + * as acknowledged with `answeredVia: "automatic_heuristic"`, and the agent is sent back once per unclear one to + * record it; `remind`: nothing is stored, the agent is sent back once per decision to record what it finds; + * `off`. It needs only the Stop entry. + */ + chatAnswers: DecisionChatAnswers; maxBlocksPerSession: number; harnesses: HarnessName[]; /** Safety net: store an unposted `❓ DECISION N` block as an open decision under its number. */ @@ -263,30 +282,116 @@ export interface AgentsTalkConfig { } /** - * The Claude Code mods of the `genesis-tools-mods` plugin (`tools agents hooks mods install`). The plugin - * reads this section of the hooks file itself, so a change here needs no reinstall. + * The Claude Code mods of the `genesis-tools-mods` plugin (`tools agents hooks mods install`), one object per + * feature (`mods.<feature>.<field>`). The plugin reads this section of the hooks file itself, so a change here + * needs no reinstall. It keeps the same shape and defaults in its own `hooks/shared.ts` (`ModsSettings`), since it + * runs outside this repository; `mods-plugin.test.ts` holds the two equal. */ export interface ModsConfig { + diff: { + /** + * Draw the whole diff of a call whose hook message was cut, with Claude Code's own diff element, + * under the call's row. Reads `hook-diffs/<call>.md`, so it needs `diff.fullDiff` not `off`. + */ + fullDiff: boolean; + /** + * Start the diff of a call that runs `git pull`, `merge`, `rebase`, `checkout`, `switch`, `reset`, + * `cherry-pick`, `revert`, `am` or `stash pop|apply` hidden behind one `show diff from git pull` button: + * those diffs are other people's work arriving, often hundreds of lines. + */ + collapseGitSync: boolean; + }; + /** + * Compact an idle Claude session `minutes` after its last turn, before the 1-hour prompt cache expires, when + * the engine's own idle compaction did not (it gives up at its first refused gate, such as a rate-limit + * warning). Skips a context under `minTokens`. `minutes` is 1 to 59: after the engine's own try at 54 + * minutes, before the cache expires at 60. + */ + idleCompact: { enabled: boolean; minutes: number; minTokens: number }; + /** A subagent's model in front of its description: `sonnet · Pair one` (`label`) or `(S) Pair one` (`short`). */ + agentModel: { enabled: boolean; format: "label" | "short" | "version" }; + /** + * The agents panel rows under the prompt name each subagent's model beside its type + * (`general-purpose · sonnet-5-5 Reading foo.ts 4s · ↓ 58k tokens`). Claude Code's `subagentStatusLine` + * setting draws them, so `tools agents hooks install` (and `mods enable agentRow --write`) wires + * `hook-subagent-line.ts` there while this is on. `format`: the model as `sonnet-5-5` (`label`), `S` (`short`) or + * `S5.5` (`version`); `effort`: the effort beside it, `S5.5(xhigh)`. + */ + agentRow: { enabled: boolean; format: "label" | "short" | "version"; effort: boolean }; + /** + * The Decisions sidebar (`/decisions`, the band's `decisions` chip): every decision of the session from the + * decision log and its messages, by state, an answer staged per decision and sent in one message, and `jump to` + * the first ask. `chip`: shown while the session has any decision, only while one waits, or never. `expand`: + * which rows start open: the waiting ones, all, or none. `history`: the answered, done and closed decisions + * start folded under one `show N answered` row, or shown. `enter`: Enter in an answer field stages it, and Enter + * again on the staged, unchanged answer sends every staged answer (`stage-then-send`), or only stages (`stage`). + */ + decisions: { + enabled: boolean; + chip: "any" | "waiting" | "off"; + expand: "waiting" | "all" | "none"; + history: "collapsed" | "shown"; + enter: "stage-then-send" | "stage"; + }; /** - * Draw the whole diff of a call whose hook message was cut, with Claude Code's own diff element, - * under the call's row. Reads `hook-diffs/<call>.md`, so it needs `diff.fullDiff` not `off`. + * The Inbox sidebar (`/mod-inbox`, the band's `inbox` chip): the forms, messages, answers and todos the session + * put in the GenesisTools inbox. `chip`: shown while it has any, only while one is unread, or never. `expand`: + * which entries start open: the unread ones, all, or none. */ - fullDiff: boolean; + inbox: { enabled: boolean; chip: "any" | "unread" | "off"; expand: "unread" | "all" | "none" }; + /** Bare paths, folders, commits and repeated decisions in the main loop's reply become links as it streams. */ + links: { enabled: boolean }; /** - * Start the diff of a call that runs `git pull`, `merge`, `rebase`, `checkout`, `switch`, `reset`, - * `cherry-pick`, `revert`, `am` or `stash pop|apply` hidden behind one `show diff from git pull` button: - * those diffs are other people's work arriving, often hundreds of lines. + * The git band above the prompt: branch, unpushed, dirty, PRs with CI; `/mod-git` switches it per session. One + * row per checkout the session wrote files or changed git state in, the last first, up to `checkouts`; the + * session's own folder shows only when it worked there (`cwd: touched`) or first in every session (`always`). + * The Git sidebar folds each checkout to one line; `expand` says which start open: the first, all, or none. + * `name` names a row: `repo-folder` gives a linked worktree as `Kalendora/enhancements`, `repo` the main + * checkout's name alone, `folder` the worktree's folder. `earlier: show` keeps the session's earlier work: the PR + * of a branch it worked on and switched away from, and the row of a worktree it removed; `hide` shows only what + * the checkouts have now. */ - collapseGitSync: boolean; + git: { + enabled: boolean; + labels: "full" | "minimal"; + checkouts: number; + cwd: "touched" | "always"; + expand: "first" | "all" | "none"; + name: "repo-folder" | "repo" | "folder"; + earlier: "show" | "hide"; + }; + /** `[ask]` beside a finished tool row: quotes the row into the prompt. */ + askRow: { enabled: boolean }; + /** The running tool, its time and the working subagents on the spinner line. */ + spinner: { enabled: boolean }; + /** + * The prompt cache countdown in the band: yellow under `cautionMinutes`, red under `warnMinutes`; `others` adds + * the other sessions inside the yellow window. + */ + cacheBand: { enabled: boolean; warnMinutes: number; cautionMinutes: number; others: boolean }; + /** + * What the session keeps on disk (its /tmp/cc scratch folder, Claude Code's task output, the worktrees it made or + * holds) and the free disk, every `minutes`; the row opens the Session files sidebar. + */ + tmpUsage: { enabled: boolean; minutes: number }; + /** One band row per working subagent: model, age, idle time, tool calls; red past `warnMinutes`. */ + agentsBand: { enabled: boolean; warnMinutes: number }; + /** + * What the classic hooks hand the model: `tokens` shows the estimate as a band chip that opens a sidebar, `dedupe` + * replaces a context block already given to the same loop since its last compaction with a one-line receipt. + */ + hooks: { tokens: boolean; dedupe: boolean }; /** - * Compact an idle Claude session `idleCompactMinutes` after its last turn, before the 1-hour prompt - * cache expires, when the engine's own idle compaction did not (it gives up at its first refused gate, - * such as a rate-limit warning). Skips a context under `idleCompactMinTokens`. + * The sidebars a band row opens: `hints` says in the status bar at a sidebar's foot what the focused button does, + * `feedback` shows what a press did there (`line`), as a toast (`toast`), or both, and `focus` says when a docked + * sidebar takes the keys as it opens: when a slash command opened it (`command`), `always`, or `never`. */ - idleCompact: boolean; - /** 1 to 59: after the engine's own try at 54 minutes, before the cache expires at 60. */ - idleCompactMinutes: number; - idleCompactMinTokens: number; + sidebar: { hints: boolean; feedback: "line" | "toast" | "both"; focus: "command" | "always" | "never" }; + /** + * A main-loop reply that ends on what it was about to do while nothing can wake the session: the band shows the + * sentence with `continue` and `dismiss` (`button`), or continues by itself after `minutes` (`auto`). + */ + stall: { enabled: boolean; action: "button" | "auto"; minutes: number }; } /** @@ -425,6 +530,8 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { }, decisions: { stopHook: "off", + questionNudge: "off", + chatAnswers: "off", maxBlocksPerSession: 2, harnesses: ["claude", "codex", "grok"], harvest: false, @@ -432,12 +539,32 @@ export const DEFAULT_HOOKS_CONFIG: HooksConfig = { staleness: { warnAfterMinutes: 30, alarmAfterMinutes: 120, notify: true }, }, agentsTalk: { hint: true }, + // Every mod past the full diff and the idle compaction starts off: `tools agents hooks mods enable <name> --write`. mods: { - fullDiff: true, - collapseGitSync: true, - idleCompact: true, - idleCompactMinutes: 56, - idleCompactMinTokens: 200_000, + diff: { fullDiff: true, collapseGitSync: true }, + idleCompact: { enabled: true, minutes: 56, minTokens: 200_000 }, + agentModel: { enabled: false, format: "label" }, + agentRow: { enabled: false, format: "version", effort: true }, + decisions: { enabled: false, chip: "any", expand: "waiting", history: "collapsed", enter: "stage-then-send" }, + inbox: { enabled: false, chip: "any", expand: "none" }, + links: { enabled: false }, + git: { + enabled: false, + labels: "full", + checkouts: 3, + cwd: "touched", + expand: "first", + name: "repo-folder", + earlier: "show", + }, + askRow: { enabled: false }, + spinner: { enabled: false }, + cacheBand: { enabled: false, warnMinutes: 10, cautionMinutes: 20, others: true }, + tmpUsage: { enabled: false, minutes: 5 }, + agentsBand: { enabled: false, warnMinutes: 60 }, + hooks: { tokens: false, dedupe: false }, + sidebar: { hints: true, feedback: "line", focus: "command" }, + stall: { enabled: false, action: "button", minutes: 5 }, }, unpushed: { enabled: true, maxCommits: 3, maxAgeMinutes: 60, remindEveryMinutes: 30 }, logPath: defaultLogPath(), @@ -1010,30 +1137,149 @@ export function mergeStoredConfig(stored: StoredHooksConfig): HooksConfig { const STOP_HOOK_MODES: readonly DecisionStopHook[] = ["off", "warn", "block"]; -/** Field by field, like `diff`: a stored value of the wrong type falls back to the default. */ -function mergeMods(stored: Partial<ModsConfig> | undefined): ModsConfig { +function isPlainObject(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function partOf(value: unknown): Record<string, unknown> { + return isPlainObject(value) ? value : {}; +} + +/** `value` when it is one of `choices`, else `fallback`. */ +function oneOf<T extends string>(value: unknown, choices: readonly T[], fallback: T): T { + return choices.find((choice) => choice === value) ?? fallback; +} + +/** + * Field by field, like `diff`: a stored value of the wrong type falls back to the default. The flat keys of before + * 2026-10-23 (`mods.fullDiff`, `mods.collapseGitSync`, `mods.idleCompact` as a boolean, `mods.idleCompactMinutes`, + * `mods.idleCompactMinTokens`) still count where the object leaves a field out; the next write stores the objects. + */ +export function mergeMods(stored: unknown): ModsConfig { const base = DEFAULT_HOOKS_CONFIG.mods; + const held = partOf(stored); + const diff = partOf(held.diff); + const idle = partOf(held.idleCompact); + const legacyIdle = typeof held.idleCompact === "boolean" ? held.idleCompact : undefined; + const model = partOf(held.agentModel); + const row = partOf(held.agentRow); + const cache = partOf(held.cacheBand); + const tmp = partOf(held.tmpUsage); + const agents = partOf(held.agentsBand); + const hooks = partOf(held.hooks); + const sidebar = partOf(held.sidebar); + const stall = partOf(held.stall); + const enabled = (key: string, fallback: boolean) => boolOr(partOf(held[key]).enabled, fallback); + const git = partOf(held.git); + const decisions = partOf(held.decisions); + const inbox = partOf(held.inbox); const minutes = countOr({ - field: "mods.idleCompactMinutes", - value: stored?.idleCompactMinutes, - fallback: base.idleCompactMinutes, + field: "mods.idleCompact.minutes", + value: idle.minutes ?? held.idleCompactMinutes, + fallback: base.idleCompact.minutes, }); return { - fullDiff: boolOr(stored?.fullDiff, base.fullDiff), - collapseGitSync: boolOr(stored?.collapseGitSync, base.collapseGitSync), - idleCompact: boolOr(stored?.idleCompact, base.idleCompact), - // The mod reads the file itself and drops 60 or more the same way. - idleCompactMinutes: minutes < 60 ? minutes : base.idleCompactMinutes, - idleCompactMinTokens: countOr({ - field: "mods.idleCompactMinTokens", - value: stored?.idleCompactMinTokens, - fallback: base.idleCompactMinTokens, - min: 0, - }), + diff: { + fullDiff: boolOr(diff.fullDiff, boolOr(held.fullDiff, base.diff.fullDiff)), + collapseGitSync: boolOr(diff.collapseGitSync, boolOr(held.collapseGitSync, base.diff.collapseGitSync)), + }, + idleCompact: { + enabled: boolOr(idle.enabled, legacyIdle ?? base.idleCompact.enabled), + // The mod reads the file itself and drops 60 or more the same way. + minutes: minutes < 60 ? minutes : base.idleCompact.minutes, + minTokens: countOr({ + field: "mods.idleCompact.minTokens", + value: idle.minTokens ?? held.idleCompactMinTokens, + fallback: base.idleCompact.minTokens, + min: 0, + }), + }, + agentModel: { + enabled: boolOr(model.enabled, base.agentModel.enabled), + format: oneOf(model.format, ["label", "short", "version"], base.agentModel.format), + }, + agentRow: { + enabled: boolOr(row.enabled, base.agentRow.enabled), + format: oneOf(row.format, ["label", "short", "version"], base.agentRow.format), + effort: boolOr(row.effort, base.agentRow.effort), + }, + decisions: { + enabled: enabled("decisions", base.decisions.enabled), + chip: oneOf(decisions.chip, ["any", "waiting", "off"], base.decisions.chip), + expand: oneOf(decisions.expand, ["waiting", "all", "none"], base.decisions.expand), + history: oneOf(decisions.history, ["collapsed", "shown"], base.decisions.history), + enter: oneOf(decisions.enter, ["stage-then-send", "stage"], base.decisions.enter), + }, + inbox: { + enabled: enabled("inbox", base.inbox.enabled), + chip: oneOf(inbox.chip, ["any", "unread", "off"], base.inbox.chip), + expand: oneOf(inbox.expand, ["unread", "all", "none"], base.inbox.expand), + }, + links: { enabled: enabled("links", base.links.enabled) }, + git: { + enabled: enabled("git", base.git.enabled), + labels: git.labels === "full" || git.labels === "minimal" ? git.labels : base.git.labels, + checkouts: minutesOr("mods.git.checkouts", git.checkouts, base.git.checkouts, 6), + cwd: git.cwd === "touched" || git.cwd === "always" ? git.cwd : base.git.cwd, + expand: + git.expand === "first" || git.expand === "all" || git.expand === "none" ? git.expand : base.git.expand, + name: git.name === "repo-folder" || git.name === "repo" || git.name === "folder" ? git.name : base.git.name, + earlier: git.earlier === "show" || git.earlier === "hide" ? git.earlier : base.git.earlier, + }, + askRow: { enabled: enabled("askRow", base.askRow.enabled) }, + spinner: { enabled: enabled("spinner", base.spinner.enabled) }, + cacheBand: { + enabled: boolOr(cache.enabled, base.cacheBand.enabled), + warnMinutes: minutesOr("mods.cacheBand.warnMinutes", cache.warnMinutes, base.cacheBand.warnMinutes, 59), + cautionMinutes: minutesOr( + "mods.cacheBand.cautionMinutes", + cache.cautionMinutes, + base.cacheBand.cautionMinutes, + 59 + ), + others: boolOr(cache.others, base.cacheBand.others), + }, + tmpUsage: { + enabled: boolOr(tmp.enabled, base.tmpUsage.enabled), + minutes: minutesOr("mods.tmpUsage.minutes", tmp.minutes, base.tmpUsage.minutes, 120), + }, + agentsBand: { + enabled: boolOr(agents.enabled, base.agentsBand.enabled), + warnMinutes: minutesOr( + "mods.agentsBand.warnMinutes", + agents.warnMinutes, + base.agentsBand.warnMinutes, + 24 * 60 + ), + }, + hooks: { tokens: boolOr(hooks.tokens, base.hooks.tokens), dedupe: boolOr(hooks.dedupe, base.hooks.dedupe) }, + sidebar: { + hints: boolOr(sidebar.hints, base.sidebar.hints), + feedback: + sidebar.feedback === "line" || sidebar.feedback === "toast" || sidebar.feedback === "both" + ? sidebar.feedback + : base.sidebar.feedback, + focus: + sidebar.focus === "command" || sidebar.focus === "always" || sidebar.focus === "never" + ? sidebar.focus + : base.sidebar.focus, + }, + stall: { + enabled: boolOr(stall.enabled, base.stall.enabled), + action: stall.action === "button" || stall.action === "auto" ? stall.action : base.stall.action, + minutes: minutesOr("mods.stall.minutes", stall.minutes, base.stall.minutes, 120), + }, }; } +/** A whole number of minutes from 1 to `max`, as the plugin reads it; else the default. */ +function minutesOr(field: string, value: unknown, fallback: number, max: number): number { + const minutes = countOr({ field, value, fallback }); + + return minutes <= max ? minutes : fallback; +} + function mergeUnpushed(stored: Partial<UnpushedConfig> | undefined): UnpushedConfig { const base = DEFAULT_HOOKS_CONFIG.unpushed; @@ -1070,6 +1316,10 @@ function mergeDecisions(stored: Partial<DecisionsHookConfig> | undefined): Decis stopHook: STOP_HOOK_MODES.includes(stored?.stopHook as DecisionStopHook) ? (stored?.stopHook as DecisionStopHook) : base.stopHook, + questionNudge: STOP_HOOK_MODES.includes(stored?.questionNudge as DecisionStopHook) + ? (stored?.questionNudge as DecisionStopHook) + : base.questionNudge, + chatAnswers: oneOf(stored?.chatAnswers, DECISION_CHAT_ANSWERS, base.chatAnswers), maxBlocksPerSession: countOr({ field: "decisions.maxBlocksPerSession", value: stored?.maxBlocksPerSession, @@ -1100,6 +1350,16 @@ export function decisionHooksWanted(config: HooksConfig): boolean { return config.decisions.stopHook !== "off" || config.decisions.harvest || config.decisions.injectAnswers; } +/** Whether the Stop hook has decision work: the hub's own checks, or the question nudge, which needs only Stop. */ +export function decisionStopWanted(config: HooksConfig): boolean { + return decisionHooksWanted(config) || stopOnlyDecisionsWanted(config); +} + +/** The decision checks that need only the Stop entry: the question nudge and the chat answers. */ +export function stopOnlyDecisionsWanted(config: HooksConfig): boolean { + return config.decisions.questionNudge !== "off" || config.decisions.chatAnswers !== "off"; +} + /** Whether this run should record the command verbatim in the decision log. */ export function keepsCommand(config: HooksConfig): boolean { if (config.logCommands === "always") { diff --git a/src/agents/lib/hooks/decisions.ts b/src/agents/lib/hooks/decisions.ts index 7609fd414c..7a11006092 100644 --- a/src/agents/lib/hooks/decisions.ts +++ b/src/agents/lib/hooks/decisions.ts @@ -1,33 +1,94 @@ import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { type AmbiguousAnswer, type ChatAnswer, chatAnswers, userText } from "@app/question/lib/decisions/chat-answers"; import { + decisionQuestion, + decisionQuestionRefs, mentionedDecisions, parseDecisionBlocks, + questionNudgeVerdict, type SentDecisions, sendSessionDecisions, stopHookVerdict, } from "@app/question/lib/decisions/read"; -import { harvestDecisions, kindOf, readDecisions, recordDelivery } from "@app/question/lib/decisions/store"; +import { + type DecisionRecord, + harvestDecisions, + kindOf, + readDecisions, + recordChatAnswers, + recordDelivery, +} from "@app/question/lib/decisions/store"; +import type { BackwardRead, ReadBackwardOptions } from "@genesiscz/utils/agent-sessions/types"; import { SafeJSON } from "@genesiscz/utils/json"; +import { appendChatAnswerRun, type ChatAnswerRun, loggedUserMessage } from "./chat-answer-log"; import type { DecisionsHookConfig } from "./config"; import { hookDiag } from "./log"; import type { HookPayload } from "./payload"; -import { bumpContextCounts, readContextCounts, safeSessionId } from "./state"; +import { bumpContextCounts, readContextCounts, safeSessionId, setContextValues } from "./state"; /** The per-session counter the loop guard reads; it lives beside the guard's context counts. */ export const STOP_BLOCK_COUNTER = "decisions-stop-block"; +/** The question nudge's own block counter, so it and the `❓ DECISION N` check do not spend each other's budget. */ +export const NUDGE_BLOCK_COUNTER = "decisions-nudge-block"; +/** The start (epoch ms) of the last turn the question nudge spoke in: it speaks once per turn. */ +export const NUDGE_TURN_KEY = "decisions-nudge-turn"; + +/** When the chat-answer step last read the session (epoch ms): the next read stops there. */ +export const CHAT_CHECKED_KEY = "decisions-chat-checked"; +/** Prefix of the per-decision mark that the chat-answer step already sent the agent back about it. */ +export const CHAT_SENT_BACK_PREFIX = "decisions-chat-sent-back:"; +/** The most the chat-answer step reads back from a transcript's end, when no time bounds the read (Grok). */ +const CHAT_READ_BYTES = 8 * 1024 * 1024; /** How much of a transcript's end is read to find the last reply. A final reply is far smaller. */ const TAIL_BYTES = 512 * 1024; +/** + * How much of a transcript's end is searched for the prompt that opened the turn. A long turn's tool output runs to + * megabytes, and this read happens only for a reply that asks the user to decide. + */ +const PROMPT_TAIL_BYTES = 16 * 1024 * 1024; export interface DecisionLogPaths { file: string; events: string; } +/** The question nudge's per-session memory: its blocks so far and the last turn it spoke in. */ +export interface NudgeState { + blocks: (session: string) => number; + lastTurn: (session: string) => number | undefined; + record: (session: string, turn: number, blocked: boolean) => void; +} + +/** The chat-answer step's per-session memory: when it last read, and which decisions it sent the agent back about. */ +export interface ChatAnswerState { + checkedAt: (session: string) => number | undefined; + setCheckedAt: (session: string, at: number) => void; + sentBack: (session: string, id: string) => boolean; + markSentBack: (session: string, ids: string[]) => void; +} + +/** A session's newest entries through its provider's reader (`readSessionBackward`). */ +export type ReadSession = ( + options: ReadBackwardOptions & { kind: string; transcriptPath: string } +) => Promise<BackwardRead>; + export interface DecisionHookDeps { log: DecisionLogPaths; + /** + * Reads the session for the chat-answer step. Passed by the Stop entry only while `decisions.chatAnswers` is on, + * so the provider registry loads only then; without it the step does nothing. + */ + readSession?: ReadSession; + /** The chat-answer step's state store; tests pass an in-memory one. */ + chat?: ChatAnswerState; + /** Where each chat-answer run is logged (`chat-answers.jsonl`); tests collect them instead. */ + appendRun?: (run: ChatAnswerRun) => void; + now?: () => number; /** The loop guard's counter store; tests pass an in-memory one. */ counts?: { read: (session: string) => number; bump: (session: string) => void }; + /** The question nudge's state store; tests pass an in-memory one. */ + nudge?: NudgeState; readTail?: (path: string) => string; } @@ -37,12 +98,12 @@ function str(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function readTail(path: string): string { +function readTail(path: string, bytes = TAIL_BYTES): string { const fd = openSync(path, "r"); try { const size = fstatSync(fd).size; - const start = Math.max(0, size - TAIL_BYTES); + const start = Math.max(0, size - bytes); const buffer = Buffer.alloc(size - start); readSync(fd, buffer, 0, buffer.length, start); return buffer.toString("utf8"); @@ -124,11 +185,379 @@ const fileCounts = { bump: (session: string) => bumpContextCounts(session, [STOP_BLOCK_COUNTER]), }; +const fileNudgeState: NudgeState = { + blocks: (session) => readContextCounts(session)[NUDGE_BLOCK_COUNTER] ?? 0, + lastTurn: (session) => readContextCounts(session)[NUDGE_TURN_KEY], + record: (session, turn, blocked) => { + const blocks = readContextCounts(session)[NUDGE_BLOCK_COUNTER] ?? 0; + + setContextValues(session, { [NUDGE_TURN_KEY]: turn, [NUDGE_BLOCK_COUNTER]: blocked ? blocks + 1 : blocks }); + }, +}; + +const fileChatState: ChatAnswerState = { + checkedAt: (session) => readContextCounts(session)[CHAT_CHECKED_KEY], + setCheckedAt: (session, at) => setContextValues(session, { [CHAT_CHECKED_KEY]: at }), + sentBack: (session, id) => readContextCounts(session)[`${CHAT_SENT_BACK_PREFIX}${id}`] !== undefined, + markSentBack: (session, ids) => + setContextValues(session, Object.fromEntries(ids.map((id) => [`${CHAT_SENT_BACK_PREFIX}${id}`, 1]))), +}; + +function lettersFor(count: number): string[] { + return Array.from({ length: count }, (_, index) => String.fromCharCode(97 + index)); +} + +/** When a decision was asked as it stands: its latest revision, else its post. */ +function askedAt(row: DecisionRecord): string { + return row.revisedTs ?? row.createdTs ?? row.updatedTs; +} + +function quoted(evidence: { text: string }): string { + const text = evidence.text.length > 160 ? `${evidence.text.slice(0, 159)}…` : evidence.text; + + return SafeJSON.stringify(text); +} + +function answerLine(row: DecisionRecord, answer: ChatAnswer): string { + return `❓ DECISION ${row.number}: ${answer.option ? `${answer.option}) ` : ""}from ${quoted(answer.evidence)}`; +} + +function unclearLine(row: DecisionRecord, item: AmbiguousAnswer): string { + return `❓ DECISION ${row.number} (${item.reason}): ${quoted(item.evidence)}`; +} + +/** + * The chat-answer step (`decisions.chatAnswers`): the open decisions of the session, the transcript since the oldest + * was asked (or since the last check, whichever is later) read through the session's provider, and the user's chat + * answers in it. `record` stores each unambiguous one as `automatic_heuristic` and tells the user; an unclear one, + * and in `remind` every one found, sends the agent back once per decision to record it with question_update. + */ +async function runChatAnswers( + payload: HookPayload, + session: string, + config: DecisionsHookConfig, + deps: DecisionHookDeps +): Promise<StopHookOutput | null> { + const transcriptPath = str(payload.raw.transcript_path) ?? str(payload.raw.transcriptPath); + + if (config.chatAnswers === "off" || !deps.readSession || !transcriptPath) { + return null; + } + + const open = readDecisions(deps.log.file).filter( + (row) => + row.sessionId === session && kindOf(row) === "decision" && (row.state === "open" || row.state === "drafted") + ); + + if (open.length === 0) { + return null; + } + + const state = deps.chat ?? fileChatState; + const now = deps.now?.() ?? Date.now(); + const asked = open.map((row) => Date.parse(askedAt(row))).filter(Number.isFinite); + const from = Math.max(asked.length > 0 ? Math.min(...asked) : 0, state.checkedAt(session) ?? 0); + const read = await deps.readSession({ + kind: payload.harness, + transcriptPath, + maxBytes: CHAT_READ_BYTES, + until: (entry) => { + const at = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN; + + return Number.isFinite(at) && at < from; + }, + }); + state.setCheckedAt(session, now); + + if (!read.found && from > 0) { + // Not an error for Grok (its rows carry no time), but for Claude and Codex a read that never reached the + // oldest open ask means an answer before the cut is not seen. + hookDiag("Chat answers: the read ended before the oldest open ask", { + session, + harness: payload.harness, + from: new Date(from).toISOString(), + entries: read.entries.length, + issues: read.issues.slice(0, 3), + }); + } + + const byNumber = new Map(open.map((row) => [row.number, row])); + const found = chatAnswers( + read.entries, + open.map((row) => ({ number: row.number, letters: lettersFor(row.options.length), askedAt: askedAt(row) })) + ); + const pairs = <T extends { number: number }>(items: T[]) => + items.flatMap((item) => { + const row = byNumber.get(item.number); + + return row ? [{ row, item }] : []; + }); + const answers = pairs(found.unambiguous); + const unclear = pairs(found.ambiguous); + let recorded: DecisionRecord[] = []; + + if (config.chatAnswers === "record" && answers.length > 0) { + recorded = await recordChatAnswers({ + file: deps.log.file, + events: deps.log.events, + answers: answers.map(({ row, item }) => ({ + id: row.id, + ...(item.option ? { option: item.option } : {}), + answer: item.answer, + evidence: item.evidence, + })), + }); + } + + const recordedNote = + recorded.length > 0 + ? `Recorded the chat answer to ❓ DECISION ${recorded.map((row) => `${row.number}${row.option ? ` ${row.option})` : ""}`).join(", ")} from the transcript (automatic_heuristic).` + : ""; + const toRecord = + config.chatAnswers === "remind" ? answers.filter(({ row }) => !state.sentBack(session, row.id)) : []; + const toCheck = unclear.filter(({ row }) => !state.sentBack(session, row.id)); + const users = read.entries.filter((entry) => entry.role === "user" && userText(entry) !== undefined); + + // Every run that read a user message is kept, a miss included: `tools agents hooks chat-answers --misses` lists the + // messages that matched nothing, so an answer the finder should have taken is found later. + if (users.length > 0 || found.unambiguous.length > 0 || found.ambiguous.length > 0) { + (deps.appendRun ?? appendChatAnswerRun)({ + at: new Date(now).toISOString(), + harness: payload.harness, + session, + transcript: transcriptPath, + mode: config.chatAnswers, + open: open.map((row) => ({ id: row.id, number: row.number, askedAt: askedAt(row) })), + read: { + ...(from > 0 ? { from: new Date(from).toISOString() } : {}), + found: read.found, + entries: read.entries.length, + issues: read.issues.length, + }, + users: users.map((entry) => loggedUserMessage(entry)), + unambiguous: found.unambiguous, + ambiguous: found.ambiguous, + recorded: recorded.map((row) => row.id), + sentBack: [...toRecord, ...toCheck].map(({ row }) => row.id), + }); + } + + if (toRecord.length === 0 && toCheck.length === 0) { + return recordedNote ? { systemMessage: recordedNote } : null; + } + + state.markSentBack( + session, + [...toRecord, ...toCheck].map(({ row }) => row.id) + ); + const parts = [ + recordedNote, + toRecord.length > 0 + ? `The user answered in the chat:\n${toRecord.map(({ row, item }) => answerLine(row, item)).join("\n")}\nRecord each with question_update (state acknowledged, option or answer) unless you already did.` + : "", + toCheck.length > 0 + ? `The user's messages since these decisions were asked talk about them without a clear answer:\n${toCheck.map(({ row, item }) => unclearLine(row, item)).join("\n")}\nIf one is an answer, record it with question_update (state acknowledged, option or answer); otherwise leave it open.` + : "", + "The decision Stop hook says this once per decision.", + ]; + + return { decision: "block", reason: parts.filter(Boolean).join("\n\n") }; +} + +/** Two Stop outputs as one: any block wins and carries every text. */ +function mergedStop(outputs: (StopHookOutput | null)[]): StopHookOutput | null { + const given = outputs.filter((output): output is StopHookOutput => output !== null); + const texts = given.map((output) => ("decision" in output ? output.reason : output.systemMessage)); + + if (given.length === 0) { + return null; + } + + return given.some((output) => "decision" in output) + ? { decision: "block", reason: texts.join("\n\n") } + : { systemMessage: texts.join(" ") }; +} + +/** + * The time (epoch ms) of a transcript line when it is a prompt that opens a turn, else null. Claude: a `user` row + * that is not `isMeta` (Stop hook feedback and injected skill text are meta) and carries text, not only tool + * results; a background task's notification counts, since it starts a turn too. Codex: a `user` message item or a + * `user_message` event. Both write a top-level ISO `timestamp`. + */ +function promptTime(line: string): number | null { + if (!line.includes('"type":"user"') && !line.includes('"role":"user"') && !line.includes('"user_message"')) { + return null; + } + + let row: unknown; + + try { + row = SafeJSON.parse(line, { strict: true }); + } catch { + // The first line of a tail read starts mid-row; it is not a prompt either way. + return null; + } + + if (typeof row !== "object" || row === null) { + return null; + } + + const record = row as { + type?: unknown; + isMeta?: unknown; + isSidechain?: unknown; + timestamp?: unknown; + message?: { content?: unknown }; + payload?: { type?: unknown; role?: unknown }; + }; + const at = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN; + + if (Number.isNaN(at)) { + return null; + } + + if (record.type === "user") { + const content = record.message?.content; + const hasText = + typeof content === "string" + ? content.length > 0 + : Array.isArray(content) && content.some((block: { type?: unknown }) => block?.type === "text"); + + return hasText && record.isMeta !== true && record.isSidechain !== true ? at : null; + } + + const payload = record.payload; + const codexPrompt = + (record.type === "response_item" && payload?.type === "message" && payload.role === "user") || + (record.type === "event_msg" && payload?.type === "user_message"); + + return codexPrompt ? at : null; +} + +/** + * When the turn this Stop ends began: the time of the last prompt in the transcript (`promptTime`), epoch ms. Null + * when the payload names no transcript, it cannot be read, or its last `PROMPT_TAIL_BYTES` hold no prompt. + */ +export function turnStartedAt( + payload: HookPayload, + read: (path: string) => string = (path) => readTail(path, PROMPT_TAIL_BYTES) +): number | null { + const transcript = str(payload.raw.transcript_path) ?? str(payload.raw.transcriptPath); + + if (!transcript) { + return null; + } + + try { + const lines = read(transcript).split("\n"); + + for (let index = lines.length - 1; index >= 0; index--) { + const at = promptTime(lines[index] ?? ""); + + if (at !== null) { + return at; + } + } + } catch (err) { + hookDiag("Could not read the transcript for the turn's prompt", { err, transcript }); + } + + return null; +} + +/** Whether the session posted a decision at or after `since`: a new post, or a new version of one. */ +function decisionLoggedSince(file: string, session: string, since: number): boolean { + return readDecisions(file).some( + (row) => + row.sessionId === session && + kindOf(row) === "decision" && + Date.parse(row.revisedTs ?? row.createdTs ?? row.updatedTs) >= since + ); +} + +/** + * The question nudge: a reply that asks the user to decide (`decisionQuestion`) while the session posted no decision + * since the turn's prompt. It speaks at most once per turn. A Stop that follows a Stop hook's block passes + * (`stop_hook_active`, sent by Claude Code), and so does any later Stop of a turn it already spoke in, which covers a + * harness that does not send that flag. + */ +function runQuestionNudge({ + payload, + session, + reply, + config, + deps, +}: { + payload: HookPayload; + session: string; + reply: string; + config: DecisionsHookConfig; + deps: DecisionHookDeps; +}): StopHookOutput | null { + const question = decisionQuestion(reply); + + if (!question || payload.raw.stop_hook_active === true || payload.raw.stopHookActive === true) { + return null; + } + + const turn = turnStartedAt(payload, deps.readTail); + + if (turn === null) { + hookDiag("The question nudge found no prompt in the transcript, so it stays quiet", { session }); + return null; + } + + const state = deps.nudge ?? fileNudgeState; + + if (state.lastTurn(session) === turn) { + return null; + } + + // The log, not the turn, says whether a decision reached the inbox: a question about `D21` posted an hour ago + // is logged even though nothing was posted this turn (2026-10-25). + const refs = decisionQuestionRefs(reply); + const logged = + refs.length > 0 + ? readDecisions(deps.log.file).filter( + (row) => row.sessionId === session && kindOf(row) === "decision" && refs.includes(row.number) + ) + : []; + + if (logged.length > 0) { + return null; + } + + const verdict = questionNudgeVerdict( + { + questionNudge: config.questionNudge, + maxBlocksPerSession: config.maxBlocksPerSession, + blocksUsed: state.blocks(session), + }, + question, + decisionLoggedSince(deps.log.file, session, turn) + ); + + if (verdict.action === "block" && verdict.reason) { + state.record(session, turn, true); + return { decision: "block", reason: verdict.reason }; + } + + if (verdict.action === "warn" && verdict.reason) { + state.record(session, turn, false); + return { systemMessage: verdict.reason }; + } + + return null; +} + /** * The Stop hook. Returns null, meaning "print nothing", for every turn it has no business with: - * the feature off, a harness not listed, no session, or a reply that asks no `❓ DECISION`. - * Otherwise it harvests unposted blocks when asked to, then warns or blocks on what is still - * unposted. A block is counted per session, and past `maxBlocksPerSession` it only warns. + * the feature off, a harness not listed, no session, or a reply that asks no `❓ DECISION` and, + * with the question nudge on, nothing for the user to decide. + * A reply that names `❓ DECISION N` is this check's alone: it harvests unposted blocks when asked + * to, then warns or blocks on what is still unposted. A block is counted per session, and past + * `maxBlocksPerSession` it only warns. Any other reply goes to the question nudge (`runQuestionNudge`). + * Before those, the chat-answer step (`decisions.chatAnswers`) records answers the user typed in the chat. */ export async function runDecisionStop( payload: HookPayload, @@ -137,7 +566,32 @@ export async function runDecisionStop( ): Promise<StopHookOutput | null> { const session = safeSessionId(payload.sessionId); - if ((config.stopHook === "off" && !config.harvest) || !session || !config.harnesses.includes(payload.harness)) { + if (!session || !config.harnesses.includes(payload.harness)) { + return null; + } + + let chat: StopHookOutput | null = null; + + try { + chat = await runChatAnswers(payload, session, config, deps); + } catch (err) { + // A chat read that fails costs the automatic record, never the reply checks below. + hookDiag("Chat answers failed; the reply checks still run", { err, session }); + } + + return mergedStop([chat, await runReplyChecks(payload, session, config, deps)]); +} + +/** The checks on the reply itself: unposted `❓ DECISION N` blocks (harvest, warn, block) and the question nudge. */ +async function runReplyChecks( + payload: HookPayload, + session: string, + config: DecisionsHookConfig, + deps: DecisionHookDeps +): Promise<StopHookOutput | null> { + const nudging = config.questionNudge !== "off"; + + if (config.stopHook === "off" && !config.harvest && !nudging) { return null; } @@ -145,6 +599,10 @@ export async function runDecisionStop( const mentioned = mentionedDecisions(reply); if (mentioned.length === 0) { + return nudging ? runQuestionNudge({ payload, session, reply, config, deps }) : null; + } + + if (config.stopHook === "off" && !config.harvest) { return null; } diff --git a/src/agents/lib/hooks/diff/diff.test.ts b/src/agents/lib/hooks/diff/diff.test.ts index 9e32a84bd0..3a3bfaa563 100644 --- a/src/agents/lib/hooks/diff/diff.test.ts +++ b/src/agents/lib/hooks/diff/diff.test.ts @@ -4010,6 +4010,8 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () expect(stripAnsi(decision.message ?? "")).toContain(`Full diff: ${path}`); expect((decision.message ?? "").length).toBeLessThanOrEqual(900); expect(readFileSync(path, "utf8")).toContain("+line 79"); + // The message showed the diff, so the note's name keeps any copy of the full-diff mod from drawing it too. + expect(basename(path)).toEndWith(".hook.md"); } finally { rmSync(join(repo, "big.ts"), { force: true }); rmSync(path, { force: true }); @@ -4031,6 +4033,7 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () expect(decision.message).toBeUndefined(); expect(decision.files).toEqual([join(repo, "small.ts")]); expect(readFileSync(path, "utf8")).toContain("+two"); + expect(basename(path)).toMatch(/^call-mod-\d+\.md$/); } finally { rmSync(join(repo, "small.ts"), { force: true }); rmSync(path, { force: true }); @@ -4111,7 +4114,7 @@ describe("the claude theme: Claude Code's own Bash diff, drawn by the hook", () expect(modDrawsDiffs({ config: on, harness: "codex", isEnabled: yes })).toBe(false); expect( modDrawsDiffs({ - config: { ...on, mods: { ...on.mods, fullDiff: false } }, + config: { ...on, mods: { ...on.mods, diff: { ...on.mods.diff, fullDiff: false } } }, harness: "claude", isEnabled: yes, }) diff --git a/src/agents/lib/hooks/diff/full.ts b/src/agents/lib/hooks/diff/full.ts index 52f0147362..662ac395da 100644 --- a/src/agents/lib/hooks/diff/full.ts +++ b/src/agents/lib/hooks/diff/full.ts @@ -63,12 +63,21 @@ export function overflowPart(path: string, patch: string): FullDiffPart { */ export const DEFERRED_NOTE_SUFFIX = ".task"; +/** + * The note of a call whose diff the hook's own message showed (the session has no mod mark): the mod draws only + * `<call>.md`, so this name keeps any loaded copy of it from drawing the diff a second time. A session that loaded the + * mod before the mark existed never sets it, and drew such calls twice (shop-tools, 2026-10-23). `diff show <call>` still + * finds it, by prefix. + */ +export const HOOK_NOTE_SUFFIX = ".hook"; + export function writeFullDiff(options: { toolUseId: string | undefined; command: string; parts: FullDiffPart[]; notShown: string[]; deferred?: boolean; + shownByHook?: boolean; }): string | null { const call = safeSegment(options.toolUseId); @@ -103,7 +112,8 @@ export function writeFullDiff(options: { ? ["## Changed, past the file cap", "", ...options.notShown.map((path) => `- ${path}`), ""] : []), ]; - const path = join(fullDiffDir(), `${call}${options.deferred ? DEFERRED_NOTE_SUFFIX : ""}.md`); + const suffix = options.deferred ? DEFERRED_NOTE_SUFFIX : options.shownByHook ? HOOK_NOTE_SUFFIX : ""; + const path = join(fullDiffDir(), `${call}${suffix}.md`); try { mkdirSync(fullDiffDir(), { recursive: true, mode: 0o700 }); diff --git a/src/agents/lib/hooks/diff/run.ts b/src/agents/lib/hooks/diff/run.ts index 5a1b5b6011..c70f3d5762 100644 --- a/src/agents/lib/hooks/diff/run.ts +++ b/src/agents/lib/hooks/diff/run.ts @@ -730,7 +730,7 @@ function installedMod(sessionId: string | undefined): boolean { } /** - * Claude Code with the full-diff mod (`genesis-tools-mods`) loaded in this session and `mods.fullDiff` on: the mod + * Claude Code with the full-diff mod (`genesis-tools-mods`) loaded in this session and `mods.diff.fullDiff` on: the mod * draws each call's diff from its note, so the hook's own message would draw it twice. Codex and Grok have no mods. */ export function modDrawsDiffs({ @@ -746,7 +746,7 @@ export function modDrawsDiffs({ }): boolean { return ( harness === "claude" && - config.mods.fullDiff && + config.mods.diff.fullDiff && diffFor(config, harness).fullDiff !== "off" && isEnabled(sessionId) ); @@ -1795,6 +1795,7 @@ export function runDiffPost({ parts: [...blocks.flatMap((block) => (block.source ? [block.source] : [])), ...overflow], notShown: notShown.filter((path) => !overflow.some((part) => part.path === path)), deferred, + shownByHook: true, }); const line = path ? fullDiffLine(path, diff.fullDiff) : null; diff --git a/src/agents/lib/hooks/hooks.test.ts b/src/agents/lib/hooks/hooks.test.ts index 46a5bd0124..ff57f6bc08 100644 --- a/src/agents/lib/hooks/hooks.test.ts +++ b/src/agents/lib/hooks/hooks.test.ts @@ -18,15 +18,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { withSpawnCounter } from "@app/benchmark/lib"; import { postDecisions, readDecisions, updateDecision } from "@app/question/lib/decisions/store"; +import type { NativeHistoryEntry } from "@genesiscz/utils/agent-sessions/types"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { SHELL_RULES } from "@genesiscz/utils/shell/rules"; +import { type ChatAnswerRun, userMessageOutcome } from "./chat-answer-log"; import { codexEntriesFor, codexWiring, installCodexHooks, uninstallCodexHooks } from "./codex"; import { changedOnly, DEFAULT_HOOKS_CONFIG, type DecisionsHookConfig, decisionHooksWanted, + decisionStopWanted, defaultLogPath, diffFor, keepsCommand, @@ -38,7 +41,7 @@ import { } from "./config"; import { recentBashCommands } from "./corpus"; import { type DetectFailure, expandRulePath, guardRules, loadCustomRules, loadRuleSet } from "./custom-rules"; -import { finalReply, type PromptHookOutput, runDecisionInject, runDecisionStop } from "./decisions"; +import { finalReply, type PromptHookOutput, runDecisionInject, runDecisionStop, turnStartedAt } from "./decisions"; import { collectStaleCaptures, parseHorizon } from "./gc"; import { evaluateCommand, evaluateGuard } from "./guard"; import { lastSeenPerHarness } from "./harnesses"; @@ -50,7 +53,9 @@ import { pointDist, readSettings, repoRoot, + subagentLineFor, uninstallHooks, + wireSubagentLine, wiringStatus, } from "./install"; import { logDecision, releaseRotationLock, setDiagLogPath, setMaxLogBytes, takeRotationLock } from "./log"; @@ -339,6 +344,53 @@ describe("installHooks", () => { expect(after.hooks?.PostToolUse?.[0]?.matcher).toBe("Bash|Edit|MultiEdit|Write"); }); + it("wires subagentStatusLine while mods.agentRow is on, removes its own when off, and leaves another one", () => { + const path = settingsFile({ hooks: {} }); + + expect(installHooks({ dist: "/dist", settingsPath: path, write: true, agentRow: true }).added).toContain( + "subagentStatusLine" + ); + expect(readSettings(path).subagentStatusLine).toEqual({ + type: "command", + command: "bun '/dist/src/agents/bin/hook-subagent-line.ts'", + }); + + expect(installHooks({ dist: "/dist", settingsPath: path, write: true, agentRow: false }).removed).toContain( + "subagentStatusLine" + ); + expect(readSettings(path).subagentStatusLine).toBeUndefined(); + + const theirs = { type: "command", command: "/home/alice/rows.sh" }; + const other = settingsFile({ subagentStatusLine: theirs }); + + expect(installHooks({ dist: "/dist", settingsPath: other, write: true, agentRow: true }).subagentLine).toBe( + "theirs" + ); + expect(wireSubagentLine({ on: false, write: true, dist: "/dist", settingsPath: other })).toBe("absent"); + expect(readSettings(other).subagentStatusLine).toEqual(theirs); + }); + + it("replaces another subagentStatusLine only when asked, and disable or uninstall puts it back", () => { + const theirs = { type: "command", command: "/home/alice/rows.sh" }; + const path = settingsFile({ subagentStatusLine: theirs }); + const wire = (on: boolean, replace?: boolean) => + wireSubagentLine({ on, write: true, replace, dist: "/dist", settingsPath: path }); + + expect(wire(true, true)).toBe("replaced"); + expect(readSettings(path).subagentStatusLine).toEqual(subagentLineFor("/dist")); + expect(existsSync(`${path}.subagent-line-owned`)).toBe(true); + // Enabling again finds our own command, so the kept one is not overwritten with ours. + expect(wire(true, true)).toBe("unchanged"); + + expect(wire(false)).toBe("restored"); + expect(readSettings(path).subagentStatusLine).toEqual(theirs); + expect(existsSync(`${path}.subagent-line-owned`)).toBe(false); + + expect(wire(true, true)).toBe("replaced"); + expect(uninstallHooks({ settingsPath: path, write: true }).removed).toBe(1); + expect(readSettings(path).subagentStatusLine).toEqual(theirs); + }); + it("quotes the script path, so a dist under a directory with a space still runs", () => { const path = settingsFile({ hooks: {} }); @@ -642,38 +694,103 @@ describe("applySetting", () => { expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "rules.find-from-root", "loud")).toThrow("takes one of"); }); - it("diff.commandLines and mods.fullDiff are set, checked and loaded", () => { + it("diff.commandLines and mods.diff.fullDiff are set, checked and loaded", () => { expect(DEFAULT_HOOKS_CONFIG.diff.commandLines).toBe(3); - expect(DEFAULT_HOOKS_CONFIG.mods.fullDiff).toBe(true); + expect(DEFAULT_HOOKS_CONFIG.mods.diff.fullDiff).toBe(true); expect(applySetting(DEFAULT_HOOKS_CONFIG, "diff.commandLines", "0").diff.commandLines).toBe(0); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "false").mods.fullDiff).toBe(false); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.diff.fullDiff", "false").mods.diff.fullDiff).toBe(false); + // The flat key of before 2026-10-23 is an alias. + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "false").mods.diff.fullDiff).toBe(false); expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "diff.commandLines", "-1")).toThrow(); - expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.fullDiff", "off")).toThrow("true or false"); + expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.diff.fullDiff", "off")).toThrow("true or false"); const path = join(mkdtempSync(join(tmpdir(), "gt-cfg-mods-")), "hooks.json"); - writeFileSync(path, SafeJSON.stringify({ diff: { commandLines: 5 }, mods: { fullDiff: false } })); + writeFileSync(path, SafeJSON.stringify({ diff: { commandLines: 5 }, mods: { diff: { fullDiff: false } } })); expect(loadHooksConfig(path).diff.commandLines).toBe(5); - expect(loadHooksConfig(path).mods.fullDiff).toBe(false); + expect(loadHooksConfig(path).mods.diff.fullDiff).toBe(false); }); - it("mods.idleCompact* are set, checked and loaded", () => { - expect(DEFAULT_HOOKS_CONFIG.mods.idleCompactMinutes).toBe(56); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact", "false").mods.idleCompact).toBe(false); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinutes", "50").mods.idleCompactMinutes).toBe(50); - expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinTokens", "0").mods.idleCompactMinTokens).toBe(0); - expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinutes", "60")).toThrow("1 to 59"); + it("mods.idleCompact.* are set, checked and loaded, and the flat keys of before still read", () => { + expect(DEFAULT_HOOKS_CONFIG.mods.idleCompact.minutes).toBe(56); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact", "false").mods.idleCompact.enabled).toBe(false); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact.minutes", "50").mods.idleCompact.minutes).toBe(50); + expect(applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompactMinTokens", "0").mods.idleCompact.minTokens).toBe(0); + expect(() => applySetting(DEFAULT_HOOKS_CONFIG, "mods.idleCompact.minutes", "60")).toThrow("1 to 59"); const path = join(mkdtempSync(join(tmpdir(), "gt-cfg-idle-")), "hooks.json"); - writeFileSync(path, SafeJSON.stringify({ mods: { idleCompactMinutes: 75, idleCompactMinTokens: 1000 } })); - expect(loadHooksConfig(path).mods).toEqual({ - fullDiff: true, - collapseGitSync: true, - idleCompact: true, - idleCompactMinutes: 56, - idleCompactMinTokens: 1000, + writeFileSync( + path, + SafeJSON.stringify({ mods: { idleCompact: false, idleCompactMinutes: 75, idleCompactMinTokens: 1000 } }) + ); + expect(loadHooksConfig(path).mods.idleCompact).toEqual({ enabled: false, minutes: 56, minTokens: 1000 }); + }); + + it("the other mods keys are set and checked by the type of their default", () => { + const set = (key: string, value: string) => applySetting(DEFAULT_HOOKS_CONFIG, key, value).mods; + + expect(set("mods.agentModel.format", "short").agentModel).toEqual({ enabled: false, format: "short" }); + expect(() => set("mods.agentModel.format", "tiny")).toThrow("label, short or version"); + expect(set("mods.agentRow.effort", "false").agentRow).toEqual({ + enabled: false, + format: "version", + effort: false, + }); + expect(set("mods.git.labels", "minimal").git).toEqual({ + enabled: false, + labels: "minimal", + checkouts: 3, + cwd: "touched", + expand: "first", + name: "repo-folder", + earlier: "show", }); + expect(set("mods.git.expand", "all").git.expand).toBe("all"); + expect(set("mods.git.name", "folder").git.name).toBe("folder"); + expect(() => set("mods.git.name", "path")).toThrow("repo-folder, repo or folder"); + expect(set("mods.git.earlier", "hide").git.earlier).toBe("hide"); + expect(() => set("mods.git.earlier", "off")).toThrow("show or hide"); + expect(() => set("mods.git.expand", "some")).toThrow("first, all or none"); + expect(() => set("mods.git.labels", "short")).toThrow("full or minimal"); + expect(set("mods.decisions.chip", "waiting").decisions).toEqual({ + enabled: false, + chip: "waiting", + expand: "waiting", + history: "collapsed", + enter: "stage-then-send", + }); + expect(set("mods.decisions.history", "shown").decisions.history).toBe("shown"); + expect(set("mods.decisions.enter", "stage").decisions.enter).toBe("stage"); + expect(() => set("mods.decisions.enter", "send")).toThrow("stage-then-send or stage"); + expect(() => set("mods.decisions.expand", "unread")).toThrow("waiting, all or none"); + expect(set("mods.inbox", "true").inbox).toEqual({ enabled: true, chip: "any", expand: "none" }); + expect(set("mods.inbox.expand", "unread").inbox.expand).toBe("unread"); + expect(() => set("mods.inbox.chip", "waiting")).toThrow("any, unread or off"); + expect(set("mods.git.checkouts", "2").git.checkouts).toBe(2); + expect(() => set("mods.git.checkouts", "9")).toThrow("1 to 6"); + expect(set("mods.git.cwd", "always").git.cwd).toBe("always"); + expect(() => set("mods.git.cwd", "never")).toThrow("touched or always"); + expect(set("mods.sidebar.hints", "false").sidebar).toEqual({ + hints: false, + feedback: "line", + focus: "command", + }); + expect(set("mods.sidebar.focus", "never").sidebar.focus).toBe("never"); + expect(set("mods.stall.action", "auto").stall).toEqual({ enabled: false, action: "auto", minutes: 5 }); + expect(() => set("mods.stall.action", "nag")).toThrow("button or auto"); + expect(set("mods.stall.minutes", "15").stall.minutes).toBe(15); + expect(() => set("mods.stall.minutes", "500")).toThrow("1 to 120"); + expect(() => set("mods.sidebar.focus", "sometimes")).toThrow("command, always or never"); + expect(set("mods.sidebar.feedback", "both").sidebar.feedback).toBe("both"); + expect(() => set("mods.sidebar.feedback", "popup")).toThrow("line, toast or both"); + expect(set("mods.links", "true").links.enabled).toBe(true); + expect(set("mods.cacheBand.warnMinutes", "15").cacheBand.warnMinutes).toBe(15); + expect(() => set("mods.cacheBand.warnMinutes", "75")).toThrow("1 to 59"); + expect(set("mods.cacheBand.cautionMinutes", "30").cacheBand.cautionMinutes).toBe(30); + expect(set("mods.hooks.dedupe", "true").hooks).toEqual({ tokens: false, dedupe: true }); + expect(() => set("mods.nonsense.enabled", "true")).toThrow("Unknown mods key"); + expect(() => set("mods.links.colour", "red")).toThrow("Unknown mods key"); }); it("reads the mods plugin's row from claude plugin list --json", () => { @@ -1787,6 +1904,192 @@ describe("decision hub hooks", () => { expect(finalReply(stopPayload("given"), () => lines)).toBe("given"); }); + it("records an unambiguous chat answer as automatic_heuristic and sends the agent back once about an unclear one", async () => { + const { log, counts } = harness(); + const [keep, ship] = await postDecisions(log.file, log.events, { + sessionId: "sess-1", + decisions: [ + { prompt: "Keep it?", options: ["yes", "no"] }, + { prompt: "Ship it?", options: ["now", "later"] }, + ], + }); + const after = (minutes: number) => new Date(Date.parse(keep.createdTs ?? "") + minutes * 60_000).toISOString(); + const reads: { kind: string; transcriptPath: string; old: boolean }[] = []; + const chat = new Map<string, number>(); + const deps = { + log, + counts, + now: () => Date.parse(after(10)), + chat: { + checkedAt: (session: string) => chat.get(`${session}:checked`), + setCheckedAt: (session: string, at: number) => chat.set(`${session}:checked`, at), + sentBack: (session: string, id: string) => chat.has(`${session}:${id}`), + markSentBack: (session: string, ids: string[]) => { + for (const id of ids) { + chat.set(`${session}:${id}`, 1); + } + }, + }, + readSession: async (options: { + kind: string; + transcriptPath: string; + until: (entry: NativeHistoryEntry) => boolean; + }) => { + const old = options.until({ + line: -9, + role: "user", + text: "old", + paths: [], + commits: [], + timestamp: after(-5), + }); + reads.push({ kind: options.kind, transcriptPath: options.transcriptPath, old }); + const entry = ( + role: NativeHistoryEntry["role"], + text: string, + minutes: number + ): NativeHistoryEntry => ({ + line: -minutes, + role, + text, + paths: [], + commits: [], + timestamp: after(minutes), + }); + + return { + found: true, + issues: [], + entries: [ + entry("assistant", "❓ DECISION 1 — Keep\n❓ DECISION 2 — Ship", 1), + entry("user", "D1 b) drop it\nand D2 when you can", 2), + entry("assistant", "Recap: [❓ DECISION 1](link) is open.", 3), + ], + }; + }, + }; + const payload = stopPayload("Done.", { transcript_path: "/tmp/sess-1.jsonl" }); + + expect(await runDecisionStop(payload, decisionsOn({ chatAnswers: "off" }), deps)).toBeNull(); + expect(reads).toEqual([]); + + const first = await runDecisionStop(payload, decisionsOn({ chatAnswers: "record" }), deps); + + // The read stops at the first entry older than the oldest open ask, and goes through the session's harness. + expect(reads).toEqual([{ kind: "claude", transcriptPath: "/tmp/sess-1.jsonl", old: true }]); + expect(first && "decision" in first ? first.reason : "").toContain( + "Recorded the chat answer to ❓ DECISION 1 b)" + ); + expect(first && "decision" in first ? first.reason : "").toContain( + '❓ DECISION 2 (mentioned without an option): "and D2 when you can"' + ); + const [stored, open] = readDecisions(log.file); + expect(stored).toMatchObject({ id: keep.id, state: "acknowledged", option: "b", answer: "drop it" }); + expect(stored?.answeredVia).toBe("automatic_heuristic"); + expect(stored?.comments?.at(-1)).toContain('"D1 b) drop it"'); + expect(open).toMatchObject({ id: ship.id, state: "open" }); + + // Said once per decision: the next Stop reads again and says nothing about DECISION 2. + expect(await runDecisionStop(payload, decisionsOn({ chatAnswers: "record" }), deps)).toBeNull(); + expect(readDecisions(log.file)[1]?.state).toBe("open"); + }); + + it("logs every chat-answer run with each user message read, and marks the ones that matched nothing", async () => { + const { log, counts } = harness(); + const [row] = await postDecisions(log.file, log.events, { + sessionId: "sess-1", + decisions: [{ prompt: "Keep it?", options: ["yes", "no"] }], + }); + const at = (minutes: number) => new Date(Date.parse(row.createdTs ?? "") + minutes * 60_000).toISOString(); + const runs: ChatAnswerRun[] = []; + const user = (text: string, minutes: number): NativeHistoryEntry => ({ + line: -minutes, + role: "user", + text, + paths: [], + commits: [], + timestamp: at(minutes), + }); + const deps = { + log, + counts, + appendRun: (run: ChatAnswerRun) => runs.push(run), + chat: { + checkedAt: () => undefined, + setCheckedAt: () => undefined, + sentBack: () => false, + markSentBack: () => undefined, + }, + readSession: async () => ({ + found: true, + issues: [], + entries: [ + user("first, the cache stays as it is", 1), + user(`${"long context ".repeat(40)}\nD1 a) keep it`, 2), + ], + }), + }; + + await runDecisionStop( + stopPayload("Done.", { transcript_path: "/tmp/sess-1.jsonl" }), + decisionsOn({ chatAnswers: "record" }), + deps + ); + + const [run] = runs; + expect(run?.open).toMatchObject([{ id: row.id, number: 1 }]); + expect(run?.recorded).toEqual([row.id]); + expect(run?.users.map((message) => (run ? userMessageOutcome(run, message) : ""))).toEqual([ + "no match", + "recorded D1 a)", + ]); + // A long message keeps its start and the line that answers, so a review sees why it matched. + expect(run?.users[1]?.candidates).toEqual(["D1 a) keep it"]); + }); + + it("in remind mode stores nothing and sends the agent back to record the chat answer itself", async () => { + const { log, counts } = harness(); + const [row] = await postDecisions(log.file, log.events, { + sessionId: "sess-1", + decisions: [{ prompt: "Keep it?", options: ["yes", "no"] }], + }); + const later = new Date(Date.parse(row.createdTs ?? "") + 60_000).toISOString(); + const deps = { + log, + counts, + readSession: async () => ({ + found: true, + issues: [], + entries: [{ line: -1, role: "user" as const, text: "1 a)", paths: [], commits: [], timestamp: later }], + }), + }; + const output = await runDecisionStop( + stopPayload("Done.", { transcript_path: "/tmp/sess-1.jsonl", session_id: "sess-remind" }), + decisionsOn({ chatAnswers: "remind" }), + deps + ); + + // Another session's decision is not this session's to read. + expect(output).toBeNull(); + + const own = await runDecisionStop( + stopPayload("Done.", { transcript_path: "/tmp/sess-1.jsonl" }), + decisionsOn({ chatAnswers: "remind" }), + { + ...deps, + chat: { + checkedAt: () => undefined, + setCheckedAt: () => undefined, + sentBack: () => false, + markSentBack: () => undefined, + }, + } + ); + + expect(own && "decision" in own ? own.reason : "").toContain('❓ DECISION 1: a) from "1 a)"'); + expect(readDecisions(log.file)[0]?.state).toBe("open"); + }); + it("hands answered decisions to the next prompt once, and stays silent when off or nothing is due", async () => { const { log, counts } = harness(); const emitted: PromptHookOutput[] = []; @@ -1875,6 +2178,203 @@ describe("decision hub hooks", () => { ]); expect(() => applySetting(next, "decisions.stopHook", "loud")).toThrow(/off, warn or block/); expect(() => applySetting(next, "decisions.harnesses", "cursor")).toThrow(/no such harness/); + expect(applySetting(next, "decisions.questionNudge", "block").decisions.questionNudge).toBe("block"); + expect(DEFAULT_HOOKS_CONFIG.decisions.questionNudge).toBe("off"); + expect(() => applySetting(next, "decisions.questionNudge", "on")).toThrow(/off, warn or block/); + }); + + describe("the question nudge", () => { + const promptAt = "2026-10-23T09:00:00.000Z"; + const transcript = (prompt: string) => + [ + SafeJSON.stringify({ type: "user", timestamp: "2026-10-23T08:00:00.000Z", message: { content: "x" } }), + SafeJSON.stringify({ type: "user", timestamp: prompt, message: { content: "go" } }), + SafeJSON.stringify({ + type: "user", + timestamp: "2026-10-23T09:05:00.000Z", + message: { content: [{ type: "tool_result", content: "ok" }] }, + }), + SafeJSON.stringify({ + type: "user", + isMeta: true, + timestamp: "2026-10-23T09:06:00.000Z", + message: { content: "Stop hook feedback:\nanother hook" }, + }), + ].join("\n"); + const asks = "Done. One decision is yours.\nShould I keep the cache?"; + const nudgeOn = (overrides: Partial<DecisionsHookConfig> = {}): DecisionsHookConfig => + decisionsOn({ stopHook: "off", questionNudge: "block", ...overrides }); + const stop = (reply: string, extra: Record<string, unknown> = {}) => + stopPayload(reply, { transcript_path: "/fixture.jsonl", ...extra }); + + function nudgeHarness() { + const base = harness(); + const turns = new Map<string, number>(); + const blocks = new Map<string, number>(); + const tail = { text: transcript(promptAt) }; + const nudge = { + blocks: (session: string) => blocks.get(session) ?? 0, + lastTurn: (session: string) => turns.get(session), + record: (session: string, turn: number, blocked: boolean) => { + turns.set(session, turn); + blocks.set(session, (blocks.get(session) ?? 0) + (blocked ? 1 : 0)); + }, + }; + const post = (at: string) => + postDecisions( + base.log.file, + base.log.events, + { sessionId: "sess-1", decisions: [{ prompt: "Keep the cache?", options: ["yes", "no"] }] }, + { now: () => at } + ); + + return { ...base, blocks, tail, post, deps: { ...base.deps, nudge, readTail: () => tail.text } }; + } + + it("blocks a reply that asks the user to decide when the turn logged no decision, quoting the line", async () => { + const { deps, blocks } = nudgeHarness(); + const output = await runDecisionStop(stop(asks), nudgeOn(), deps); + + expect(output).toMatchObject({ decision: "block" }); + expect(output && "reason" in output ? output.reason : "").toContain('("Should I keep the cache?")'); + expect(blocks.get("sess-1")).toBe(1); + }); + + it("passes once the session logged a decision after the turn's prompt", async () => { + const { deps, post } = nudgeHarness(); + + await post("2026-10-23T09:03:00.000Z"); + expect(await runDecisionStop(stop(asks), nudgeOn(), deps)).toBeNull(); + }); + + it("passes a question about a decision logged in an earlier turn, found in the log, not the transcript", async () => { + const { deps, post } = nudgeHarness(); + + // Logged an hour before this turn's prompt: the log, not the turn, says it reached the inbox. + await post("2026-10-23T08:00:00.000Z"); + const linked = + "❓ [DECISION 1](https://genesis.tools/claude/find/sess-1/decision/1) is still open: should each tool " + + "folder get its own `tsconfig.json`? I recommend a)."; + + expect(await runDecisionStop(stop(linked), nudgeOn(), deps)).toBeNull(); + expect( + await runDecisionStop(stop("D1 is still open.\nShould I keep the cache, decide?"), nudgeOn(), deps) + ).toBeNull(); + // A question about a number nobody logged still blocks. + expect( + await runDecisionStop(stop("D7 is new.\nShould I keep the cache, decide?"), nudgeOn(), deps) + ).toMatchObject({ + decision: "block", + }); + }); + + it("does not count a decision logged before the turn's prompt", async () => { + const { deps, post } = nudgeHarness(); + + await post("2026-10-23T08:30:00.000Z"); + expect(await runDecisionStop(stop(asks), nudgeOn(), deps)).toMatchObject({ decision: "block" }); + }); + + it("passes a question with no decision cue, and a cue inside code or a quote", async () => { + const { deps } = nudgeHarness(); + const replies = [ + "Tests pass. Should I push?", + "Should I push?\n```ts\nconst decision = choose();\n```", + "Should I push? The `decision` field stays.", + "> Which decision do you want?\nDone. Should I push?", + ]; + + for (const reply of replies) { + expect(await runDecisionStop(stop(reply), nudgeOn(), deps)).toBeNull(); + } + }); + + it("lets the second Stop of the same turn pass, by Claude's flag or by the turn it already spoke in", async () => { + const flagged = nudgeHarness(); + + expect(await runDecisionStop(stop(asks, { stop_hook_active: true }), nudgeOn(), flagged.deps)).toBeNull(); + + const { deps, tail } = nudgeHarness(); + + expect(await runDecisionStop(stop(asks), nudgeOn(), deps)).toMatchObject({ decision: "block" }); + expect(await runDecisionStop(stop(asks), nudgeOn(), deps)).toBeNull(); + + tail.text = transcript("2026-10-23T10:00:00.000Z"); + expect(await runDecisionStop(stop(asks), nudgeOn(), deps)).toMatchObject({ decision: "block" }); + }); + + it("only warns past the per-session cap, and in warn mode", async () => { + const { deps, tail } = nudgeHarness(); + + expect(await runDecisionStop(stop(asks), nudgeOn({ maxBlocksPerSession: 1 }), deps)).toMatchObject({ + decision: "block", + }); + tail.text = transcript("2026-10-23T10:00:00.000Z"); + expect(await runDecisionStop(stop(asks), nudgeOn({ maxBlocksPerSession: 1 }), deps)).toHaveProperty( + "systemMessage" + ); + expect( + await runDecisionStop(stop(asks), nudgeOn({ questionNudge: "warn" }), nudgeHarness().deps) + ).toHaveProperty("systemMessage"); + }); + + it("never fires while off, and the shipped default is off", async () => { + const { deps } = nudgeHarness(); + + expect(DEFAULT_HOOKS_CONFIG.decisions.questionNudge).toBe("off"); + expect(decisionStopWanted(DEFAULT_HOOKS_CONFIG)).toBe(false); + expect(await runDecisionStop(stop(asks), decisionsOn({ questionNudge: "off" }), deps)).toBeNull(); + expect(await runDecisionStop(stop(asks), DEFAULT_HOOKS_CONFIG.decisions, deps)).toBeNull(); + }); + + it("leaves a reply that names ❓ DECISION N to the existing check", async () => { + const { deps, post } = nudgeHarness(); + const both = decisionsOn({ questionNudge: "block" }); + const blocked = await runDecisionStop(stop(asking), both, deps); + + expect(blocked && "reason" in blocked ? blocked.reason : "").toContain( + "Your reply asks ❓ DECISION 1 without posting it." + ); + + await post("2026-10-23T08:30:00.000Z"); + expect(await runDecisionStop(stop(asking), both, deps)).toBeNull(); + expect(await runDecisionStop(stop(asking), nudgeOn(), nudgeHarness().deps)).toBeNull(); + }); + + it("stays quiet when the transcript holds no prompt", async () => { + const { deps } = nudgeHarness(); + + expect(await runDecisionStop(stopPayload(asks), nudgeOn(), deps)).toBeNull(); + expect(await runDecisionStop(stop(asks), nudgeOn(), { ...deps, readTail: () => "" })).toBeNull(); + }); + + it("finds the turn's prompt in Claude and Codex transcripts", () => { + const codex = [ + SafeJSON.stringify({ + timestamp: "2026-10-23T09:00:00.000Z", + type: "response_item", + payload: { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + }), + SafeJSON.stringify({ + timestamp: "2026-10-23T09:01:00.000Z", + type: "response_item", + payload: { type: "function_call_output", output: "ok" }, + }), + ].join("\n"); + + expect(turnStartedAt(stop(asks), () => `{"torn":${transcript(promptAt)}`)).toBe(Date.parse(promptAt)); + expect(turnStartedAt(stop(asks), () => codex)).toBe(Date.parse("2026-10-23T09:00:00.000Z")); + }); + + it("wires the Stop hook alone, and the Stop entrypoint loads the decision checks for it", () => { + const nudging = { ...DEFAULT_HOOKS_CONFIG, decisions: nudgeOn() }; + const events = entriesFor("/dist", { questionNudge: true }).map((item) => item.event); + + expect(events).toContain("Stop"); + expect(events).not.toContain("UserPromptSubmit"); + expect(decisionStopWanted(nudging)).toBe(true); + expect(decisionHooksWanted(nudging)).toBe(false); + }); }); }); diff --git a/src/agents/lib/hooks/install.ts b/src/agents/lib/hooks/install.ts index a862697d71..53a3f4bc97 100644 --- a/src/agents/lib/hooks/install.ts +++ b/src/agents/lib/hooks/install.ts @@ -13,7 +13,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; import { shellQuote } from "@genesiscz/utils/shell/quote"; -import { agentsDataDir, decisionHooksWanted, diffFor, loadHooksConfig } from "./config"; +import { agentsDataDir, decisionHooksWanted, diffFor, loadHooksConfig, stopOnlyDecisionsWanted } from "./config"; import { hookDiag } from "./log"; import { writeJsonFile } from "./write-json"; @@ -48,16 +48,20 @@ export interface SettingsShape { /** Every command string this installer writes carries this, so uninstall is exact. */ export const INSTALL_MARKER = "src/agents/bin/hook-"; +/** What the Stop and UserPromptSubmit entries serve; each omitted feature is off. */ +export interface StopFeatures { + decisions?: boolean; + unpushed?: boolean; + questionNudge?: boolean; +} + /** - * The Stop entry serves two features, the decision hub and the unpushed reminder, so it is wired - * while either is on. UserPromptSubmit belongs to the decision hub alone. + * The Stop entry serves three features, the decision hub, the unpushed reminder and the question + * nudge, so it is wired while any of them is on. UserPromptSubmit belongs to the decision hub alone. */ -function stopEntries( - script: (name: string) => string, - options: { decisions?: boolean; unpushed?: boolean } -): { event: string; entry: HookEntry }[] { +function stopEntries(script: (name: string) => string, options: StopFeatures): { event: string; entry: HookEntry }[] { return [ - ...(options.decisions || options.unpushed + ...(options.decisions || options.unpushed || options.questionNudge ? [ { event: "Stop", @@ -76,10 +80,7 @@ function stopEntries( ]; } -export function entriesFor( - dist: string, - options: { decisions?: boolean; unpushed?: boolean } = {} -): { event: string; entry: HookEntry }[] { +export function entriesFor(dist: string, options: StopFeatures = {}): { event: string; entry: HookEntry }[] { // The harness runs each command through a shell, so a `dist` under a home directory or a // `--dist` with a space in it split into two arguments and every hook call failed. The // quoted form still contains INSTALL_MARKER, so a re-install converges an older entry. @@ -127,6 +128,145 @@ export function entriesFor( ]; } +/** Claude Code's command for the agents panel rows (`mods.agentRow`, `plugins/genesis-tools-mods/hooks/agent-row.ts`). */ +export function subagentLineFor(dist: string): { type: "command"; command: string } { + return { type: "command", command: `bun ${shellQuote(`${dist}/src/agents/bin/hook-subagent-line.ts`)}` }; +} + +/** + * What an install does to `subagentStatusLine`. `theirs`: `mods.agentRow` is on, but the setting holds a command this + * installer did not write, which stays (the agents panel rows stay the engine's) unless `replace` is asked for. + * `replaced`: that command was kept beside the settings file and ours took its place. `restored`: the mod went off and + * the kept command came back. `absent`: the mod is off and no command of ours is there. + */ +export type SubagentLinePlan = + | "added" + | "updated" + | "unchanged" + | "replaced" + | "removed" + | "restored" + | "absent" + | "theirs"; + +/** The `subagentStatusLine` that `replace` took out, kept beside the settings file so turning the mod off restores it. */ +function subagentLineOwnedPath(settingsPath: string): string { + return `${settingsPath}.subagent-line-owned`; +} + +/** The command `replace` kept; `undefined` when none was (or the file does not parse, so nothing is guessed). */ +function savedSubagentLine(settingsPath: string): unknown { + const path = subagentLineOwnedPath(settingsPath); + + if (!existsSync(path)) { + return undefined; + } + + try { + return readSettings(path).previous; + } catch (err) { + hookDiag("Could not read the kept subagentStatusLine", { err, path }); + + return undefined; + } +} + +/** + * Puts the agents panel command into `settings` (changed in place) while `on`, and takes our own out while off, putting + * back `saved` (the command `replace` took out) when there is one. A command of someone else's is replaced only with + * `replace`. + */ +export function planSubagentLine( + settings: SettingsShape, + options: { dist: string; on: boolean; replace?: boolean; saved?: unknown } +): SubagentLinePlan { + const held = settings.subagentStatusLine; + const ours = + typeof held === "object" && + held !== null && + "command" in held && + typeof held.command === "string" && + held.command.includes(INSTALL_MARKER); + + if (!options.on) { + if (!ours) { + return "absent"; + } + + if (options.saved !== undefined) { + settings.subagentStatusLine = options.saved; + return "restored"; + } + + delete settings.subagentStatusLine; + return "removed"; + } + + const wanted = subagentLineFor(options.dist); + + if (held !== undefined && !ours) { + if (!options.replace) { + return "theirs"; + } + + settings.subagentStatusLine = wanted; + return "replaced"; + } + + if (SafeJSON.stringify(held) === SafeJSON.stringify(wanted)) { + return "unchanged"; + } + + settings.subagentStatusLine = wanted; + return held === undefined ? "added" : "updated"; +} + +/** Whether a plan changed the settings, so they must be written. */ +function subagentLineChanged(plan: SubagentLinePlan): boolean { + return plan !== "unchanged" && plan !== "absent" && plan !== "theirs"; +} + +/** + * The bookkeeping beside a written plan: `replaced` keeps the command it took out, and a plan that took ours out + * drops the kept copy, which is back in the settings or no longer wanted. + */ +function keepSubagentLine(settingsPath: string, plan: SubagentLinePlan, previous: unknown): void { + if (plan === "replaced") { + writeJsonFile(subagentLineOwnedPath(settingsPath), { previous }); + } else if (plan === "restored" || plan === "removed") { + rmSync(subagentLineOwnedPath(settingsPath), { force: true }); + } +} + +/** + * `mods enable|disable agentRow`: the `subagentStatusLine` part of an install alone, so switching the mod does not + * also rewire every hook. Writes only when the plan changed something and `write` is set. + */ +export function wireSubagentLine(options: { + on: boolean; + write: boolean; + replace?: boolean; + dist?: string; + settingsPath?: string; +}): SubagentLinePlan { + const settingsPath = options.settingsPath ?? claudeSettingsPath(); + const settings = readSettings(settingsPath); + const previous = settings.subagentStatusLine; + const plan = planSubagentLine(settings, { + dist: options.dist ?? hooksDistPath(), + on: options.on, + replace: options.replace, + saved: savedSubagentLine(settingsPath), + }); + + if (options.write && subagentLineChanged(plan)) { + writeJsonFile(settingsPath, settings); + keepSubagentLine(settingsPath, plan, previous); + } + + return plan; +} + /** * A settings file that does not exist yet is an EMPTY one, not a crash: a fresh machine has * no `~/.claude/settings.json`, and `install` should write one rather than exit with a stack @@ -187,11 +327,18 @@ export function installAndPoint(options: { settingsPath?: string; write: boolean; decisions?: boolean; + replaceSubagentLine?: boolean; }): InstallAndPointResult { const dist = options.dist ?? hooksDistPath(); const repoint = options.write && dist === hooksDistPath(); - const decisions = options.decisions; - const planned = installHooks({ dist, settingsPath: options.settingsPath, write: false, decisions }); + const { decisions, replaceSubagentLine } = options; + const planned = installHooks({ + dist, + settingsPath: options.settingsPath, + write: false, + decisions, + replaceSubagentLine, + }); if (!options.write) { return { ...planned, repointed: false }; @@ -205,7 +352,7 @@ export function installAndPoint(options: { try { return { - ...installHooks({ dist, settingsPath: options.settingsPath, write: true, decisions }), + ...installHooks({ dist, settingsPath: options.settingsPath, write: true, decisions, replaceSubagentLine }), repointed: repoint, }; } catch (err) { @@ -260,6 +407,8 @@ export interface InstallResult { dist: string; /** What became of Claude Code's own Bash diff; see `diff.replaceNative`. */ nativeDiff?: "turned-off" | "already-off" | "kept"; + /** `theirs`: `subagentStatusLine` holds a command of the user's, so the agents panel rows were not wired. */ + subagentLine?: SubagentLinePlan; } /** Does this entry belong to this installer? */ @@ -305,6 +454,12 @@ export function installHooks(options: { decisions?: boolean; /** Wire the Stop hook for the unpushed reminder. Omitted: `unpushed.enabled` from the hooks config. */ unpushed?: boolean; + /** Wire the Stop hook for the question nudge and the chat answers. Omitted: whether either is on. */ + questionNudge?: boolean; + /** Wire `subagentStatusLine` for the agents panel rows. Omitted: `mods.agentRow.enabled`. */ + agentRow?: boolean; + /** Replace a `subagentStatusLine` of someone else's, kept for `disable` and `uninstall` to restore. */ + replaceSubagentLine?: boolean; }): InstallResult { const dist = options.dist ?? hooksDistPath(); const settingsPath = options.settingsPath ?? claudeSettingsPath(); @@ -318,6 +473,7 @@ export function installHooks(options: { const wanted = entriesFor(dist, { decisions: options.decisions ?? decisionHooksWanted(config), unpushed: options.unpushed ?? config.unpushed.enabled, + questionNudge: options.questionNudge ?? stopOnlyDecisionsWanted(config), }); const wantedEvents = new Set(wanted.map((item) => item.event)); @@ -349,6 +505,24 @@ export function installHooks(options: { (ours.length === 0 ? added : updated).push(event); } + const previousLine = settings.subagentStatusLine; + const subagentLine = planSubagentLine(settings, { + dist, + on: options.agentRow ?? config.mods.agentRow.enabled, + replace: options.replaceSubagentLine, + saved: savedSubagentLine(settingsPath), + }); + const lists: Partial<Record<SubagentLinePlan, string[]>> = { + added, + updated, + replaced: updated, + unchanged, + removed, + restored: removed, + }; + + lists[subagentLine]?.push("subagentStatusLine"); + // The hook replaces Claude Code's own Bash diff, in the same write as the wiring. // While `shadow` is on the hook prints nothing (`hook-diff-post`), so turning the native // diff off would remove the only diff the user sees. It stays until the hook speaks. @@ -364,7 +538,7 @@ export function installHooks(options: { const changed = SafeJSON.stringify(settings) !== before; if (!options.write || !changed) { - return { added, updated, unchanged, removed, changed, dist, nativeDiff }; + return { added, updated, unchanged, removed, changed, dist, nativeDiff, subagentLine }; } const backup = `${settingsPath}.pre-agents-hooks`; @@ -374,12 +548,13 @@ export function installHooks(options: { } writeJsonFile(settingsPath, settings); + keepSubagentLine(settingsPath, subagentLine, previousLine); if (nativeDiff === "turned-off") { rememberNativeDiff(settingsPath, previousNative); } - return { added, updated, unchanged, removed, changed, backup, dist, nativeDiff }; + return { added, updated, unchanged, removed, changed, backup, dist, nativeDiff, subagentLine }; } /** The previous `bashEditDiffEnabled`, kept beside the settings file install rewrote. */ @@ -423,9 +598,7 @@ export interface WiringStatus { * install (the post hook on `Bash` only) read as fine while no Edit or Write call reached the * session change log. */ -export function wiringStatus( - options: { dist?: string; settingsPath?: string; decisions?: boolean; unpushed?: boolean } = {} -): WiringStatus { +export function wiringStatus(options: { dist?: string; settingsPath?: string } & StopFeatures = {}): WiringStatus { const settingsPath = options.settingsPath ?? claudeSettingsPath(); if (!existsSync(settingsPath) || !SafeJSON.stringify(readSettings(settingsPath)).includes(INSTALL_MARKER)) { @@ -438,6 +611,13 @@ export function wiringStatus( return events.length > 0 ? { state: "stale", events } : { state: "installed", events: [] }; } +/** Whether `settings.json` holds a Stop entry of ours, the one the question nudge and the unpushed reminder run in. */ +export function stopHookWired(settingsPath = claudeSettingsPath()): boolean { + return (readSettings(settingsPath).hooks?.Stop ?? []).some( + (entry) => isOurs(entry) && Boolean(entry.hooks?.some((hook) => hook.command?.includes("hook-stop.ts"))) + ); +} + export interface UninstallResult { removed: number; } @@ -457,6 +637,16 @@ export function uninstallHooks(options: { settingsPath?: string; write: boolean } } + const line = planSubagentLine(settings, { + dist: hooksDistPath(), + on: false, + saved: savedSubagentLine(settingsPath), + }); + + if (subagentLineChanged(line)) { + removed++; + } + const saved = options.write ? savedNativeDiff(settingsPath) : { found: false as const }; let restored = false; @@ -474,6 +664,7 @@ export function uninstallHooks(options: { settingsPath?: string; write: boolean if (options.write && (removed > 0 || restored)) { writeJsonFile(settingsPath, settings); + keepSubagentLine(settingsPath, line, undefined); } if (saved.found) { diff --git a/src/agents/lib/hooks/log.ts b/src/agents/lib/hooks/log.ts index 0d0f5101cb..6a762d8abc 100644 --- a/src/agents/lib/hooks/log.ts +++ b/src/agents/lib/hooks/log.ts @@ -293,6 +293,11 @@ export function logDecision(record: DecisionRecord, logPath: string): void { append(logPath, record); } +/** One record into a hook's own JSONL log, with the same modes and rotation as the hooks log; never throws. */ +export function appendHookRecord(path: string, record: Record<string, unknown>): void { + append(path, record); +} + /** A caught error on the hot path. Never thrown, never swallowed: it lands in the log. */ export function hookDiag(reason: string, fields: Record<string, unknown> = {}): void { append(diagPath, { diff --git a/src/agents/lib/hooks/mods-plugin.test.ts b/src/agents/lib/hooks/mods-plugin.test.ts index 0684b29b08..1fce9eb45f 100644 --- a/src/agents/lib/hooks/mods-plugin.test.ts +++ b/src/agents/lib/hooks/mods-plugin.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; +import { agentRows, duration, finishedAt, rowInput } from "../../../../plugins/genesis-tools-mods/hooks/agent-row"; import { compactionEndsIdleWait, IDLE_COMPACT_DEFAULTS, @@ -10,6 +11,7 @@ import { idleCompactSkip, idleNotice, } from "../../../../plugins/genesis-tools-mods/hooks/idle"; +import { bareDescription, modelLabel } from "../../../../plugins/genesis-tools-mods/hooks/model-label"; import { collapseGitSyncEnabled, fullDiffEnabled, @@ -20,8 +22,11 @@ import { sectionsOf, sectionsOfCalls, } from "../../../../plugins/genesis-tools-mods/hooks/sections"; +import { MODS_DEFAULTS, modsSettings } from "../../../../plugins/genesis-tools-mods/hooks/settings"; +import { DEFAULT_HOOKS_CONFIG } from "./config"; import { overflowPart } from "./diff/full"; import { MOD_MAX_PATCH_CHARS, MODS_SESSION_VAR } from "./diff/run"; +import { MOD_SWITCHES, modSwitchKeys } from "./mods"; const NOTE = [ "# Diff of toolu_x", @@ -217,7 +222,7 @@ describe("idle compaction", () => { expect(idleCompactSkip({ settings, surfaces: 1, tokens: 300_000 })).toBeUndefined(); expect(idleCompactSkip({ settings: { ...settings, enabled: false }, surfaces: 1, tokens: 300_000 })).toBe( - "mods.idleCompact is off" + "mods.idleCompact.enabled is off" ); expect(idleCompactSkip({ settings, surfaces: 0, tokens: 300_000 })).toContain("no surface"); expect(idleCompactSkip({ settings, surfaces: 1, tokens: 199_999 })).toContain("under the floor"); @@ -240,3 +245,157 @@ describe("idle compaction", () => { expect(idleNotice({ minutes: 56, tokensBefore: undefined, tokensAfter: 1 })).not.toContain("tokens)"); }); }); + +describe("the mods section", () => { + it("the plugin's defaults are the CLI's, field for field", () => { + expect(MODS_DEFAULTS).toEqual(DEFAULT_HOOKS_CONFIG.mods); + }); + + it("starts every mod off but the full diff and the idle compaction, and each one has a switch by name", () => { + const valueAt = (key: string): unknown => + key + .split(".") + .slice(1) + .reduce<unknown>( + (at, part) => (typeof at === "object" && at !== null ? Reflect.get(at, part) : undefined), + MODS_DEFAULTS + ); + const on = Object.entries(MOD_SWITCHES).filter(([, keys]) => keys.every((key) => valueAt(key) === true)); + expect(on.map(([name]) => name)).toEqual(["diff", "idleCompact"]); + expect(modSwitchKeys(["git", "hooks", "git"]).keys).toEqual(["mods.git.enabled", "mods.hooks.tokens"]); + expect(modSwitchKeys(["all"]).keys).toHaveLength(Object.keys(MOD_SWITCHES).length); + expect(modSwitchKeys(["gti"]).unknown).toEqual(["gti"]); + }); + + it("the plugin reads the nested keys, and the flat keys of before where the object leaves a field out", () => { + const read = modsSettings( + SafeJSON.stringify({ + mods: { + fullDiff: false, + idleCompact: false, + idleCompactMinutes: 40, + diff: { collapseGitSync: false }, + agentModel: { format: "short" }, + cacheBand: { warnMinutes: 99 }, + hooks: { dedupe: false }, + }, + }) + ); + + expect(read.diff).toEqual({ fullDiff: false, collapseGitSync: false }); + expect(read.idleCompact).toEqual({ enabled: false, minutes: 40, minTokens: 200_000 }); + expect(read.agentModel).toEqual({ enabled: false, format: "short" }); + // Out of range keeps the default, as the CLI does. + expect(read.cacheBand.warnMinutes).toBe(10); + expect(read.hooks).toEqual({ tokens: false, dedupe: false }); + expect(modsSettings("{ not json")).toEqual(MODS_DEFAULTS); + }); +}); + +describe("the agents panel rows (mods.agentRow)", () => { + const now = 1_000_000; + // The shape Claude Code 2.1.296 sends its subagentStatusLine command, captured live on 2026-10-25. + const payload = { + session_id: "s-1", + columns: 80, + tasks: [ + { + id: "a1", + type: "local_agent", + agentType: "general-purpose", + status: "running", + description: "(H) Probe one", + label: "Reading foo.ts", + startTime: now - 65_000, + model: "claude-haiku-5-5", + tokenCount: 58_500, + }, + { + id: "a2", + type: "local_agent", + agentType: "Explore", + status: "completed", + description: "sonnet-5-5 · Probe two", + label: "sonnet-5-5 · Probe two", + startTime: now - 30_000, + model: "claude-sonnet-5-5", + tokenCount: 0, + }, + ], + }; + const options = { + format: "label" as const, + effort: false, + now, + ends: { a2: now - 10_000 }, + width: Bun.stringWidth, + }; + + it("puts the model beside the type, drops the description's model, and right-aligns time and tokens", () => { + const input = rowInput(payload); + + expect(input?.columns).toBe(80); + + const rows = agentRows(input ?? { columns: 0, tasks: [] }, options); + + expect(rows.map((row) => row.id)).toEqual(["a1", "a2"]); + expect(rows[0]?.content.startsWith("general-purpose · haiku-5-5 Reading foo.ts ")).toBe(true); + expect(rows[0]?.content.endsWith(" 1m 5s · ↓ 58.5k tokens")).toBe(true); + // A finished agent's time stops at its end; no tokens print nothing. + expect(rows[1]?.content.startsWith("Explore · sonnet-5-5 Probe two ")).toBe(true); + expect(rows[1]?.content.endsWith("20s")).toBe(true); + + for (const row of rows) { + expect(Bun.stringWidth(row.content)).toBeLessThanOrEqual(79); + } + }); + + it("cuts a long activity with an ellipsis and keeps the status whole on a narrow row", () => { + const input = rowInput({ + columns: 40, + tasks: [{ id: "a", agentType: "general-purpose", model: "claude-opus-5-5", label: "x".repeat(80) }], + }); + const [row] = agentRows(input ?? { columns: 0, tasks: [] }, { ...options, format: "short" }); + + expect(row?.content).toBe(`general-purpose · O ${"x".repeat(17)}…`); + }); + + it("names the version and the effort compactly (`O5.5(xhigh)`), and the effort only when the engine sends one", () => { + const input = rowInput({ + columns: 80, + tasks: [ + { id: "a", agentType: "general-purpose", model: "claude-opus-5-5", effort: "xhigh", label: "Reading" }, + { id: "b", agentType: "Explore", model: "claude-haiku-4-5-20251001", label: "Searching" }, + { id: "c", agentType: "Plan", model: "sonnet", effort: "high", label: "Planning" }, + ], + }); + const rows = agentRows(input ?? { columns: 0, tasks: [] }, { ...options, format: "version", effort: true }); + + expect(rows.map((row) => row.content.split(" ")[0])).toEqual([ + "general-purpose · O5.5(xhigh)", + "Explore · H4.5", + "Plan · S(high)", + ]); + + const labelled = agentRows(input ?? { columns: 0, tasks: [] }, { ...options, effort: true }); + + expect(labelled[0]?.content.startsWith("general-purpose · opus-5-5 (xhigh)")).toBe(true); + expect(modelLabel("gpt-6-astra", "version")).toBe("gpt-6-astra"); + expect(bareDescription("(O5.5) Probe")).toBe("Probe"); + }); + + it("keeps an end once seen and forgets agents the panel no longer lists", () => { + const input = rowInput(payload) ?? { columns: 0, tasks: [] }; + + expect(finishedAt(input, { gone: 5, a2: 7 }, now)).toEqual({ a2: 7 }); + expect(finishedAt(input, {}, now)).toEqual({ a2: now }); + }); + + it("writes time as the engine does and refuses a payload without tasks", () => { + expect(duration(4_900)).toBe("4s"); + expect(duration(65_000)).toBe("1m 5s"); + expect(duration(3_723_000)).toBe("1h 2m 3s"); + expect(rowInput({ columns: 80 })).toBeUndefined(); + expect(rowInput("nope")).toBeUndefined(); + }); +}); diff --git a/src/agents/lib/hooks/mods.ts b/src/agents/lib/hooks/mods.ts index 476788cb7a..fce0c1a5ae 100644 --- a/src/agents/lib/hooks/mods.ts +++ b/src/agents/lib/hooks/mods.ts @@ -15,6 +15,35 @@ export const MODS_PLUGIN = "genesis-tools-mods@genesis-tools"; export const MODS_SCOPES = ["user", "project", "local"] as const; export type ModsScope = (typeof MODS_SCOPES)[number]; +/** Each mod by the name `hooks mods enable|disable` takes, with the hooks-config keys that switch it. */ +export const MOD_SWITCHES: Record<string, string[]> = { + diff: ["mods.diff.fullDiff"], + idleCompact: ["mods.idleCompact.enabled"], + agentModel: ["mods.agentModel.enabled"], + agentRow: ["mods.agentRow.enabled"], + decisions: ["mods.decisions.enabled"], + inbox: ["mods.inbox.enabled"], + links: ["mods.links.enabled"], + git: ["mods.git.enabled"], + askRow: ["mods.askRow.enabled"], + spinner: ["mods.spinner.enabled"], + cacheBand: ["mods.cacheBand.enabled"], + tmpUsage: ["mods.tmpUsage.enabled"], + agentsBand: ["mods.agentsBand.enabled"], + hooks: ["mods.hooks.tokens"], + hooksDedupe: ["mods.hooks.dedupe"], + stall: ["mods.stall.enabled"], +}; + +/** The keys of the named mods (`all` is every one), and the names that are no mod. */ +export function modSwitchKeys(names: readonly string[]): { keys: string[]; unknown: string[] } { + const picked = names.includes("all") ? Object.keys(MOD_SWITCHES) : names; + const unknown = picked.filter((name) => !(name in MOD_SWITCHES)); + const keys = picked.flatMap((name) => MOD_SWITCHES[name] ?? []); + + return { keys: [...new Set(keys)], unknown }; +} + export interface ModsPluginState { installed: boolean; enabled: boolean; diff --git a/src/agents/lib/hooks/set-config.ts b/src/agents/lib/hooks/set-config.ts index 1009f1e1e1..8ec4d27441 100644 --- a/src/agents/lib/hooks/set-config.ts +++ b/src/agents/lib/hooks/set-config.ts @@ -1,6 +1,8 @@ import { SafeJSON } from "@genesiscz/utils/json"; import { SHELL_RULES } from "@genesiscz/utils/shell/rules"; import { + DECISION_CHAT_ANSWERS, + DEFAULT_HOOKS_CONFIG, FILL_MODES, type HarnessName, HIGHLIGHT_MODES, @@ -11,6 +13,8 @@ import { isCount, isMegabytes, loadHooksConfigForWrite, + type ModsConfig, + mergeMods, OTHERS_SUMMARIES, } from "./config"; import { FULL_DIFF_MODES } from "./diff/full"; @@ -62,28 +66,142 @@ export const SETTABLE_KEYS = [ "rules.<rule-id>", "harnesses.<claude|codex|grok>.<rule-id>", "decisions.stopHook", + "decisions.questionNudge", + "decisions.chatAnswers", "decisions.maxBlocksPerSession", "decisions.harnesses", "decisions.harvest", "decisions.injectAnswers", "decisions.staleness.<warnAfterMinutes|alarmAfterMinutes|notify>", "agentsTalk.hint", - "mods.<fullDiff|collapseGitSync>", - "mods.<idleCompact|idleCompactMinutes|idleCompactMinTokens>", + "mods.diff.<fullDiff|collapseGitSync>", + "mods.idleCompact.<enabled|minutes|minTokens>", + "mods.agentModel.<enabled|format>", + "mods.agentRow.<enabled|format|effort>", + "mods.<decisions|inbox|links|git|askRow|spinner>.enabled", + "mods.decisions.<chip|expand|history|enter>", + "mods.inbox.<chip|expand>", + "mods.git.<labels|checkouts|cwd|expand|name|earlier>", + "mods.cacheBand.<enabled|warnMinutes|cautionMinutes|others>", + "mods.tmpUsage.<enabled|minutes>", + "mods.agentsBand.<enabled|warnMinutes>", + "mods.hooks.<tokens|dedupe>", + "mods.sidebar.<hints|feedback|focus>", + "mods.stall.<enabled|action|minutes>", ] as const; -/** One `decisions.*` key. The stop hook takes off|warn|block; `harnesses` a comma list. */ +/** The flat keys of before 2026-10-23, still taken; `mods.<feature>` alone means its `enabled`. */ +const MOD_ALIASES: Record<string, string> = { + "mods.fullDiff": "mods.diff.fullDiff", + "mods.collapseGitSync": "mods.diff.collapseGitSync", + "mods.idleCompactMinutes": "mods.idleCompact.minutes", + "mods.idleCompactMinTokens": "mods.idleCompact.minTokens", +}; + +/** The highest whole number each numeric mods field takes, and the lowest when it is not 1. */ +const MOD_NUMBER_LIMITS: Record<string, { max: number; min?: number; why: string }> = { + "idleCompact.minutes": { max: 59, why: "at 60 the 1-hour prompt cache has expired" }, + "idleCompact.minTokens": { max: Number.MAX_SAFE_INTEGER, min: 0, why: "" }, + "cacheBand.warnMinutes": { max: 59, why: "the cache lives 60 minutes" }, + "cacheBand.cautionMinutes": { max: 59, why: "the cache lives 60 minutes" }, + "tmpUsage.minutes": { max: 120, why: "a reading older than 2 hours says little" }, + "git.checkouts": { max: 6, why: "each checkout takes a row above the prompt" }, + "agentsBand.warnMinutes": { max: 24 * 60, why: "a day" }, + "stall.minutes": { max: 120, why: "a reply left two hours is no longer a stall to resume" }, +}; + +/** The values each text mods field takes, as `--help` and an error list them. */ +const MOD_CHOICES: Record<string, readonly string[]> = { + "agentModel.format": ["label", "short", "version"], + "agentRow.format": ["label", "short", "version"], + "decisions.chip": ["any", "waiting", "off"], + "decisions.expand": ["waiting", "all", "none"], + "decisions.history": ["collapsed", "shown"], + "decisions.enter": ["stage-then-send", "stage"], + "inbox.chip": ["any", "unread", "off"], + "inbox.expand": ["unread", "all", "none"], + "git.labels": ["full", "minimal"], + "git.cwd": ["touched", "always"], + "git.expand": ["first", "all", "none"], + "git.name": ["repo-folder", "repo", "folder"], + "git.earlier": ["show", "hide"], + "sidebar.feedback": ["line", "toast", "both"], + "sidebar.focus": ["command", "always", "never"], + "stall.action": ["button", "auto"], +}; + +/** `a or b`, `a, b or c`. */ +function listed(choices: readonly string[]): string { + return choices.length < 2 ? (choices[0] ?? "") : `${choices.slice(0, -1).join(", ")} or ${choices.at(-1)}`; +} + +function isFields(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** One `mods.<feature>.<field>` key, checked against the type of the field's default. */ +function withMods(mods: ModsConfig, key: string, value: string): ModsConfig { + const path = MOD_ALIASES[key] ?? key; + const [, feature = "", field = "enabled"] = path.split("."); + const base = new Map<string, unknown>(Object.entries(DEFAULT_HOOKS_CONFIG.mods)).get(feature); + const held = new Map<string, unknown>(Object.entries(mods)).get(feature); + + if (!isFields(base) || !isFields(held) || !(field in base)) { + throw new Error(`Unknown mods key ${SafeJSON.stringify(key)}; see the mods.* keys in --help`); + } + + const part = { ...held }; + const sample = base[field]; + + if (typeof sample === "boolean") { + part[field] = asBoolean(key, value); + } else if (typeof sample === "number") { + const limit = MOD_NUMBER_LIMITS[`${feature}.${field}`]; + const count = asCount(key, value, limit?.min ?? 1); + + if (limit && count > limit.max) { + throw new Error(`${key} takes ${limit.min ?? 1} to ${limit.max}: ${limit.why}`); + } + + part[field] = count; + } else { + const choices = MOD_CHOICES[`${feature}.${field}`]; + + if (choices && !choices.includes(value)) { + throw new Error(`${key} takes ${listed(choices)}, not ${SafeJSON.stringify(value)}`); + } + + if (choices) { + part[field] = value; + } + } + + return mergeMods({ ...mods, [feature]: part }); +} + +/** One `decisions.*` key. The stop hook and the question nudge take off|warn|block; `harnesses` a comma list. */ function withDecisions(next: HooksConfig, key: string, value: string): HooksConfig { const decisions = { ...next.decisions, staleness: { ...next.decisions.staleness } }; next.decisions = decisions; switch (key) { - case "decisions.stopHook": { + case "decisions.stopHook": + case "decisions.questionNudge": { if (value !== "off" && value !== "warn" && value !== "block") { throw new Error(`${key} takes off, warn or block, not ${SafeJSON.stringify(value)}`); } - decisions.stopHook = value; + decisions[key === "decisions.stopHook" ? "stopHook" : "questionNudge"] = value; + return next; + } + case "decisions.chatAnswers": { + const mode = DECISION_CHAT_ANSWERS.find((choice) => choice === value); + + if (!mode) { + throw new Error(`${key} takes ${listed(DECISION_CHAT_ANSWERS)}, not ${SafeJSON.stringify(value)}`); + } + + decisions.chatAnswers = mode; return next; } case "decisions.maxBlocksPerSession": @@ -422,24 +540,8 @@ export function applySetting( return next; } - if (key === "mods.fullDiff" || key === "mods.collapseGitSync" || key === "mods.idleCompact") { - next.mods = { ...config.mods, [key.slice("mods.".length)]: asBoolean(key, value) }; - return next; - } - - if (key === "mods.idleCompactMinutes") { - const minutes = asCount(key, value); - - if (minutes > 59) { - throw new Error(`${key} takes 1 to 59: at 60 the 1-hour prompt cache has expired`); - } - - next.mods = { ...config.mods, idleCompactMinutes: minutes }; - return next; - } - - if (key === "mods.idleCompactMinTokens") { - next.mods = { ...config.mods, idleCompactMinTokens: asCount(key, value, 0) }; + if (key.startsWith("mods.")) { + next.mods = withMods(config.mods, key, value); return next; } diff --git a/src/agents/lib/hooks/state.ts b/src/agents/lib/hooks/state.ts index 1df532eecd..7185f97fef 100644 --- a/src/agents/lib/hooks/state.ts +++ b/src/agents/lib/hooks/state.ts @@ -41,6 +41,15 @@ export function bumpContextCounts(sessionId: string, ruleIds: string[]): void { counts[id] = (counts[id] ?? 0) + 1; } + writeContextCounts(sessionId, counts); +} + +/** Sets numbers in the same per-session file that are not counts, such as the start of the turn a check spoke in. */ +export function setContextValues(sessionId: string, values: Record<string, number>): void { + writeContextCounts(sessionId, { ...readContextCounts(sessionId), ...values }); +} + +function writeContextCounts(sessionId: string, counts: Record<string, number>): void { try { mkdirSync(hooksStateDir(), { recursive: true }); writeFileSync(join(hooksStateDir(), `${sessionId}.json`), SafeJSON.stringify(counts)); diff --git a/src/ai-proxy/README.md b/src/ai-proxy/README.md index 972f25c7bf..004e5da249 100644 --- a/src/ai-proxy/README.md +++ b/src/ai-proxy/README.md @@ -84,6 +84,15 @@ How it works (`lib/anthropic-messages.ts`): call timeline stay on the one shape they parse. - Upstreams that report no streaming usage (Grok drops `stream_options`) get an estimate rather than `0`, so Claude Code's context meter and auto-compact still work. +- **The Codex and Grok agents skip the chat hop.** Their `/v1/messages` goes straight to the Responses wire + (`translators/formats/anthropic/anthropic-to-responses.ts` and its mirror), so encrypted reasoning survives a + turn: it rides in the thinking block's `signature`, which Claude Code replays verbatim. Codex stamps it as + `gtr1.codex.<accountHash8>.<model>.<compHash>.<payload>` (`reasoning-envelope.ts`); a later turn replays only + envelopes of the same account and `comp_hash`, in their original order, and drops every other one silently. + A 400 about a replayed blob is retried once without reasoning, and that blob is not sent again. Claude Code's + compaction turn goes out with `tool_choice: "none"`. Reasoning summaries show as thinking text, never as visible + text (`openaiSub.reasoningSummary`: `auto` by default, `detailed`, `none`). `AI_PROXY_CODEX_MESSAGES_ROUTE=chat` + restores the older chat hop for Codex, as `AI_PROXY_GROK_MESSAGES_ROUTE=shim` does for Grok. Caveats: `count_tokens` is a ~4-chars-per-token estimate by choice (the anthropic-subscription upstream does expose a real counter; proxying to it would cost a round trip per turn and is @@ -114,14 +123,25 @@ Append `:<effort>` to any model id to pin the reasoning effort for that call: <account>/<provider>/<upstreamModelId>:<effort> ``` -Supported values: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Example: +Supported values: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Example: `martin/grok/grok-4.6:xhigh`. +- **Codex models check the effort against the model's own list.** The list comes from the live WHAM `/models` + record, then the Codex CLI's `~/.codex/models_cache.json`, then the static table (`tools codex models` prints + it, with the source of each field). `max` is sent as `max`. `ultra` is never sent (the backend answers 400): it + runs as the model's `multi_agent_reasoning_effort`, else `max` (gpt-6.1-sol and gpt-6-astra run it as `xhigh`, + gpt-6-sol as `max`). An effort the model does not list is a 400 that names the allowed ones. A Codex model the + catalog does not know keeps the older fixed list, where `max` runs as `xhigh`. xAI and OpenRouter map `ultra` to + `xhigh`, like `max`. +- **Per-model overrides** go in `config.json` as `codexModels.<slug>`: `window`, `usablePercent`, + `compactPercent`, `defaultEffort`. The proxy uses `defaultEffort`; `tools claude codex` uses the rest. + - **The client's own field wins.** An explicit `reasoning_effort` (or a nested `reasoning.effort` on the Responses door) takes precedence over the suffix, which is the fallback. - **Only on translated routes.** Providers that pass an Anthropic body through untouched (`anthropic-subscription`, `grok-subscription` on `/v1/messages`) do not carry an OpenAI - `reasoning_effort` field, so the suffix is dropped there and a debug line records it. Anthropic + `reasoning_effort` field, so the suffix is dropped there and a debug line records it. The Codex agent's + `/v1/messages` route takes it. Anthropic rejects the field outright (`reasoning_effort: Extra inputs are not permitted`). - **OpenRouter ids already contain a slash but no colon**, so the suffix stays unambiguous — it is split off the END of the id. @@ -194,13 +214,13 @@ Speaks OpenAI to proxy clients on both `/v1/chat/completions` and `/v1/responses } ``` -Two token sources, tried in order: -- `openaiSub.accountName` set → the named `openai-sub` account in `~/.genesis-tools/ai/config.json` (refreshed via Codex OAuth and persisted). -- `openaiSub.accountName` omitted → the Codex CLI's own cache (`~/.codex/auth.json`, read-only; run `codex login` to refresh it). Override the path with `openaiSub.codexAuthPath`. +Token source: +- `openaiSub.accountName` (required for a new account) → the named `openai-sub` login in `~/.genesis-tools/ai/config.json`, through `CodexAccountBinding`: the one Codex auth engine that `tools codex run` and `tools codex spawn` use too, refreshed under one lock. +- Legacy: `openaiSub.accountName` omitted → the Codex CLI's own cache (`~/.codex/auth.json`, read-only, never refreshed by GenesisTools; override the path with `openaiSub.codexAuthPath`). Configs written before 2026-10-25 keep working; the proxy logs a warning once per process, and `tools ai-proxy accounts status` names the fix: `tools ai-proxy accounts bind <account> --codex <login>`. Behavior notes: - **Rate limits / failover:** a 429 puts the account on an in-memory cooldown (honours `Retry-After`, else exponential backoff); `failoverAccountNames` (additional `openai-sub` AI-config accounts) are tried in order within the same request. A 401 triggers one forced token refresh + retry before the account is marked unhealthy for 15 minutes. -- **Parameters:** WHAM rejects `max_output_tokens`, `temperature`, and `top_p` — the proxy strips them (warned once per process, surfaced in the `x-ai-proxy-dropped` response header). Client `reasoning` passes through (unknown efforts clamp to `low`); when omitted, `openaiSub.defaultReasoningEffort` applies (`"none"` omits the field, default `low`). +- **Parameters:** WHAM rejects `max_output_tokens`, `temperature`, and `top_p` — the proxy strips them (warned once per process, surfaced in the `x-ai-proxy-dropped` response header). Client `reasoning` passes through; its effort is checked against the model's catalog list (see "Pinning reasoning effort" above). When omitted, `openaiSub.defaultReasoningEffort` applies (`"none"` omits the field), else the model's own default from the catalog, else `low`. - **Aliases:** built-ins `latest`, `codex`, `mini` resolve against the catalog; `openaiSub.aliases` adds per-account ones. Unknown ids pass through so WHAM's own 400 surfaces. - **Usage:** `tools ai-proxy usage` reports proxy-observed token counts from the local store — ChatGPT exposes no plan-quota endpoint, so weekly-limit numbers are never claimed. - **Local health checks:** shell proxies break localhost curls — use `curl --noproxy '*' http://127.0.0.1:<port>/…` (or unset `http_proxy`/`https_proxy`). diff --git a/src/ai-proxy/commands/accounts-login.ts b/src/ai-proxy/commands/accounts-login.ts index 22c834af79..1b4a5c1255 100644 --- a/src/ai-proxy/commands/accounts-login.ts +++ b/src/ai-proxy/commands/accounts-login.ts @@ -103,7 +103,9 @@ async function runCodexLogin(): Promise<void> { if (!isInteractive()) { out.log.error("Codex login needs a TTY (browser OAuth + code paste)."); out.log.info(suggestCommand("tools ai-proxy", { replaceCommand: ["accounts", "login", "codex"] })); - out.log.info("Alternative: run `codex login` and configure openaiSub.codexAuthPath (CLI-cache mode)."); + out.log.info( + `Alternative: ${toolCommand("codex login")} <name>, then ${suggestCommand("tools ai-proxy", { replaceCommand: ["accounts", "bind", "<proxy account>", "--codex", "<name>"] })}.` + ); return; } diff --git a/src/ai-proxy/commands/accounts.test.ts b/src/ai-proxy/commands/accounts.test.ts index bb891610f4..039a2048c4 100644 --- a/src/ai-proxy/commands/accounts.test.ts +++ b/src/ai-proxy/commands/accounts.test.ts @@ -1,13 +1,22 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runAccountsAllowEnv, runAccountsSetKey, runAccountsSetRouting } from "@app/ai-proxy/commands/accounts"; +import { + codexAccountAuthStatus, + runAccountsAllowEnv, + runAccountsBind, + runAccountsSetKey, + runAccountsSetRouting, +} from "@app/ai-proxy/commands/accounts"; import { loadConfig, saveConfig } from "@app/ai-proxy/lib/config"; import { resetAiProxyConfigStore } from "@app/ai-proxy/lib/config-store"; import { resetAiProxyStorage } from "@app/ai-proxy/lib/storage"; import type { AiProxyAccountConfig } from "@app/ai-proxy/lib/types"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; +import { type AiConfigData, CONFIG_VERSION } from "@genesiscz/utils/ai/config/schema"; import { env } from "@genesiscz/utils/env"; +import { SafeJSON } from "@genesiscz/utils/json"; /** * These cover the non-interactive halves of `accounts set-key` / `allow-env` — @@ -355,3 +364,76 @@ describe("runAccountsSetRouting --match (per-model routes)", () => { ]); }); }); + +describe("codex accounts: one auth engine", () => { + /** An AI-config `openai-sub` login with no credentials: enough for `bind` to find it, nothing to migrate. */ + function seedCodexLogins(...names: string[]): void { + const data: AiConfigData = { + version: CONFIG_VERSION, + defaults: {}, + accounts: names.map((name) => ({ + id: `acc_${name}`, + name, + provider: "openai-sub", + enabled: true, + billing: { mode: "subscription" }, + credentials: {}, + useEnvApiKey: false, + })), + }; + mkdirSync(join(tempDir, ".genesis-tools", "ai"), { recursive: true }); + writeFileSync(join(tempDir, ".genesis-tools", "ai", "config.json"), SafeJSON.stringify(data, null, 2)); + AiConfigStore.invalidate(); + } + + const legacy = account({ name: "codex", provider: "openai-subscription", providerSlug: "codex" }); + + afterEach(() => { + AiConfigStore.invalidate(); + process.exitCode = 0; + }); + + it("bind points a legacy codex account at a managed login and drops the CLI-cache path", async () => { + seedCodexLogins("personal", "work"); + await seed({ ...legacy, openaiSub: { codexAuthPath: "/somewhere/auth.json", defaultReasoningEffort: "high" } }); + + await runAccountsBind("codex", { codex: "work" }); + + expect((await readAccount("codex"))?.openaiSub).toEqual({ + accountName: "work", + defaultReasoningEffort: "high", + }); + }); + + it("bind refuses a login that does not exist and changes nothing", async () => { + seedCodexLogins("personal"); + await seed(legacy); + + await runAccountsBind("codex", { codex: "shop" }); + + expect(process.exitCode).toBe(1); + expect((await readAccount("codex"))?.openaiSub).toBeUndefined(); + }); + + it("bind refuses an account that is not a codex account", async () => { + seedCodexLogins("personal"); + await seed(account()); + + await runAccountsBind("work", { codex: "personal" }); + + expect((await readAccount("work"))?.openaiSub).toBeUndefined(); + }); + + it("status names the legacy read of the Codex CLI login, and never refreshes", async () => { + const missing = join(tempDir, "no-auth.json"); + const status = await codexAccountAuthStatus({ ...legacy, openaiSub: { codexAuthPath: missing } }); + + expect(status).toEqual({ + source: `legacy Codex CLI login (${missing}, read-only)`, + plan: undefined, + expiresAt: undefined, + error: "no token", + }); + expect(status.login).toBeUndefined(); + }); +}); diff --git a/src/ai-proxy/commands/accounts.ts b/src/ai-proxy/commands/accounts.ts index 494bd35e24..e67996c6f1 100644 --- a/src/ai-proxy/commands/accounts.ts +++ b/src/ai-proxy/commands/accounts.ts @@ -10,8 +10,14 @@ import type { AiProxyOpenRouterRoute, AiProxyProviderType, } from "@app/ai-proxy/lib/types"; -import { AIConfig } from "@genesiscz/utils/ai/AIConfig"; -import { CODEX_AUTH_PATH, extractPlanType, readCodexAuthJson } from "@genesiscz/utils/ai/openai/codex-auth"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; +import { CodexAccountBinding } from "@genesiscz/utils/ai/openai/account-binding"; +import { + CODEX_AUTH_PATH, + extractExpiry, + extractPlanType, + readCodexAuthJson, +} from "@genesiscz/utils/ai/openai/codex-auth"; import { isInteractive, suggestCommand } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { SafeJSON } from "@genesiscz/utils/json"; @@ -92,9 +98,56 @@ export async function runAccountsTest(name: string): Promise<void> { } } +/** One codex proxy account's auth, read without refreshing anything. */ +export interface CodexAccountAuthStatus { + /** The managed login it binds (`openaiSub.accountName`), or undefined for the legacy Codex CLI read. */ + login?: string; + source: string; + plan?: string; + expiresAt?: number; + error?: string; +} + +/** + * Where a codex proxy account's token comes from. A managed login goes through `CodexAccountBinding`, the one + * Codex auth engine of ai-proxy, `tools codex run` and `tools codex spawn`, read-only: a status never refreshes, + * because an OpenAI refresh token is single use. An account without `openaiSub.accountName` is the legacy read of + * the Codex CLI's own `auth.json`, which GenesisTools never refreshes. + */ +export async function codexAccountAuthStatus(account: AiProxyAccountConfig): Promise<CodexAccountAuthStatus> { + const login = account.openaiSub?.accountName; + + if (!login) { + const path = account.openaiSub?.codexAuthPath ?? CODEX_AUTH_PATH; + const tokens = await readCodexAuthJson(path); + + return { + source: `legacy Codex CLI login (${path}, read-only)`, + plan: tokens?.accessToken ? extractPlanType(tokens.accessToken) : undefined, + expiresAt: tokens?.expiresAt, + error: tokens?.accessToken ? undefined : "no token", + }; + } + + try { + const binding = await CodexAccountBinding.create(login, { allowRefresh: false }); + const tokens = await binding.tokens({ refresh: false }); + + return { + login, + source: `managed login "${login}"`, + plan: tokens.chatgptPlanType ?? extractPlanType(tokens.accessToken), + expiresAt: extractExpiry(tokens.accessToken), + }; + } catch (err) { + return { login, source: `managed login "${login}"`, error: err instanceof Error ? err.message : String(err) }; + } +} + /** * Auth detail for codex (openai-subscription) accounts: where the token comes - * from, when it expires, and the ChatGPT plan when the JWT carries it. + * from, when it expires, and the ChatGPT plan when the JWT carries it. A legacy + * account gets the command that binds it to a managed login. */ export async function runAccountsStatus(): Promise<void> { const config = await loadConfig(); @@ -106,54 +159,81 @@ export async function runAccountsStatus(): Promise<void> { return; } - const aiConfig = await AIConfig.load(); - for (const account of codexAccounts) { - const accountName = account.openaiSub?.accountName; - let source: string; - let accessToken: string | undefined; - let expiresAt: number | undefined; - - if (accountName) { - const entry = aiConfig.getAccount(accountName); - - if (!entry) { - out.log.warn(`${account.name}: AI-config account "${accountName}" not found`); - continue; - } - - if (entry.tokens.authFile) { - source = `codex-auth.json (${entry.tokens.authFile})`; - const tokens = await readCodexAuthJson(entry.tokens.authFile); - accessToken = tokens?.accessToken; - expiresAt = tokens?.expiresAt; - } else { - source = `ai-config (${accountName})`; - accessToken = entry.tokens.accessToken; - expiresAt = entry.tokens.expiresAt; - } - } else { - const path = account.openaiSub?.codexAuthPath ?? CODEX_AUTH_PATH; - source = `codex-auth.json (${path})`; - const tokens = await readCodexAuthJson(path); - accessToken = tokens?.accessToken; - expiresAt = tokens?.expiresAt; - } - - const plan = accessToken ? extractPlanType(accessToken) : undefined; + const status = await codexAccountAuthStatus(account); const failover = account.openaiSub?.failoverAccountNames; - const expiry = expiresAt - ? `${new Date(expiresAt).toLocaleString()}${expiresAt < Date.now() ? " (EXPIRED)" : ""}` + const expiry = status.expiresAt + ? `${new Date(status.expiresAt).toLocaleString()}${status.expiresAt < Date.now() ? " (EXPIRED)" : ""}` : "unknown"; - out.log.info(`${account.name}${plan ? ` (${plan})` : ""}${account.enabled ? "" : " [disabled]"}`); - out.log.info(` auth: ${accessToken ? source : `${source} — NO TOKEN`}`); + out.log.info(`${account.name}${status.plan ? ` (${status.plan})` : ""}${account.enabled ? "" : " [disabled]"}`); + out.log.info(` auth: ${status.source}${status.error ? ` — ${status.error}` : ""}`); out.log.info(` expires: ${expiry}`); if (failover && failover.length > 0) { out.log.info(` failover: ${failover.join(", ")}`); } + + if (!status.login) { + await warnLegacyCodexAccount(account.name); + } + } +} + +/** The legacy read has no refresh and no named owner; offer the managed logins it can bind to instead. */ +async function warnLegacyCodexAccount(proxyAccount: string): Promise<void> { + const store = await AiConfigStore.readOnly(); + const logins = store.accounts({ provider: "openai-sub", enabled: true }).map((entry) => entry.name); + + out.log.warn( + ` legacy: "${proxyAccount}" reads the Codex CLI's own login (no openaiSub.accountName). GenesisTools never refreshes it, and it is not the shared Codex auth engine.` + ); + + if (logins.length > 0) { + out.log.info(` bind it to a managed login: ${cmd(["accounts", "bind", proxyAccount, "--codex", logins[0]])}`); + out.log.info(` managed logins: ${logins.join(", ")}`); + return; } + + out.log.info(` import the Codex CLI's login: ${toolCommand("codex login")} --import-native`); + out.log.info(` or log in a new one: ${cmd(["accounts", "login", "codex"])}`); +} + +/** + * Point a codex proxy account at a managed `openai-sub` login (`openaiSub.accountName`), so its token comes + * from `CodexAccountBinding` instead of the legacy read of the Codex CLI's `auth.json`. + */ +export async function runAccountsBind(name: string, options: { codex?: string }): Promise<void> { + const config = await loadConfig(); + const account = config.accounts.find((item) => item.name === name); + + if (!account) { + out.log.warn(`Account not found: ${name}`); + out.log.info(cmd(["accounts", "list"])); + return; + } + + if (account.provider !== "openai-subscription") { + out.log.warn(`Account "${name}" is ${account.provider}; bind takes an openai-subscription (codex) account.`); + return; + } + + const login = options.codex?.trim(); + const store = await AiConfigStore.readOnly(); + const logins = store.accounts({ provider: "openai-sub", enabled: true }).map((entry) => entry.name); + + if (!login || !logins.includes(login)) { + out.log.warn(login ? `No enabled Codex login named "${login}".` : "--codex <login> is required."); + out.log.info(`Managed logins: ${logins.join(", ") || "none"} (${toolCommand("codex login")} <name> adds one)`); + process.exitCode = 1; + return; + } + + account.openaiSub = { ...account.openaiSub, accountName: login }; + delete account.openaiSub.codexAuthPath; + await saveConfig(config); + out.log.success(`"${name}" now uses the managed login "${login}".`); + out.log.info(`Restart the proxy to apply: ${toolCommand("ai-proxy down")} && ${toolCommand("ai-proxy up")}`); } export async function runAccountsSetEnabled(name: string, enabled: boolean): Promise<void> { diff --git a/src/ai-proxy/commands/calls.ts b/src/ai-proxy/commands/calls.ts index 319c0a3d38..9f9f031deb 100644 --- a/src/ai-proxy/commands/calls.ts +++ b/src/ai-proxy/commands/calls.ts @@ -10,9 +10,11 @@ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; import { join } from "node:path"; import { splitReasoningEffortSuffix } from "@app/ai-proxy/lib/resolve-model"; +import { requestRows, transcriptOnDisk } from "@app/ai-proxy/lib/sessions/view"; import { getAiProxyStorage } from "@app/ai-proxy/lib/storage"; import { formatTimeline } from "@app/ai-proxy/lib/usage/call-timeline"; import type { UsageRequestRecord } from "@app/ai-proxy/lib/usage/types"; +import { formatLocalDateTimeStamp } from "@genesiscz/utils/date"; import { readLinesSync } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; @@ -147,15 +149,6 @@ interface TranscriptEntry { }; } -/** The transcript file on disk: the recorded path, or `<path>.zst` once the daily pass compressed an older day. */ -function transcriptOnDisk(file: string): string | undefined { - if (existsSync(file)) { - return file; - } - - return existsSync(`${file}.zst`) ? `${file}.zst` : undefined; -} - /** Pull one call's entries out of its session JSONL and print prompt, thinking, answer. */ function printTranscript(record: UsageRequestRecord): void { const ref = record.transcript; @@ -203,6 +196,11 @@ function printTranscript(record: UsageRequestRecord): void { out.println(""); } +/** HH:MM:SS of a row in local time (the index stores UTC). */ +function localClock(ts: string): string { + return formatLocalDateTimeStamp(ts).slice(11); +} + export function runCallsCommand(options: CallsOptions): void { const cutoff = options.sinceMinutes ? Date.now() - options.sinceMinutes * 60_000 : undefined; const matched = collectRecords(options, cutoff); @@ -232,7 +230,7 @@ export function runCallsCommand(options: CallsOptions): void { for (const record of matched) { const t = record.timeline; phases.push([ - record.ts.slice(11, 19), + localClock(record.ts), truncateDisplay(record.tags?.label, 28), (record.elapsedMs / 1000).toFixed(1), t?.upstreamHeadersMs !== undefined ? `${t.upstreamHeadersMs}ms` : "—", @@ -252,20 +250,35 @@ export function runCallsCommand(options: CallsOptions): void { return; } - const table = createBoxTable(["TIME", "STAGE", "LABEL", "MODEL", "EFFORT", "TTFB", "SECS", "IN", "OUT", "STATUS"]); - const today = new Date().toISOString().slice(0, 10); + const table = createBoxTable([ + "TIME", + "STAGE", + "LABEL", + "MODEL", + "EFFORT", + "TTFB", + "SECS", + "IN", + "READ", + "HIT", + "OUT", + "STATUS", + ]); + const today = formatLocalDateTimeStamp(Date.now()).slice(0, 10); + const rows = requestRows(matched); let lastDay = ""; - for (const record of matched) { + for (const [index, record] of matched.entries()) { + const row = rows[index]; const secs = record.elapsedMs / 1000; - const day = record.ts.slice(0, 10); + const day = formatLocalDateTimeStamp(record.ts).slice(0, 10); // A bare HH:MM:SS silently reads as "today". Older rows get their date on // its own line rather than a repeated column — and once any banner has // printed, the transition back to today needs its own banner too, or // today's rows read as belonging to the previous day. if (day !== lastDay && (day !== today || lastDay !== "")) { - table.push([{ colSpan: 10, content: pc.dim(`── ${day} ──`) }]); + table.push([{ colSpan: 12, content: pc.dim(`── ${day} ──`) }]); } lastDay = day; @@ -281,14 +294,17 @@ export function runCallsCommand(options: CallsOptions): void { const ttfb = ttfbMs === undefined ? "—" : (ttfbMs / 1000).toFixed(1); table.push([ - record.ts.slice(11, 19), + localClock(record.ts), truncateDisplay(record.tags?.stage, 12), truncateDisplay(record.tags?.label, 28), truncateDisplay(record.proxyModel, 34), reasoningEffort ?? "—", ttfbMs !== undefined && ttfbMs >= 10_000 ? pc.yellow(ttfb) : ttfb, secs >= 30 ? pc.red(secs.toFixed(1)) : secs.toFixed(1), - String(record.usage?.prompt_tokens ?? "—"), + // IN is the uncached input; READ comes on top of it (session-analyzer's split). + String(row?.input ?? "—"), + row?.cacheKnown ? String(row.cacheRead) : "?", + row?.cacheKnown && row.hit !== undefined ? `${row.hit.toFixed(0)}%` : "?", String(record.usage?.completion_tokens ?? "—"), // A dropped stream answers 200 at the header and then dies, so status // alone would show it as a success. diff --git a/src/ai-proxy/commands/sessions.ts b/src/ai-proxy/commands/sessions.ts new file mode 100644 index 0000000000..b32f340172 --- /dev/null +++ b/src/ai-proxy/commands/sessions.ts @@ -0,0 +1,397 @@ +/** + * `tools ai-proxy sessions`, `session <id>` and `stats`: per session and per agent, requests, uncached input, cache + * read and write, output, reasoning, hit %, cache losses, errors and latency. Hit % = cache read / (uncached + cache + * read + cache write), the session-analyzer definition. Rows come from the request index; rows booked before the + * index kept cache numbers take them from their transcript (plain or `.jsonl.zst`). + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { type AiProxySessionRecord, listSessionRecords, readSessionRecord } from "@app/ai-proxy/lib/sessions/records"; +import { + type AgentStats, + agentOfModel, + lossesBySession, + MIXED_SUBAGENT_THREAD, + parseAgent, + type RequestRow, + readRequestRecords, + requestRow, + requestRows, + statsByAgent, +} from "@app/ai-proxy/lib/sessions/view"; +import { getAiProxyStorage } from "@app/ai-proxy/lib/storage"; +import type { UsageRequestRecord } from "@app/ai-proxy/lib/usage/types"; +import { loadEvents } from "@app/ai-spend/lib/reports/load"; +import { hitPercent, summarizeCache } from "@genesiscz/utils/ai/usage/cache-stats"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { formatLocalDateTimeStamp, parseSince } from "@genesiscz/utils/date"; +import { formatTokens, parseDuration } from "@genesiscz/utils/format"; +import { FileTailer } from "@genesiscz/utils/fs/file-tailer"; +import { out } from "@genesiscz/utils/logger"; +import { createBoxTable, renderCliHeader, renderCliSection, truncateDisplay } from "@genesiscz/utils/table"; +import pc from "picocolors"; + +/** `--since`: a duration back from now (`1d`, `7d`, `90m`) or a local date or time (`2026-10-25`, `08:30`). */ +export function sinceToMs(value: string, now = Date.now()): number { + const duration = parseDuration(value); + + if (duration > 0) { + return now - duration; + } + + const at = parseSince(value, new Date(now)); + + if (!at) { + throw new Error(`--since "${value}": use a duration (1d, 7d, 90m) or a date (2026-10-25)`); + } + + return at.getTime(); +} + +function pct(value: number | undefined): string { + return value === undefined ? "—" : `${value.toFixed(1)}%`; +} + +function hitCell(value: number | undefined): string { + if (value === undefined) { + return "—"; + } + + const text = pct(value); + return value >= 80 ? pc.green(text) : value >= 50 ? pc.yellow(text) : pc.red(text); +} + +function tokens(value: number): string { + return formatTokens(value); +} + +function localTime(ts: string): string { + return formatLocalDateTimeStamp(ts).slice(11); +} + +function sessionRows(record: AiProxySessionRecord, sinceMs = Date.parse(record.createdAt)): UsageRequestRecord[] { + const ids = new Set([record.id, record.claudeSessionId].filter(Boolean)); + return readRequestRecords(sinceMs, (row) => ids.has(row.tags?.session ?? "")); +} + +function lastError(rows: RequestRow[]): string | undefined { + const failed = rows.filter((row) => row.status >= 400 || row.failure).at(-1); + return failed ? `${localTime(failed.ts)} ${failed.failure ?? failed.status}` : undefined; +} + +export function runSessionsCommand(options: { open?: boolean; since?: string; json?: boolean }): void { + const sinceMs = sinceToMs(options.since ?? "7d"); + const records = listSessionRecords() + .filter((record) => !options.open || !record.closedAt) + .filter((record) => Date.parse(record.lastSeenAt ?? record.createdAt) >= sinceMs) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const oldest = Math.min(...records.map((record) => Date.parse(record.createdAt)), Date.now()); + const ids = new Map<string, AiProxySessionRecord>(); + + for (const record of records) { + ids.set(record.id, record); + + if (record.claudeSessionId) { + ids.set(record.claudeSessionId, record); + } + } + + const rows = requestRows(readRequestRecords(oldest, (row) => ids.has(row.tags?.session ?? ""))); + const summaries = records.map((record) => { + const mine = rows.filter((row) => ids.get(row.session ?? "") === record); + const known = mine.filter((row) => row.cacheKnown); + const summary = summarizeCache(known, lossesBySession(known).length); + return { record, requests: mine.length, summary, lastError: lastError(mine) }; + }); + + if (options.json) { + out.result(summaries); + return; + } + + if (summaries.length === 0) { + out.log.info( + `No session records since ${formatLocalDateTimeStamp(sinceMs)} (${join(getAiProxyStorage().getBaseDir(), "sessions")}).` + ); + return; + } + + renderCliHeader( + "Proxy sessions", + `${summaries.length} since ${formatLocalDateTimeStamp(sinceMs)} · hit = read / all input` + ); + const table = createBoxTable([ + "SESSION", + "START", + "CWD", + "CODEX", + "CLAUDE", + "MODEL", + "EFFORT", + "REQ", + "TOKENS", + "HIT", + "LOSS", + "LAST ERROR", + ]); + + for (const { record, requests, summary, lastError: error } of summaries) { + const total = summary.uncached + summary.cacheRead + summary.cacheWrite + summary.output; + table.push([ + record.closedAt ? pc.dim(record.id) : record.id, + formatLocalDateTimeStamp(record.createdAt, { seconds: false }), + truncateDisplay(record.cwd, 28), + record.codexAccount ?? record.codexProxyAccount ?? "—", + record.claudeAccount ?? record.claudeProxyAccount ?? "—", + record.model ?? "—", + record.effort ?? "—", + String(requests), + tokens(total), + hitCell(summary.hitPercent), + String(summary.losses), + error ? pc.red(truncateDisplay(error, 24)) : "—", + ]); + } + + out.println(table.toString()); +} + +function rowCells(row: RequestRow, loss: string | undefined): string[] { + return [ + localTime(row.ts), + row.agent, + truncateDisplay(row.proxyModel, 30), + row.effort ?? "—", + row.failure + ? pc.red(`${row.status} dropped`) + : row.status === 200 + ? pc.green("200") + : pc.red(String(row.status)), + row.ttfbMs === undefined ? "—" : (row.ttfbMs / 1000).toFixed(1), + (row.elapsedMs / 1000).toFixed(1), + tokens(row.input), + row.cacheKnown ? tokens(row.cacheRead) : "?", + row.cacheKnown ? tokens(row.cacheWrite) : "?", + tokens(row.output), + tokens(row.reasoning), + row.cacheKnown ? hitCell(row.hit) : "?", + loss ? pc.red(loss) : row.compaction ? pc.dim("compaction") : "", + ]; +} + +const ROW_HEADERS = [ + "TIME", + "AGENT", + "MODEL", + "EFFORT", + "STATUS", + "TTFB", + "SECS", + "IN", + "READ", + "WRITE", + "OUT", + "REASON", + "HIT", + "LOSS", +]; + +export function runSessionCommand(id: string, options: { follow?: boolean; json?: boolean }): void { + const record = readSessionRecord(id); + + if (!record) { + out.log.error( + `No session record "${id}" (${join(getAiProxyStorage().getBaseDir(), "sessions")}). List them: ${toolCommand("ai-proxy sessions")}` + ); + process.exitCode = 1; + return; + } + + const rows = requestRows(sessionRows(record)); + const known = rows.filter((row) => row.cacheKnown); + const losses = lossesBySession(rows); + const lossAt = new Map(losses.map((loss) => [loss.row, `${loss.cause} −${tokens(loss.before - loss.after)}`])); + const summary = summarizeCache(known, losses.length); + + if (options.json) { + out.result({ record: { ...record, keyHash: undefined }, summary, losses, rows }); + return; + } + + renderCliHeader( + `Session ${record.id}`, + record.closedAt ? `closed ${formatLocalDateTimeStamp(record.closedAt)}` : "open" + ); + out.println( + [ + `started ${formatLocalDateTimeStamp(record.createdAt)} · cwd ${record.cwd ?? "—"}`, + `codex ${record.codexAccount ?? record.codexProxyAccount ?? "—"} · claude ${record.claudeAccount ?? record.claudeProxyAccount ?? "—"}${record.grokAccount || record.grokProxyAccount ? ` · grok ${record.grokAccount ?? record.grokProxyAccount}` : ""}`, + `model ${record.model ?? "—"} · effort ${record.effort ?? "—"} · cache key ${record.cacheKeyPolicy ?? "claude-session"}${record.claudeSessionId ? ` · forked as ${record.claudeSessionId}` : ""}`, + ...(record.moves.length > 0 + ? [`moves ${record.moves.map((move) => `${move.agent} ${move.from}→${move.to}`).join(", ")}`] + : []), + ].join("\n") + ); + out.println( + `${rows.length} requests · in ${tokens(summary.uncached)} · read ${tokens(summary.cacheRead)} · write ${tokens(summary.cacheWrite)} · out ${tokens(summary.output)} · hit ${pct(summary.hitPercent)} · ${losses.length} losses` + ); + + const table = createBoxTable(ROW_HEADERS); + + for (const row of rows) { + table.push(rowCells(row, lossAt.get(row))); + } + + out.println(table.toString()); + + if (!options.follow) { + return; + } + + renderCliSection("Following (Ctrl-C to stop)"); + const ids = new Set([record.id, record.claudeSessionId].filter(Boolean)); + let previous = rows.at(-1); + const tailer = new FileTailer<UsageRequestRecord>( + join(getAiProxyStorage().getBaseDir(), "usage", "requests.jsonl"), + { + onLine: (entry) => { + if (!ids.has(entry.tags?.session ?? "")) { + return; + } + + const row = requestRow(entry); + const fell = previous && row.cacheKnown && previous.cacheKnown ? previous.cacheRead - row.cacheRead : 0; + const loss = + previous && fell >= 2_048 && fell > 0.5 * previous.cacheRead ? `fell −${tokens(fell)}` : undefined; + const live = createBoxTable(ROW_HEADERS); + live.push(rowCells(row, loss)); + out.println(live.toString()); + previous = row; + }, + } + ); + tailer.start(); +} + +function statsTable(stats: AgentStats[]): string { + const table = createBoxTable([ + "AGENT", + "SESS", + "REQ", + "IN", + "READ", + "WRITE", + "OUT", + "REASON", + "HIT", + "P10", + "MEDIAN", + "P90", + "SESS MED", + "LOSSES", + "ERR", + ]); + + for (const agent of stats) { + const causes = Object.entries(agent.lossesByCause) + .filter(([, count]) => count > 0) + .map(([cause, count]) => `${count} ${cause}`) + .join(", "); + table.push([ + agent.agent, + String(agent.sessions), + agent.unknownCache > 0 ? `${agent.requests} (${agent.unknownCache} ?)` : String(agent.requests), + tokens(agent.uncached), + tokens(agent.cacheRead), + tokens(agent.cacheWrite), + tokens(agent.output), + tokens(agent.reasoning), + hitCell(agent.hitPercent), + pct(agent.p10), + pct(agent.median), + pct(agent.p90), + pct(agent.sessionMedian), + causes || "0", + String(agent.errors), + ]); + } + + return table.toString(); +} + +/** + * The same rows from Claude Code's own transcripts (`~/.claude/projects`, read by ai-spend's Claude parser, one row + * per `message.id`): the "before" numbers of sessions the proxy never tagged, measured by the same code as "after". + * A session's subagents form one extra thread (`<session>:subagents`), never mixed into the main loop. + */ +export function transcriptRequestRows(sinceMs: number): RequestRow[] { + return loadEvents({ home: homedir(), sources: ["claude"], minMtimeMs: sinceMs }) + .filter((event) => Date.parse(event.timestamp) >= sinceMs) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + .map((event) => { + const split = { + uncached: event.inputTokens, + cacheRead: event.cacheReadTokens, + cacheWrite: event.cacheCreationTokens, + }; + return { + t: Date.parse(event.timestamp), + ts: event.timestamp, + agent: agentOfModel(event.model), + model: event.model, + proxyModel: event.model, + status: 200, + elapsedMs: 0, + input: split.uncached, + cacheRead: split.cacheRead, + cacheWrite: split.cacheWrite, + output: event.outputTokens, + reasoning: event.reasoningOutputTokens ?? 0, + hit: hitPercent(split), + cacheKnown: true, + session: event.isSidechain ? `${event.sessionId}${MIXED_SUBAGENT_THREAD}` : event.sessionId, + }; + }); +} + +export function runStatsCommand(options: { + session?: string; + agent?: string; + since?: string; + json?: boolean; + fromTranscripts?: boolean; +}): void { + const sinceMs = sinceToMs(options.since ?? "7d"); + const agent = parseAgent(options.agent); + const source = options.fromTranscripts + ? transcriptRequestRows(sinceMs) + : requestRows(readRequestRecords(sinceMs, (row) => !options.session || row.tags?.session === options.session)); + const rows = source.filter( + (row) => (!agent || row.agent === agent) && (!options.session || row.session?.startsWith(options.session)) + ); + const stats = statsByAgent(rows); + + if (options.json) { + out.result({ since: new Date(sinceMs).toISOString(), stats }); + return; + } + + if (stats.length === 0) { + out.log.info( + `No proxied requests since ${formatLocalDateTimeStamp(sinceMs)}${options.session ? ` for session ${options.session}` : ""}.` + ); + return; + } + + renderCliHeader( + options.fromTranscripts ? "Cache stats from Claude Code transcripts" : "Proxy cache stats", + `since ${formatLocalDateTimeStamp(sinceMs)} · hit = read / (in + read + write) · P10/MEDIAN/P90 per request` + ); + out.println(statsTable(stats)); + out.println( + pc.dim( + "A loss: the cache read fell by over half and at least 2,048 tokens against the previous request of the same session. ? = rows without cache numbers, left out of the hit %." + ) + ); +} diff --git a/src/ai-proxy/commands/usage.ts b/src/ai-proxy/commands/usage.ts index dad6a87554..206cc60d61 100644 --- a/src/ai-proxy/commands/usage.ts +++ b/src/ai-proxy/commands/usage.ts @@ -11,6 +11,7 @@ import { usageStorePaths, } from "@app/ai-proxy/lib/usage/store"; import type { AccountBillingSnapshot, UsageRequestRecord } from "@app/ai-proxy/lib/usage/types"; +import { hitPercent } from "@genesiscz/utils/ai/usage/cache-stats"; import { formatTokens } from "@genesiscz/utils/format"; import { logger, out } from "@genesiscz/utils/logger"; import { formatDotStatus } from "@genesiscz/utils/table"; @@ -244,8 +245,21 @@ export async function runUsageCommand(options: UsageCommandOptions): Promise<voi for (const [model, stats] of entries) { const shortModel = model.split("/").slice(2).join("/") || model; const rateLimits = stats.rate_limits > 0 ? `, ${stats.rate_limits} rate limits` : ""; + // Cache columns exist only for requests booked since 2026-10-25; older days show none. + const hit = + stats.cache_read_tokens === undefined + ? undefined + : hitPercent({ + uncached: stats.uncached_input_tokens ?? 0, + cacheRead: stats.cache_read_tokens, + cacheWrite: stats.cache_write_tokens ?? 0, + }); + const cache = + hit === undefined + ? "" + : `, read ${formatTokens(stats.cache_read_tokens ?? 0)}, write ${formatTokens(stats.cache_write_tokens ?? 0)}, hit ${hit.toFixed(1)}%`; out.log.info( - ` 30d ${shortModel}: ${stats.requests} req, ${formatTokens(stats.total_tokens)} tok${rateLimits}` + ` 30d ${shortModel}: ${stats.requests} req, ${formatTokens(stats.total_tokens)} tok${cache}${rateLimits}` ); } } diff --git a/src/ai-proxy/index.ts b/src/ai-proxy/index.ts index 235da46c44..c10968677f 100644 --- a/src/ai-proxy/index.ts +++ b/src/ai-proxy/index.ts @@ -3,6 +3,7 @@ import { type AccountsRoutingPatch, runAccountsAllowEnv, + runAccountsBind, runAccountsList, runAccountsRemove, runAccountsSetEnabled, @@ -23,6 +24,7 @@ import { runIntrospectCommand } from "@app/ai-proxy/commands/introspect"; import { runLinkCommand } from "@app/ai-proxy/commands/link"; import { runModelsCommand } from "@app/ai-proxy/commands/models"; import { runServeCommand } from "@app/ai-proxy/commands/serve"; +import { runSessionCommand, runSessionsCommand, runStatsCommand } from "@app/ai-proxy/commands/sessions"; import { runStatusCommand } from "@app/ai-proxy/commands/status"; import { runUpCommand } from "@app/ai-proxy/commands/up"; import { runUsageCommand } from "@app/ai-proxy/commands/usage"; @@ -33,6 +35,7 @@ import type { AiProxyProviderType, CursorTranslationMode, ThinkingPresentationMo import { setProxyUsageSink } from "@app/ai-proxy/lib/usage/usage-events"; import { recordUsage } from "@genesiscz/utils/ai/usage"; import { runTool } from "@genesiscz/utils/cli"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { configureLogger } from "@genesiscz/utils/logger"; import { Command } from "commander"; @@ -134,6 +137,42 @@ program await runModelsCommand(options); }); +program + .command("sessions") + .description("One row per session record: accounts, model, requests, tokens, cache hit %, losses, last error") + .option("--open", "Only sessions that are still open") + .option("--since <when>", "Sessions active since a duration (1d, 7d) or a local date", "7d") + .option("--json", "Machine-readable output") + .action((options: { open?: boolean; since?: string; json?: boolean }) => { + runSessionsCommand(options); + }); + +program + .command("session <id>") + .description("One session: its record, then one row per request with cache read, write, hit % and losses") + .option("-f, --follow", "Keep printing the session's new requests as they come") + .option("--json", "Machine-readable output") + .action((id: string, options: { follow?: boolean; json?: boolean }) => { + runSessionCommand(id, options); + }); + +program + .command("stats") + .description("Cache totals per agent: hit %, its spread per request and per session, losses by cause") + .option("--session <id>", "One session only") + .option("--agent <agent>", "codex, claude (or anthropic), grok, other") + .option("--since <when>", "A duration (1d, 7d) or a local date", "7d") + .option( + "--from-transcripts", + "Read Claude Code's own transcripts instead of the proxy ledger (the agent comes from the model id)" + ) + .option("--json", "Machine-readable output") + .action( + (options: { session?: string; agent?: string; since?: string; json?: boolean; fromTranscripts?: boolean }) => { + runStatsCommand(options); + } + ); + program .command("calls") .description("Query proxied calls (session/stage/label/model/slow) and open full transcripts") @@ -345,6 +384,19 @@ accountsCmd await runAccountsStatus(); }); +accountsCmd + .command("bind <name>") + .description( + `Point a codex account at a managed login (${toolCommand("codex login")}), instead of the Codex CLI's auth.json` + ) + .option( + "--codex <login>", + `The managed openai-sub login, e.g. one listed by \`${toolCommand("ai-proxy accounts status")}\`` + ) + .action(async (name: string, options: { codex?: string }) => { + await runAccountsBind(name, options); + }); + accountsCmd .command("list") .description("List configured accounts") diff --git a/src/ai-proxy/lib/anthropic-messages.test.ts b/src/ai-proxy/lib/anthropic-messages.test.ts index f1e10d1727..398eeba6e8 100644 --- a/src/ai-proxy/lib/anthropic-messages.test.ts +++ b/src/ai-proxy/lib/anthropic-messages.test.ts @@ -271,6 +271,55 @@ describe("countAnthropicInputTokens", () => { }); }); +describe("anthropicMessagesPipeline: the conversation's cache key", () => { + const SESSION = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"; + const body = SafeJSON.stringify({ + model: PROXY_MODEL, + max_tokens: 32, + metadata: { user_id: SafeJSON.stringify({ device_id: "d", account_uuid: "a", session_id: SESSION }) }, + messages: [{ role: "user", content: "hi" }], + }); + const answer = () => + new Response( + SafeJSON.stringify({ + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + + it("carries Claude Code's session id as prompt_cache_key to a provider that keys its cache by it", async () => { + const provider = { ...fakeProvider(answer), acceptsPromptCacheKey: true }; + const result = await anthropicMessagesPipeline({ + provider, + upstreamModel: UPSTREAM_MODEL, + proxyModel: PROXY_MODEL, + req: request(), + bodyText: body, + }); + + expect(SafeJSON.parse(provider.seen[0]?.bodyText ?? "{}", { strict: true })).toMatchObject({ + prompt_cache_key: SESSION, + }); + expect(result.promptCacheKey).toEqual({ key: SESSION, source: "claude-session" }); + }); + + it("adds no prompt_cache_key for a provider that did not ask for one", async () => { + const provider = fakeProvider(answer); + await anthropicMessagesPipeline({ + provider, + upstreamModel: UPSTREAM_MODEL, + proxyModel: PROXY_MODEL, + req: request(), + bodyText: body, + }); + + expect(SafeJSON.parse(provider.seen[0]?.bodyText ?? "{}", { strict: true })).not.toHaveProperty( + "prompt_cache_key" + ); + }); +}); + describe("anthropicMessagesPipeline", () => { it("translates a non-streaming answer into an Anthropic message", async () => { const provider = fakeProvider( diff --git a/src/ai-proxy/lib/anthropic-messages.ts b/src/ai-proxy/lib/anthropic-messages.ts index 66b808b1e1..32e33de6b5 100644 --- a/src/ai-proxy/lib/anthropic-messages.ts +++ b/src/ai-proxy/lib/anthropic-messages.ts @@ -1,4 +1,4 @@ -import type { ProxyProvider } from "@app/ai-proxy/lib/providers/types"; +import type { ProviderRequestContext, ProxyProvider } from "@app/ai-proxy/lib/providers/types"; import { safeStreamControllerError } from "@app/ai-proxy/lib/safe-stream-controller"; import { withSseKeepalive } from "@app/ai-proxy/lib/sse-keepalive"; import { normalizeAnthropicToOpenAI } from "@app/ai-proxy/lib/translators/formats/anthropic/normalize"; @@ -13,6 +13,12 @@ import { import { type CallTimeline, TimelineCollector } from "@app/ai-proxy/lib/usage/call-timeline"; import { captureResponseBody } from "@app/ai-proxy/lib/usage/capture-response"; import { type PipelineResult, pipelineResult } from "@app/ai-proxy/lib/usage/pipeline-result"; +import { + claudeSessionIdFromMetadata, + type PromptCacheKey, + type PromptCacheKeyPolicy, + resolvePromptCacheKey, +} from "@genesiscz/utils/ai/prompt-cache-key"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { isObject } from "@genesiscz/utils/object"; @@ -36,6 +42,8 @@ const PING_INTERVAL_MS = 10_000; const CLIENT_CAPTURE_MAX_CHARS = 1_000_000; export interface AnthropicMessagesResult extends PipelineResult { + /** The conversation's cache key and where it came from; absent when the body named nothing to key on. */ + promptCacheKey?: PromptCacheKey; /** The translated request body, for the usage row (the client's body is Anthropic-shaped). */ openAiBodyText: string; /** The Anthropic frames the client actually received, for triage. */ @@ -67,7 +75,11 @@ function errorPipelineResult( * OpenAI field name differs per upstream, so both are set and providers drop * whichever they reject. */ -export function anthropicBodyToOpenAiBody(parsed: Record<string, unknown>, upstreamModel: string): string { +export function anthropicBodyToOpenAiBody( + parsed: Record<string, unknown>, + upstreamModel: string, + promptCacheKey?: string +): string { // A spread is SHALLOW, so `body.messages` was the caller's own array and // `normalizeAnthropicToOpenAI` unshifted the system turn straight into it. // Calling this twice on one body produced two system messages, and the @@ -78,6 +90,10 @@ export function anthropicBodyToOpenAiBody(parsed: Record<string, unknown>, upstr body.model = upstreamModel; + if (promptCacheKey) { + body.prompt_cache_key = promptCacheKey; + } + return SafeJSON.stringify(body); } @@ -534,6 +550,7 @@ async function anthropicPassthrough({ bodyText, prof, startedAt, + context, }: { provider: ProxyProvider; upstreamModel: string; @@ -541,10 +558,11 @@ async function anthropicPassthrough({ bodyText: string; prof: ProfilerScope; startedAt?: number; + context: ProviderRequestContext; }): Promise<AnthropicMessagesResult> { const collector = new TimelineCollector(startedAt ?? performance.now()); const endDispatch = prof.start("messages.passthrough-dispatch"); - const upstream = await provider.messages?.(req, upstreamModel, bodyText); + const upstream = await provider.messages?.(req, upstreamModel, bodyText, context); collector.markUpstreamHeaders(); endDispatch(); @@ -561,6 +579,7 @@ async function anthropicPassthrough({ captureFailure: captured.captureFailure, openAiBodyText: bodyText, clientResponseBody: captured.responseBody, + promptCacheKey: context.promptCacheKey, }; } @@ -571,6 +590,9 @@ export async function anthropicMessagesPipeline({ req, bodyText, startedAt, + cacheKeyPolicy, + recordId, + sessionAgents, }: { provider: ProxyProvider; upstreamModel: string; @@ -579,6 +601,12 @@ export async function anthropicMessagesPipeline({ bodyText: string; /** performance.now() taken when the proxy received the request (timeline anchor). */ startedAt?: number; + /** How the conversation's cache key is chosen (default: Claude Code's session id). */ + cacheKeyPolicy?: PromptCacheKeyPolicy; + /** The session record id, the fallback key when the body names no Claude Code session. */ + recordId?: string; + /** The subagents the session defined with their own model (`ProviderRequestContext.sessionAgents`). */ + sessionAgents?: readonly string[]; }): Promise<AnthropicMessagesResult> { const prof = profiler.scope("ai-proxy"); const parsed = SafeJSON.parse(bodyText, { strict: true }); @@ -588,15 +616,37 @@ export async function anthropicMessagesPipeline({ } const wantsStream = parsed.stream === true; + const promptCacheKey = resolvePromptCacheKey({ + policy: cacheKeyPolicy, + claudeSessionId: claudeSessionIdFromMetadata(parsed.metadata), + recordId, + content: { + model: upstreamModel, + system: parsed.system, + first: Array.isArray(parsed.messages) ? parsed.messages[0] : undefined, + }, + }); // Both ends already speak Anthropic: translating down to OpenAI and back // would only lose fields OpenAI cannot carry. Forward untouched. if (provider.messages) { - return anthropicPassthrough({ provider, upstreamModel, req, bodyText, prof, startedAt }); + return anthropicPassthrough({ + provider, + upstreamModel, + req, + bodyText, + prof, + startedAt, + context: { promptCacheKey, sessionAgents }, + }); } const openAiBodyText = prof.measure("messages.translate-request", () => - anthropicBodyToOpenAiBody(parsed, upstreamModel) + anthropicBodyToOpenAiBody( + parsed, + upstreamModel, + provider.acceptsPromptCacheKey ? promptCacheKey?.key : undefined + ) ); const result = wantsStream @@ -612,5 +662,5 @@ export async function anthropicMessagesPipeline({ }) : await messagesJson({ provider, upstreamModel, proxyModel, req, openAiBodyText, prof, startedAt }); - return { ...result, openAiBodyText }; + return { ...result, openAiBodyText, promptCacheKey: provider.acceptsPromptCacheKey ? promptCacheKey : undefined }; } diff --git a/src/ai-proxy/lib/clients.ts b/src/ai-proxy/lib/clients.ts index 8a304451e1..e3fc8eae14 100644 --- a/src/ai-proxy/lib/clients.ts +++ b/src/ai-proxy/lib/clients.ts @@ -1,12 +1,27 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { statSync } from "node:fs"; import { extractBearerToken } from "@app/ai-proxy/lib/auth-middleware"; -import type { AiProxyClientConfig, AiProxyConfig, AiProxyProviderType } from "@app/ai-proxy/lib/types"; +import { + type AiProxySessionRecord, + isSessionKey, + resolveSessionKey, + sessionProxyAccounts, +} from "@app/ai-proxy/lib/sessions/records"; +import type { + AiProxyAccountConfig, + AiProxyClientConfig, + AiProxyConfig, + AiProxyProviderType, +} from "@app/ai-proxy/lib/types"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { formatLocalDateTimeStamp } from "@genesiscz/utils/date"; import { logger } from "@genesiscz/utils/logger"; import { isSecureRef, resolveSecret, type SecretStore } from "@genesiscz/utils/security"; import { vaultAdmin } from "@genesiscz/utils/security/SecretStore"; export const OWNER_CLIENT_NAME = "owner"; +/** Every `gts_` session key books under this one client name; `tags.session` tells the sessions apart. */ +export const SESSION_CLIENT_NAME = "session"; const MIN_KEY_LENGTH = 16; @@ -153,6 +168,8 @@ export interface ResolvedClient { name: string; isOwner: boolean; config?: AiProxyClientConfig; + /** Set for a `gts_` session key: owner-equivalent, but only for the accounts its record names. */ + session?: AiProxySessionRecord; } function digestsEqual(a: string, b: string): boolean { @@ -177,6 +194,15 @@ export async function resolveClient(req: Request, config: AiProxyConfig): Promis return null; } + // A session key is looked up by its hash in the session records, never compared with client keys. Unknown and + // closed keys get a 403 from the server before any route runs (`sessionKeyRejection`). + if (isSessionKey(token)) { + const resolution = resolveSessionKey(token); + return resolution.state === "open" + ? { name: SESSION_CLIENT_NAME, isOwner: false, session: resolution.record } + : null; + } + let resolved: ResolvedClient | null = null; if (typeof config.proxyApiKey === "string" && digestsEqual(token, config.proxyApiKey)) { @@ -303,6 +329,11 @@ export function clientProviderDenial(client: ResolvedClient, providerType: AiPro return null; } + // A session is judged per account (`routeDenial`); without the account, refuse rather than guess. + if (client.session) { + return "a session key is checked against the accounts in its session record"; + } + if (SUBSCRIPTION_PROVIDER_TYPES.has(providerType)) { return `provider "${providerType}" bills a personal subscription and is owner-only`; } @@ -315,3 +346,63 @@ export function clientProviderDenial(client: ResolvedClient, providerType: AiPro return null; } + +/** The denial for one routed request: a session against its record, every other client by provider type. */ +export function routeDenial(client: ResolvedClient, account: AiProxyAccountConfig): string | null { + return client.session + ? sessionRouteDenial(client.session, account) + : clientProviderDenial(client, account.provider); +} + +/** + * A session may use the proxy accounts its record names. A subscription account is allowed; a metered one only + * when the user confirmed it for this session, and never with an API key taken from the proxy's environment. + */ +export function sessionRouteDenial(record: AiProxySessionRecord, account: AiProxyAccountConfig): string | null { + const allowed = sessionProxyAccounts(record); + + if (!allowed.includes(account.name)) { + return `account "${account.name}" is not part of session ${record.id} (its accounts: ${allowed.join(", ") || "none"})`; + } + + if (SUBSCRIPTION_PROVIDER_TYPES.has(account.provider)) { + return null; + } + + if (!record.meteredConfirmed.some((confirmation) => confirmation.account === account.name)) { + return ( + `account "${account.name}" (${account.provider}) is billed per token, and this session uses subscription ` + + `accounts only. To use it anyway, relaunch with: ${toolCommand("claude codex")} … --allow-metered ${account.name}` + ); + } + + if (!account.apiKey?.trim()) { + return ( + `account "${account.name}" has no API key of its own, so it would spend a key from the proxy's ` + + "environment, which a session never does. Store a key on the account first." + ); + } + + return null; +} + +/** Why a `gts_` bearer gets no access, or null when the request does not carry one or it is open. */ +export function sessionKeyRejection(req: Request): string | null { + const token = extractBearerToken(req); + + if (!isSessionKey(token)) { + return null; + } + + const resolution = resolveSessionKey(token); + + if (resolution.state === "open") { + return null; + } + + const closed = + resolution.state === "closed" + ? ` (session ${resolution.record.id} closed ${formatLocalDateTimeStamp(resolution.record.closedAt ?? Date.now())})` + : ""; + return `unknown or closed session${closed}; relaunch with ${toolCommand("claude codex")}`; +} diff --git a/src/ai-proxy/lib/codex-catalog.ts b/src/ai-proxy/lib/codex-catalog.ts new file mode 100644 index 0000000000..aed3864919 --- /dev/null +++ b/src/ai-proxy/lib/codex-catalog.ts @@ -0,0 +1,23 @@ +import { + type CodexModelInfo, + type CodexModelOverride, + mergeCodexModelRecords, + readCodexModelsCache, +} from "@genesiscz/utils/ai/openai/codex-models"; +import type { WhamModelRecord } from "@genesiscz/utils/ai/openai/sub-models"; + +/** + * The proxy's view of the Codex model catalog. `server.ts` hands over `config.codexModels` on every request (the + * same hot reload as the rest of the config), and the Codex provider asks `codexModelInfo` per request. The request + * path adds no network call: it uses the live list only when the provider already holds one (the image guard and + * `/v1/models` fetch it), else the Codex CLI's `models_cache.json`, else the static table. + */ +let overrides: Record<string, CodexModelOverride> = {}; + +export function setCodexModelOverrides(next: Record<string, CodexModelOverride> | undefined): void { + overrides = next ?? {}; +} + +export function codexModelInfo(slug: string, live: WhamModelRecord[] | null): CodexModelInfo | undefined { + return mergeCodexModelRecords({ live, cache: readCodexModelsCache(), overrides }).get(slug); +} diff --git a/src/ai-proxy/lib/providers/anthropic-subscription.test.ts b/src/ai-proxy/lib/providers/anthropic-subscription.test.ts index 30ead29442..1e4aa25ded 100644 --- a/src/ai-proxy/lib/providers/anthropic-subscription.test.ts +++ b/src/ai-proxy/lib/providers/anthropic-subscription.test.ts @@ -136,4 +136,90 @@ describe("AnthropicSubscriptionProvider", () => { expect(text).toContain("Hi"); expect(text).toContain("data: [DONE]"); }); + + it("/v1/messages passthrough: the cached prefix arrives byte-identical, behind one billing block stable per conversation", async () => { + // Anthropic caches only at explicit cache_control breakpoints, and only on a byte-identical prefix. + // Claude Code places them on the last tool, the system blocks and the latest user turn. The subscription + // fetch puts its billing block before them (system[0]); it hashes only the FIRST user message, so it is + // the same on every turn of one conversation and the prefix stays cacheable. + const ephemeral = { type: "ephemeral" }; + const client = { + model: "claude-haiku-4-5", + max_tokens: 64, + stream: false, + metadata: { + user_id: SafeJSON.stringify({ device_id: "dev", session_id: "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b" }), + }, + tools: [ + { + name: "Read", + description: "read a file", + input_schema: { type: "object", properties: { path: { type: "string" } } }, + }, + { + name: "Bash", + description: "run", + input_schema: { type: "object", properties: {} }, + cache_control: ephemeral, + }, + ], + system: [ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.", + cache_control: ephemeral, + }, + { + type: "text", + text: "You are an interactive agent. ünïcödé stays as sent.", + cache_control: ephemeral, + }, + ], + messages: [ + { role: "user", content: "first" }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + { role: "user", content: [{ type: "text", text: "second", cache_control: ephemeral }] }, + ], + }; + const sent: Array<Record<string, unknown>> = []; + + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + sent.push(SafeJSON.parse(String(init?.body)) as Record<string, unknown>); + + return new Response( + SafeJSON.stringify({ id: "msg_1", type: "message", role: "assistant", content: [], usage: {} }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; + + const { AnthropicSubscriptionProvider } = await import("./anthropic-subscription"); + const provider = await AnthropicSubscriptionProvider.create(account); + const send = async (body: typeof client) => { + const bodyText = SafeJSON.stringify(body); + const res = await provider.messages( + new Request("http://localhost/v1/messages", { method: "POST", body: bodyText }), + "haiku", + bodyText + ); + expect(res.status).toBe(200); + }; + + await send(client); + await send({ + ...client, + messages: [...client.messages, { role: "assistant", content: [{ type: "text", text: "done" }] }], + }); + + const [first, next] = sent; + const system = first?.system as Array<{ text: string }>; + + for (const field of ["tools", "messages", "metadata"] as const) { + expect(SafeJSON.stringify(first?.[field])).toBe(SafeJSON.stringify(client[field])); + } + + expect(system[0]?.text).toStartWith("x-anthropic-billing-header:"); + expect(SafeJSON.stringify(system.slice(1))).toBe(SafeJSON.stringify(client.system)); + expect(SafeJSON.stringify(next?.system)).toBe(SafeJSON.stringify(first?.system)); + expect(SafeJSON.stringify(next?.tools)).toBe(SafeJSON.stringify(first?.tools)); + }); }); diff --git a/src/ai-proxy/lib/providers/fixtures/wham-reasoning-parallel-tools.sse b/src/ai-proxy/lib/providers/fixtures/wham-reasoning-parallel-tools.sse new file mode 100644 index 0000000000..4d648fc034 --- /dev/null +++ b/src/ai-proxy/lib/providers/fixtures/wham-reasoning-parallel-tools.sse @@ -0,0 +1,533 @@ +event: response.created +data: {"type":"response.created","sequence_number":0,"safety_buffering":false,"response":{"id":"resp_0b859be91c473db4016ade78c3537881918295a15608676db4","object":"response","created_at":1792964803,"completed_at":null,"status":"in_progress","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-6.1-sol","access_programs":{"cyber":"standard"},"previous_response_id":null,"instructions":"You are a tool-using assistant. When the user asks for two lookups, call both tools in one turn.","output":[],"error":null,"incomplete_details":null,"tools":[{"type":"function","name":"get_time","description":"Current time in a time zone","parameters":{"type":"object","properties":{"timezone":{"type":"string"}},"required":["timezone"],"additionalProperties":false},"output_schema":null,"strict":true},{"type":"function","name":"get_weather","description":"Current weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"output_schema":null,"strict":true}],"tool_choice":"auto","parallel_tool_calls":true,"prompt_cache_key":"7a04de2f-d096-796d-355c-0c55e687e96f","prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"high","summary":"detailed","context":"all_turns","mode":"standard"},"user":null,"usage":null,"tool_usage":{"image_gen":{"input_tokens":0,"output_tokens":0,"total_tokens":0,"input_tokens_details":{"text_tokens":0,"image_tokens":0},"output_tokens_details":{"image_tokens":0,"text_tokens":0}},"web_search":{"num_requests":0}},"safety_identifier":"user-qKqgLtFS4OvrpqduuhxW5xKe","store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"auto","truncation":"disabled"}} + +event: response.in_progress +data: {"type":"response.in_progress","sequence_number":1,"safety_buffering":false,"response":{"id":"resp_0b859be91c473db4016ade78c3537881918295a15608676db4","object":"response","created_at":1792964803,"completed_at":null,"status":"in_progress","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-6.1-sol","access_programs":{"cyber":"standard"},"previous_response_id":null,"instructions":"You are a tool-using assistant. When the user asks for two lookups, call both tools in one turn.","output":[],"error":null,"incomplete_details":null,"tools":[{"type":"function","name":"get_time","description":"Current time in a time zone","parameters":{"type":"object","properties":{"timezone":{"type":"string"}},"required":["timezone"],"additionalProperties":false},"output_schema":null,"strict":true},{"type":"function","name":"get_weather","description":"Current weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"output_schema":null,"strict":true}],"tool_choice":"auto","parallel_tool_calls":true,"prompt_cache_key":"7a04de2f-d096-796d-355c-0c55e687e96f","prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"high","summary":"detailed","context":"all_turns","mode":"standard"},"user":null,"usage":null,"tool_usage":{"image_gen":{"input_tokens":0,"output_tokens":0,"total_tokens":0,"input_tokens_details":{"text_tokens":0,"image_tokens":0},"output_tokens_details":{"image_tokens":0,"text_tokens":0}},"web_search":{"num_requests":0}},"safety_identifier":"user-qKqgLtFS4OvrpqduuhxW5xKe","store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"auto","truncation":"disabled"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"type":"reasoning","id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","summary":[],"content":[],"encrypted_content":"REDACTED-ENCRYPTED-1"}} + +: keep-alive + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","sequence_number":3,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":4,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":"**Evaluating border ambiguities**\n\nI","obfuscation":"ntSOcE1KHZ4H"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":5,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" need","obfuscation":"A7NqMj8oEDR"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":6,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" to","obfuscation":"DvRZEplTvCipO"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":7,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" clarify","obfuscation":"6fTNdbTi"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":8,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" that","obfuscation":"k1VRey7nvJG"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":9,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" both","obfuscation":"PXSTKbZhyz4"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":10,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Austria","obfuscation":"NQUuX37D"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":11,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" borders","obfuscation":"3IIrim8c"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":12,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Poland","obfuscation":"DVPCUNT36"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":13,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" and","obfuscation":"hve2T0XStHtw"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":14,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Czech","obfuscation":"nNjRhYuhZZ"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":15,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":"ia","obfuscation":"xX4nHq9Y15q6X3"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":16,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":",","obfuscation":"bVAMiSTx0CW9ouO"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":17,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" as","obfuscation":"4EsGDcVOzya4l"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":18,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" well","obfuscation":"6YRBYqGWHCY"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":19,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" as","obfuscation":"WhXcChQgxn9yj"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":20,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Slovakia","obfuscation":"yLSoL43"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":21,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":".","obfuscation":"mgytqKaMIvdCHk5"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":22,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" The","obfuscation":"JGdxtn3pGNgZ"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":23,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" user","obfuscation":"9nyFBsqh64F"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":24,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" likely","obfuscation":"Reusb4auS"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":25,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" refers","obfuscation":"OzLeePrHs"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":26,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" to","obfuscation":"zGFRlMq9i85Xe"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":27,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Prague","obfuscation":"YCqU09MbM"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":28,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" in","obfuscation":"RRtkeMvO638lI"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":29,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Czech","obfuscation":"kX6E7HGe5c"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":30,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":"ia","obfuscation":"A2kDyoB3ZIIPMD"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":31,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":",","obfuscation":"nGOBkrchH9w0n8q"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":32,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" but","obfuscation":"KuYdguKrJs69"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":33,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Bratis","obfuscation":"n6xUilfRJ"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":34,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":"lava","obfuscation":"O5h1PXGHs0OA"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":35,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" in","obfuscation":"KpZ8ezdnqMKzw"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":36,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Slovakia","obfuscation":"UnFuqDt"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":37,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" also","obfuscation":"PyQq9OiUV8V"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":38,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" fits","obfuscation":"heXHyI0eknm"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":39,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":".","obfuscation":"vhuve45fHJowSHA"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":40,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" I","obfuscation":"pPSLfchHiQomBe"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":41,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" could","obfuscation":"nu5YFtTQUR"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":42,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" ask","obfuscation":"S00MNOLKNMCm"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":43,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" for","obfuscation":"uCMsQf1gSN14"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":44,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" clarification","obfuscation":"yB"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":45,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":",","obfuscation":"JecBdIQOfO8RUIq"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":46,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" but","obfuscation":"U1xlBjWb3WwZ"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":47,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" it","obfuscation":"ePyVIYMMouOV5"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":48,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" seems","obfuscation":"EOu41id92x"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":49,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" like","obfuscation":"hqxrIOVrnlY"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":50,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" the","obfuscation":"btEP4IeJO7kX"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":51,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" user","obfuscation":"RARkmiZhjTT"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":52,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" expects","obfuscation":"pHeaplIH"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":53,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" me","obfuscation":"SqZOpzK0GIY2E"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":54,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" to","obfuscation":"0UKdmy38jwVK3"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":55,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" handle","obfuscation":"G8MpOlNif"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":56,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" this","obfuscation":"9srqGNaOK0H"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":57,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":".","obfuscation":"xubdE4c0d9NemnE"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":58,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" I'll","obfuscation":"MC8hqIBJWls"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":59,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" mention","obfuscation":"Y3qcbW4c"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":60,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" both","obfuscation":"ikJ5dogMJoA"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":61,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" capitals","obfuscation":"Ii14l3Y"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":62,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" to","obfuscation":"GODJCjGLLccBf"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":63,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" avoid","obfuscation":"MH2WdplkPA"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":64,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" any","obfuscation":"EsfTfRsRpWZ9"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":65,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" ambiguity","obfuscation":"fWK02e"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":66,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":",","obfuscation":"lENr3Eyrns3g5zx"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":67,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" saying","obfuscation":"LeTIFxshV"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":68,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" something","obfuscation":"iHXmjB"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":69,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" like","obfuscation":"8TIRhBdHpsn"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":70,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":":","obfuscation":"uQvqS5a7Qss2wmI"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":71,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" \"","obfuscation":"wfE5V9lFH5n6vH"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":72,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":"Both","obfuscation":"kHYUjGjl399R"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":73,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Czech","obfuscation":"2FBhEF0hO1"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":74,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":"ia","obfuscation":"8f53nipmcKLtAV"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":75,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" and","obfuscation":"blZZ2EzzHt44"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":76,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Slovakia","obfuscation":"Yyzvznu"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":77,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" border","obfuscation":"yLPEHDvBq"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":78,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Austria","obfuscation":"UlZNt8sB"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":79,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" and","obfuscation":"fVsFRn3nQIvL"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":80,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" Poland","obfuscation":"AlJszrRSQ"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":81,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":".\"","obfuscation":"3uYgIQuPKrkEHw"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":82,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" That","obfuscation":"hAnfASNT60E"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":83,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" way","obfuscation":"LUYS5c98h3o1"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":84,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":",","obfuscation":"giDgoXTd5uflIAg"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":85,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" I","obfuscation":"5ZyinjpP4KM4Rx"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":86,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":"’ll","obfuscation":"BOLKgP1XyWIuc"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":87,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" check","obfuscation":"3wKoltbSrl"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":88,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" the","obfuscation":"THXzm8wAMaJe"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":89,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" time","obfuscation":"fuYaHOTpxWx"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":90,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" and","obfuscation":"wZAygQFXFR5w"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":91,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" weather","obfuscation":"vM9UiqgW"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":92,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" for","obfuscation":"N7TqusoZ7WZl"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":93,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" both","obfuscation":"vN3mT1oqjvh"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":94,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" capitals","obfuscation":"AC2vIcC"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":95,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" in","obfuscation":"7v3AJZN9uvign"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":96,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" one","obfuscation":"gkPTMdWvE5ib"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":97,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":" go","obfuscation":"THR8e1FlF3J9z"} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":98,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"delta":".","obfuscation":"k0cJ9ajWZOt7rI8"} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","sequence_number":99,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"text":"**Evaluating border ambiguities**\n\nI need to clarify that both Austria borders Poland and Czechia, as well as Slovakia. The user likely refers to Prague in Czechia, but Bratislava in Slovakia also fits. I could ask for clarification, but it seems like the user expects me to handle this. I'll mention both capitals to avoid any ambiguity, saying something like: \"Both Czechia and Slovakia border Austria and Poland.\" That way, I’ll check the time and weather for both capitals in one go."} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","sequence_number":100,"item_id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":"**Evaluating border ambiguities**\n\nI need to clarify that both Austria borders Poland and Czechia, as well as Slovakia. The user likely refers to Prague in Czechia, but Bratislava in Slovakia also fits. I could ask for clarification, but it seems like the user expects me to handle this. I'll mention both capitals to avoid any ambiguity, saying something like: \"Both Czechia and Slovakia border Austria and Poland.\" That way, I’ll check the time and weather for both capitals in one go."}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":101,"output_index":0,"item":{"type":"reasoning","id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","summary":[{"type":"summary_text","text":"**Evaluating border ambiguities**\n\nI need to clarify that both Austria borders Poland and Czechia, as well as Slovakia. The user likely refers to Prague in Czechia, but Bratislava in Slovakia also fits. I could ask for clarification, but it seems like the user expects me to handle this. I'll mention both capitals to avoid any ambiguity, saying something like: \"Both Czechia and Slovakia border Austria and Poland.\" That way, I’ll check the time and weather for both capitals in one go."}],"content":[],"encrypted_content":"REDACTED-ENCRYPTED-2"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":102,"output_index":1,"item":{"type":"message","id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","role":"assistant","status":"in_progress","content":[],"phase":"commentary"}} + +event: response.content_part.added +data: {"type":"response.content_part.added","sequence_number":103,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":104,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"Two","logprobs":[],"safety_buffering":false,"obfuscation":"cgbQXO4KzTHN6"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":105,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" countries","logprobs":[],"safety_buffering":false,"obfuscation":"Zop2HN"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":106,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" border","logprobs":[],"safety_buffering":false,"obfuscation":"HoebWhfKJ"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":107,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" both","logprobs":[],"safety_buffering":false,"obfuscation":"zea6RFYoXv3"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":108,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" Austria","logprobs":[],"safety_buffering":false,"obfuscation":"LZnvRfuD"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":109,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" and","logprobs":[],"safety_buffering":false,"obfuscation":"qNifXWhLNxfl"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":110,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" Poland","logprobs":[],"safety_buffering":false,"obfuscation":"Uul0f2oX9"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":111,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":":","logprobs":[],"safety_buffering":false,"obfuscation":"1k8g5sMtNbD3Aoo"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":112,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" **","logprobs":[],"safety_buffering":false,"obfuscation":"whLvzIktNqWGV"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":113,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"C","logprobs":[],"safety_buffering":false,"obfuscation":"BsMaURvcAhwzBb7"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":114,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"zech","logprobs":[],"safety_buffering":false,"obfuscation":"HromeJavAtc0"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":115,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"ia","logprobs":[],"safety_buffering":false,"obfuscation":"OnK5qP2GBN6yZf"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":116,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"**,","logprobs":[],"safety_buffering":false,"obfuscation":"E7ndQUDHfG2vp"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":117,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" whose","logprobs":[],"safety_buffering":false,"obfuscation":"nWNoWdRENS"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":118,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" capital","logprobs":[],"safety_buffering":false,"obfuscation":"PIRDS39L"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":119,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" is","logprobs":[],"safety_buffering":false,"obfuscation":"uUxgEmu9lQi99"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":120,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" **","logprobs":[],"safety_buffering":false,"obfuscation":"4MYC3hN5FDEvW"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":121,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"Pr","logprobs":[],"safety_buffering":false,"obfuscation":"nFOqsrmkcsXf7w"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":122,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"ague","logprobs":[],"safety_buffering":false,"obfuscation":"VzJ8xQbWFNU8"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":123,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"**,","logprobs":[],"safety_buffering":false,"obfuscation":"eGEs3E1q7BLlm"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":124,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" and","logprobs":[],"safety_buffering":false,"obfuscation":"VFD2rvA1N1fh"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":125,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" **","logprobs":[],"safety_buffering":false,"obfuscation":"TJSCxyGXzvKtK"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":126,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"Sl","logprobs":[],"safety_buffering":false,"obfuscation":"OPNRYlLpG1Tlq1"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":127,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"ov","logprobs":[],"safety_buffering":false,"obfuscation":"iZk1i4ALYYs0i5"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":128,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"akia","logprobs":[],"safety_buffering":false,"obfuscation":"ke6ki8tOO0Uw"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":129,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"**,","logprobs":[],"safety_buffering":false,"obfuscation":"hmNc0YZXzkndX"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":130,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" whose","logprobs":[],"safety_buffering":false,"obfuscation":"U1YZc3oBrZ"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":131,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" capital","logprobs":[],"safety_buffering":false,"obfuscation":"ZopN0AVq"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":132,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" is","logprobs":[],"safety_buffering":false,"obfuscation":"duOyhDT9SDaCW"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":133,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" **","logprobs":[],"safety_buffering":false,"obfuscation":"Aogc9GuYWyBbX"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":134,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"Br","logprobs":[],"safety_buffering":false,"obfuscation":"YK8HOIazjfnhfK"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":135,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"atis","logprobs":[],"safety_buffering":false,"obfuscation":"IZFlG2ggnK97"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":136,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"lava","logprobs":[],"safety_buffering":false,"obfuscation":"2D3LZEkgYj9K"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":137,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"**","logprobs":[],"safety_buffering":false,"obfuscation":"5wbccYtounCSMt"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":138,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":".","logprobs":[],"safety_buffering":false,"obfuscation":"VThz0wkbjSLHDdP"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":139,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" Since","logprobs":[],"safety_buffering":false,"obfuscation":"eApCuUHypp"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":140,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" the","logprobs":[],"safety_buffering":false,"obfuscation":"iews1rqLbGmS"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":141,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" clue","logprobs":[],"safety_buffering":false,"obfuscation":"8gOp7LJv9W7"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":142,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" isn","logprobs":[],"safety_buffering":false,"obfuscation":"6ACyNukgBTV1"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":143,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"’t","logprobs":[],"safety_buffering":false,"obfuscation":"uY0WSJ6NLVuP7K"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":144,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" unique","logprobs":[],"safety_buffering":false,"obfuscation":"JOUC7Fg0t"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":145,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":",","logprobs":[],"safety_buffering":false,"obfuscation":"0hiqh3CvmPVQRcK"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":146,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" I","logprobs":[],"safety_buffering":false,"obfuscation":"Pmor2TYg3x8qhh"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":147,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":"’ll","logprobs":[],"safety_buffering":false,"obfuscation":"2T2feMfPh6CTE"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":148,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" look","logprobs":[],"safety_buffering":false,"obfuscation":"OyoByrzUrDE"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":149,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" up","logprobs":[],"safety_buffering":false,"obfuscation":"yOTyaL2VbHplz"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":150,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" the","logprobs":[],"safety_buffering":false,"obfuscation":"qoIFj3kzMiR4"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":151,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" time","logprobs":[],"safety_buffering":false,"obfuscation":"o1L9GJ0D5mo"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":152,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" and","logprobs":[],"safety_buffering":false,"obfuscation":"wza0CvjSpxri"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":153,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" weather","logprobs":[],"safety_buffering":false,"obfuscation":"vs5Ri5JX"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":154,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" for","logprobs":[],"safety_buffering":false,"obfuscation":"Ul8T5hy2JL4L"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":155,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" both","logprobs":[],"safety_buffering":false,"obfuscation":"lAQ7c51lKlM"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":156,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" capitals","logprobs":[],"safety_buffering":false,"obfuscation":"Rhqmcg2"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":157,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" at","logprobs":[],"safety_buffering":false,"obfuscation":"tkjWHRyiT0esS"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":158,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":" once","logprobs":[],"safety_buffering":false,"obfuscation":"s54uYAGWe3U"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":159,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"delta":".","logprobs":[],"safety_buffering":false,"obfuscation":"AtpX81mDsCYAuWk"} + +event: response.output_text.done +data: {"type":"response.output_text.done","sequence_number":160,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"text":"Two countries border both Austria and Poland: **Czechia**, whose capital is **Prague**, and **Slovakia**, whose capital is **Bratislava**. Since the clue isn’t unique, I’ll look up the time and weather for both capitals at once.","logprobs":[]} + +event: response.content_part.done +data: {"type":"response.content_part.done","sequence_number":161,"item_id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","output_index":1,"content_index":0,"part":{"type":"output_text","text":"Two countries border both Austria and Poland: **Czechia**, whose capital is **Prague**, and **Slovakia**, whose capital is **Bratislava**. Since the clue isn’t unique, I’ll look up the time and weather for both capitals at once.","annotations":[],"logprobs":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":162,"output_index":1,"item":{"type":"message","id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Two countries border both Austria and Poland: **Czechia**, whose capital is **Prague**, and **Slovakia**, whose capital is **Bratislava**. Since the clue isn’t unique, I’ll look up the time and weather for both capitals at once.","annotations":[],"logprobs":[]}],"phase":"commentary"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":163,"output_index":2,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd2c408191b4c58abaa55ee7cd","call_id":"call_90b4be1d6cca4063a55a2759839102c6","name":"get_time","arguments":"","status":"in_progress"}} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","sequence_number":164,"item_id":"fc_0b859be91c473db4016ade78cd2c408191b4c58abaa55ee7cd","output_index":2,"arguments":"{\"timezone\": \"Europe/Prague\"}"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":165,"output_index":2,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd2c408191b4c58abaa55ee7cd","call_id":"call_90b4be1d6cca4063a55a2759839102c6","name":"get_time","arguments":"{\"timezone\": \"Europe/Prague\"}","status":"completed"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":166,"output_index":3,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd08191ba6b4d44d1041082","call_id":"call_edd9adef26ff4c7884acfb110d504da2","name":"get_weather","arguments":"","status":"in_progress"}} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","sequence_number":167,"item_id":"fc_0b859be91c473db4016ade78cd3cd08191ba6b4d44d1041082","output_index":3,"arguments":"{\"city\": \"Prague\"}"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":168,"output_index":3,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd08191ba6b4d44d1041082","call_id":"call_edd9adef26ff4c7884acfb110d504da2","name":"get_weather","arguments":"{\"city\": \"Prague\"}","status":"completed"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":169,"output_index":4,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd0819189b550edda00a93c","call_id":"call_75bb5278157b415b811e5dd8ddaa1665","name":"get_time","arguments":"","status":"in_progress"}} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","sequence_number":170,"item_id":"fc_0b859be91c473db4016ade78cd3cd0819189b550edda00a93c","output_index":4,"arguments":"{\"timezone\": \"Europe/Bratislava\"}"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":171,"output_index":4,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd0819189b550edda00a93c","call_id":"call_75bb5278157b415b811e5dd8ddaa1665","name":"get_time","arguments":"{\"timezone\": \"Europe/Bratislava\"}","status":"completed"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":172,"output_index":5,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd08191a065875943af004d","call_id":"call_3c127638184f436eb77ff84cc103be2e","name":"get_weather","arguments":"","status":"in_progress"}} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","sequence_number":173,"item_id":"fc_0b859be91c473db4016ade78cd3cd08191a065875943af004d","output_index":5,"arguments":"{\"city\": \"Bratislava\"}"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":174,"output_index":5,"item":{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd08191a065875943af004d","call_id":"call_3c127638184f436eb77ff84cc103be2e","name":"get_weather","arguments":"{\"city\": \"Bratislava\"}","status":"completed"}} + +event: response.metadata +data: {"type":"response.metadata","sequence_number":175,"response_id":"resp_0b859be91c473db4016ade78c3537881918295a15608676db4","metadata":{"openai_chatgpt_moderation_metadata":{"prompt":{"omnimod":{"protection_type":"omnimod","outputs":[{"results":[{"category_flags":{"A1":false,"A2":false,"A3":false,"C1":false,"C11":false,"C2":false,"E1":false,"E2":false,"E3":false,"H1":false,"H2":false,"H3":false,"JB1":false,"K1":false,"K2":false,"K3":false,"K4":false,"P1":false,"P2":false,"P3":false,"P4":false,"PD1":false,"PD4":false,"S1":false,"S3+":false,"S4":false,"SH":false,"TXT-C3":false,"TXT-C4":false,"V1":false,"V2+":false,"V4+":false,"REFUSED":false,"CBRN-B1+":false,"IMG-C1":false,"IMG-C11":false,"IMG-C2":false,"IMG-S1":false,"IMG-SH":false,"IMG-V1":false,"IMG-V2+":false,"IMG-V4+":false}}],"is_blocked":false}]}},"generation":{},"tool_call":{},"tool_response":{}}}} + +event: response.completed +data: {"type":"response.completed","sequence_number":176,"safety_buffering":false,"response":{"id":"resp_0b859be91c473db4016ade78c3537881918295a15608676db4","object":"response","created_at":1792964803,"completed_at":1792964813,"status":"completed","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-6.1-sol","access_programs":{"cyber":"standard"},"previous_response_id":null,"instructions":"You are a tool-using assistant. When the user asks for two lookups, call both tools in one turn.","output":[{"type":"reasoning","id":"rs_0b859be91c473db4016ade78c53d948191905120917fd9aa2f","summary":[{"type":"summary_text","text":"**Evaluating border ambiguities**\n\nI need to clarify that both Austria borders Poland and Czechia, as well as Slovakia. The user likely refers to Prague in Czechia, but Bratislava in Slovakia also fits. I could ask for clarification, but it seems like the user expects me to handle this. I'll mention both capitals to avoid any ambiguity, saying something like: \"Both Czechia and Slovakia border Austria and Poland.\" That way, I’ll check the time and weather for both capitals in one go."}],"content":[],"encrypted_content":"REDACTED-ENCRYPTED-3"},{"type":"message","id":"msg_0b859be91c473db4016ade78cd1a3c8191b4eb7bd439d6e6c7","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Two countries border both Austria and Poland: **Czechia**, whose capital is **Prague**, and **Slovakia**, whose capital is **Bratislava**. Since the clue isn’t unique, I’ll look up the time and weather for both capitals at once.","annotations":[],"logprobs":[]}],"phase":"commentary"},{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd2c408191b4c58abaa55ee7cd","call_id":"call_90b4be1d6cca4063a55a2759839102c6","name":"get_time","arguments":"{\"timezone\": \"Europe/Prague\"}","status":"completed"},{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd08191ba6b4d44d1041082","call_id":"call_edd9adef26ff4c7884acfb110d504da2","name":"get_weather","arguments":"{\"city\": \"Prague\"}","status":"completed"},{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd0819189b550edda00a93c","call_id":"call_75bb5278157b415b811e5dd8ddaa1665","name":"get_time","arguments":"{\"timezone\": \"Europe/Bratislava\"}","status":"completed"},{"type":"function_call","id":"fc_0b859be91c473db4016ade78cd3cd08191a065875943af004d","call_id":"call_3c127638184f436eb77ff84cc103be2e","name":"get_weather","arguments":"{\"city\": \"Bratislava\"}","status":"completed"}],"error":null,"incomplete_details":null,"tools":[{"type":"function","name":"get_time","description":"Current time in a time zone","parameters":{"type":"object","properties":{"timezone":{"type":"string"}},"required":["timezone"],"additionalProperties":false},"output_schema":null,"strict":true},{"type":"function","name":"get_weather","description":"Current weather in a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"output_schema":null,"strict":true}],"tool_choice":"auto","parallel_tool_calls":true,"prompt_cache_key":"7a04de2f-d096-796d-355c-0c55e687e96f","prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"high","summary":"detailed","context":"all_turns","mode":"standard"},"user":null,"usage":{"input_tokens":129,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":312,"output_tokens_details":{"reasoning_tokens":161},"total_tokens":441},"tool_usage":{"image_gen":{"input_tokens":0,"output_tokens":0,"total_tokens":0,"input_tokens_details":{"text_tokens":0,"image_tokens":0},"output_tokens_details":{"image_tokens":0,"text_tokens":0}},"web_search":{"num_requests":0}},"safety_identifier":"user-qKqgLtFS4OvrpqduuhxW5xKe","store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"default","truncation":"disabled"}} + diff --git a/src/ai-proxy/lib/providers/grok-subscription.test.ts b/src/ai-proxy/lib/providers/grok-subscription.test.ts index 2c10073ab9..f097d18eab 100644 --- a/src/ai-proxy/lib/providers/grok-subscription.test.ts +++ b/src/ai-proxy/lib/providers/grok-subscription.test.ts @@ -336,6 +336,43 @@ describe("GrokSubscriptionProvider.messages /responses route (default)", () => { expect(message.stop_reason).toBe("end_turn"); }); + it("sends the conversation's cache key as prompt_cache_key and as x-grok-conv-id on every turn", async () => { + const provider = makeProvider(); + const client = provider as unknown as { + client: { + fetch: (path: string, init: { body?: unknown; headers?: Record<string, string> }) => Promise<Response>; + }; + }; + const seen: Array<{ key: unknown; header: string | undefined }> = []; + client.client.fetch = async (_path, init) => { + const sentBody = SafeJSON.parse(String(init.body), { strict: true }) as Record<string, unknown>; + seen.push({ key: sentBody.prompt_cache_key, header: init.headers?.["x-grok-conv-id"] }); + return new Response(envelope, { status: 200, headers: { "content-type": "application/json" } }); + }; + const key = { key: "0f1e2d3c-4b5a-4968-8776-5a4b3c2d1e0f", source: "claude-session" as const }; + const send = (messages: unknown[]) => { + const body = SafeJSON.stringify({ model: "grok-4.6", max_tokens: 100, messages }); + return provider.messages( + new Request("http://proxy/v1/messages", { method: "POST", body }), + "grok-4.6", + body, + { promptCacheKey: key } + ); + }; + + await send([{ role: "user", content: "2+2?" }]); + await send([ + { role: "user", content: "2+2?" }, + { role: "assistant", content: "4" }, + { role: "user", content: "and 3+3?" }, + ]); + + expect(seen).toEqual([ + { key: key.key, header: key.key }, + { key: key.key, header: key.key }, + ]); + }); + it("retries once without reasoning items when the upstream cannot decrypt them", async () => { const { provider, sent } = stubbedProvider((calls) => calls.length === 1 diff --git a/src/ai-proxy/lib/providers/grok-subscription.ts b/src/ai-proxy/lib/providers/grok-subscription.ts index 9e01fc69a6..73201a7839 100644 --- a/src/ai-proxy/lib/providers/grok-subscription.ts +++ b/src/ai-proxy/lib/providers/grok-subscription.ts @@ -2,7 +2,12 @@ import { accountConfigFingerprint, resolveGrokAuthPath } from "@app/ai-proxy/lib import { listGrokProxyModels } from "@app/ai-proxy/lib/model-meta"; import { mapGrokError } from "@app/ai-proxy/lib/providers/grok-errors"; import { relayHeaders } from "@app/ai-proxy/lib/providers/http-relay"; -import type { OpenAiModel, ProviderInspectionOptions, ProxyProvider } from "@app/ai-proxy/lib/providers/types"; +import type { + OpenAiModel, + ProviderInspectionOptions, + ProviderRequestContext, + ProxyProvider, +} from "@app/ai-proxy/lib/providers/types"; import { parseRetryAfterSeconds } from "@app/ai-proxy/lib/providers/wham-errors"; import { harvestResponsesOutput, whamItemScope } from "@app/ai-proxy/lib/providers/wham-item-store"; import { prepareGrokUpstreamBody } from "@app/ai-proxy/lib/rewrite-upstream-body"; @@ -42,10 +47,12 @@ import { GrokSubscriptionClient, resolveGrokSubToken, } from "@genesiscz/utils/ai/grok"; +import { grokConversationHeaders } from "@genesiscz/utils/ai/grok/headers"; import { GROK_CLI_CHAT_PROXY_BASE_URL } from "@genesiscz/utils/ai/grok/paths"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; export class GrokSubscriptionProvider implements ProxyProvider { readonly id = "grok-subscription"; @@ -136,8 +143,9 @@ export class GrokSubscriptionProvider implements ProxyProvider { // on the body for shapes Grok's deserializer rejects, and one on the SSE. // `stringifyUnknownToolResultBlocks` is the only lossy one — see // docs/ai-proxy/FlowMatrix.md. - async messages(req: Request, model: string, bodyText: string): Promise<Response> { + async messages(req: Request, model: string, bodyText: string, context?: ProviderRequestContext): Promise<Response> { const parsed = SafeJSON.parse(bodyText, { strict: true }); + const cacheKey = context?.promptCacheKey?.key; let outBody = bodyText; let toolMatchers: ToolMatcher[] = []; @@ -183,7 +191,7 @@ export class GrokSubscriptionProvider implements ProxyProvider { // AI_PROXY_GROK_MESSAGES_ROUTE=shim restores the passthrough // below instantly. if (env.aiProxy.getGrokMessagesRoute() === "responses") { - return this.messagesViaResponses(req, model, parsed as Record<string, unknown>); + return this.messagesViaResponses(req, model, parsed as Record<string, unknown>, cacheKey); } const body = stringifyUnknownToolResultBlocks( @@ -212,7 +220,7 @@ export class GrokSubscriptionProvider implements ProxyProvider { modelOverride: model, requestedModel: model, imageRouted: false, - headers: { "anthropic-version": "2023-06-01" }, + headers: { "anthropic-version": "2023-06-01", ...grokConversationHeaders(cacheKey) }, }); // `dispatch` re-wraps upstream failures in the OpenAI error envelope, @@ -248,11 +256,16 @@ export class GrokSubscriptionProvider implements ProxyProvider { private async messagesViaResponses( req: Request, model: string, - parsed: Record<string, unknown> + parsed: Record<string, unknown>, + cacheKey: string | undefined ): Promise<Response> { const anthropicBody = hoistSystemMessages(ensureToolRequiredArrays(parsed)); const responsesBody = anthropicToGrokResponses(anthropicBody, model); + if (cacheKey) { + responsesBody.prompt_cache_key = cacheKey; + } + const send = (bodyText: string): Promise<Response> => this.dispatch({ path: "/responses", @@ -261,6 +274,7 @@ export class GrokSubscriptionProvider implements ProxyProvider { modelOverride: model, requestedModel: model, imageRouted: false, + headers: grokConversationHeaders(cacheKey), }); let response = await send(SafeJSON.stringify(responsesBody)); @@ -436,6 +450,7 @@ export class GrokSubscriptionProvider implements ProxyProvider { modelOverride: prepared.upstreamModel, requestedModel: upstreamModel, imageRouted: prepared.imageRouted, + headers: grokConversationHeaders(clientCacheKey(bodyText, req)), }); } @@ -588,3 +603,21 @@ function unsupportedNativeResponse(loss: string): Response { { status: 400, headers: { "Content-Type": "application/json" } } ); } + +/** + * A cache key the client chose itself on the OpenAI doors: the body's `prompt_cache_key`, else its own + * `x-grok-conv-id` header. Forwarded unchanged as the conversation header; nothing is invented for these clients. + */ +function clientCacheKey(bodyText: string, req: Request): string | undefined { + try { + const parsed = SafeJSON.parse(bodyText, { strict: true }); + + if (isObject(parsed) && typeof parsed.prompt_cache_key === "string" && parsed.prompt_cache_key.length > 0) { + return parsed.prompt_cache_key; + } + } catch (err) { + logger.debug({ err }, "ai-proxy: grok body is not JSON; no client cache key"); + } + + return req.headers.get("x-grok-conv-id") ?? undefined; +} diff --git a/src/ai-proxy/lib/providers/openai-sub-token.ts b/src/ai-proxy/lib/providers/openai-sub-token.ts index f6fcd0bb61..23d60f8a0e 100644 --- a/src/ai-proxy/lib/providers/openai-sub-token.ts +++ b/src/ai-proxy/lib/providers/openai-sub-token.ts @@ -6,6 +6,7 @@ import { readCodexAuthJson, resolveCodexAccountToken, } from "@genesiscz/utils/ai/openai/codex-auth"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { logger } from "@genesiscz/utils/logger"; export interface OpenAiSubToken { @@ -39,9 +40,29 @@ export async function resolveOpenAiSubToken( ); } + warnLegacyOnce(account.name); return resolveFromCodexCli(account.openaiSub?.codexAuthPath); } +const warnedLegacy = new Set<string>(); + +/** + * The Codex CLI read is legacy: a new codex proxy account names a managed login (`openaiSub.accountName`, set by + * `tools ai-proxy accounts login codex` and `accounts bind`), so every Codex token comes from `CodexAccountBinding`. + * Old configs keep working; the log says once per process how to move them. + */ +function warnLegacyOnce(proxyAccount: string): void { + if (warnedLegacy.has(proxyAccount)) { + return; + } + + warnedLegacy.add(proxyAccount); + logger.warn( + { account: proxyAccount, fix: toolCommand("ai-proxy accounts bind", proxyAccount, "--codex", "<login>") }, + "ai-proxy: codex account reads the Codex CLI's own auth.json (legacy, never refreshed); bind it to a managed login" + ); +} + /** * Resolve a token for one of `openaiSub.failoverAccountNames` — always a named * `openai-sub` account in the unified AI config. diff --git a/src/ai-proxy/lib/providers/openai-subscription.test.ts b/src/ai-proxy/lib/providers/openai-subscription.test.ts index 7ebe871aa0..ec63daefc4 100644 --- a/src/ai-proxy/lib/providers/openai-subscription.test.ts +++ b/src/ai-proxy/lib/providers/openai-subscription.test.ts @@ -1,6 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { resetCooldowns } from "@app/ai-proxy/lib/providers/cooldown"; +import { + CLAUDE_CODE_COMPACTION_MARK, + isClaudeCodeModelProbe, + modelProbeReply, +} from "@app/ai-proxy/lib/translators/formats/anthropic/claude-code-turns"; +import { + accountHash8, + isReasoningReplayRejection, + openReasoningEnvelope, + packReasoningEnvelope, +} from "@app/ai-proxy/lib/translators/formats/anthropic/reasoning-envelope"; import type { AiProxyAccountConfig } from "@app/ai-proxy/lib/types"; +import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; const TEST_TOKEN = "codex-access-token"; @@ -35,6 +50,24 @@ mock.module("@genesiscz/utils/ai/openai/sub-models", () => ({ resolveOpenAiSubModel: (id: string, aliases?: Record<string, string>) => aliases?.[id] ?? id, tryFetchWhamModels: async () => whamModelsStub, fetchWhamModels: async () => whamModelsStub ?? [], + // The catalog reads the Codex CLI cache through this mapper; the fixtures below need only these fields. + whamModelRecordFromApi: (model: { + slug: string; + display_name: string; + context_window: number; + visibility: "list" | "hide"; + supported_reasoning_levels?: Array<{ effort: string }>; + default_reasoning_level?: string; + multi_agent_reasoning_effort?: string | null; + }) => ({ + slug: model.slug, + displayName: model.display_name, + contextWindow: model.context_window, + visibility: model.visibility, + supportedEfforts: model.supported_reasoning_levels?.map((level) => level.effort), + defaultEffort: model.default_reasoning_level, + multiAgentReasoningEffort: model.multi_agent_reasoning_effort ?? undefined, + }), })); const account: AiProxyAccountConfig = { @@ -106,7 +139,7 @@ describe("buildWhamResponsesBody", () => { expect(dropped).toContain("max_tokens"); }); - it("maps chat function tools to flat Responses tools", async () => { + it("maps chat function tools to flat Responses tools, not strict unless the client says so", async () => { const { buildWhamResponsesBody } = await import("./openai-subscription"); const { body } = buildWhamResponsesBody( { @@ -116,6 +149,7 @@ describe("buildWhamResponsesBody", () => { type: "function", function: { name: "search", description: "d", parameters: { type: "object" } }, }, + { type: "function", function: { name: "exact", parameters: { type: "object" }, strict: true } }, ], }, "gpt-5.5", @@ -123,7 +157,8 @@ describe("buildWhamResponsesBody", () => { ); expect(body.tools).toEqual([ - { type: "function", name: "search", description: "d", parameters: { type: "object" } }, + { type: "function", name: "search", description: "d", parameters: { type: "object" }, strict: false }, + { type: "function", name: "exact", description: undefined, parameters: { type: "object" }, strict: true }, ]); }); @@ -143,7 +178,9 @@ describe("buildWhamResponsesBody", () => { expect(dropped).toContain("temperature"); expect(dropped).toContain("top_p"); expect(dropped).toContain("tools[type=web_search]"); - expect(body.tools).toEqual([{ type: "function", name: "f", description: undefined, parameters: {} }]); + expect(body.tools).toEqual([ + { type: "function", name: "f", description: undefined, parameters: {}, strict: false }, + ]); }); it("passes client reasoning through and clamps unknown efforts", async () => { @@ -224,9 +261,157 @@ describe("buildWhamResponsesBody", () => { ); expect(bogus.body.reasoning).toEqual({ effort: "high" }); }); + + it("checks the effort against the model's catalog record instead of the fixed allowlist", async () => { + const { buildWhamResponsesBody } = await import("./openai-subscription"); + const { mergeCodexModelRecords } = await import("@genesiscz/utils/ai/openai/codex-models"); + const catalog = mergeCodexModelRecords({ + live: [ + { + slug: "gpt-x-sol", + displayName: "X", + contextWindow: 500_000, + visibility: "list", + supportedEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + defaultEffort: "low", + multiAgentReasoningEffort: "xhigh", + }, + ], + fallback: [], + }).get("gpt-x-sol"); + const build = (extra: Record<string, unknown>, defaultReasoningEffort?: "none" | "high") => + buildWhamResponsesBody({ messages: [{ role: "user", content: "x" }], ...extra }, "gpt-x-sol", { + itemScope: ITEM_SCOPE, + catalog, + defaultReasoningEffort, + }); + + // `max` is a real backend effort for these models: it is sent as `max`, not clamped to xhigh. + expect(build({ reasoning_effort: "max" }).body.reasoning).toEqual({ effort: "max" }); + expect(build({ reasoning: { effort: "max", summary: "auto" } }).body.reasoning).toEqual({ + effort: "max", + summary: "auto", + }); + // `ultra` is never sent (the backend answers 400): it resolves to the record's multi-agent effort. + expect(build({ reasoning_effort: "ultra" }).body.reasoning).toEqual({ effort: "xhigh" }); + // No effort: the account default, else the model's own default. + expect(build({}, "high").body.reasoning).toEqual({ effort: "high" }); + expect(build({}).body.reasoning).toEqual({ effort: "low" }); + expect(build({}, "none").body.reasoning).toBeUndefined(); + + const refused = build({ reasoning_effort: "minimal" }); + expect(refused.effortError).toBe( + 'gpt-x-sol has no reasoning effort "minimal". Allowed: low, medium, high, xhigh, max, ultra (runs as xhigh).' + ); + expect(refused.body.reasoning).toBeUndefined(); + }); }); describe("OpenAiSubscriptionProvider", () => { + it("reads efforts from the Codex CLI model cache: max reaches WHAM, a missing effort is a 400 with no upstream call", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-codex-cache-")); + const cachePath = join(dir, "models_cache.json"); + writeFileSync( + cachePath, + SafeJSON.stringify({ + models: [ + { + slug: "gpt-x-luna", + display_name: "X Luna", + context_window: 272_000, + visibility: "list", + supported_reasoning_levels: ["low", "medium", "high", "xhigh", "max"].map((effort) => ({ + effort, + })), + default_reasoning_level: "medium", + }, + ], + }) + ); + const sentBodies: Array<{ reasoning?: unknown }> = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + sentBodies.push(SafeJSON.parse(String(init?.body)) as { reasoning?: unknown }); + return whamStreamResponse(); + }) as typeof fetch; + + await env.testing.withOverrides({ GENESIS_TOOLS_CODEX_MODELS_CACHE: cachePath }, async () => { + const { OpenAiSubscriptionProvider } = await import("./openai-subscription"); + const provider = await OpenAiSubscriptionProvider.create(account); + const send = (effort: string) => { + const bodyText = SafeJSON.stringify({ input: "hi", reasoning: { effort }, stream: true }); + return provider.responses( + new Request("http://localhost/v1/responses", { method: "POST", body: bodyText }), + "gpt-x-luna", + bodyText + ); + }; + + expect((await send("max")).status).toBe(200); + expect(sentBodies[0]?.reasoning).toEqual({ effort: "max" }); + + const refused = await send("ultra"); + expect(refused.status).toBe(400); + expect(await refused.json()).toEqual({ + error: { + message: 'gpt-x-luna has no reasoning effort "ultra". Allowed: low, medium, high, xhigh, max.', + type: "invalid_request_error", + param: "reasoning.effort", + code: "unsupported_effort", + }, + }); + expect(sentBodies).toHaveLength(1); + }); + }); + + it("keeps one cache key per conversation: body prompt_cache_key and all three session headers, every turn", async () => { + const sent: Array<{ key: unknown; headers: Headers }> = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = SafeJSON.parse(String(init?.body)) as { prompt_cache_key?: unknown }; + sent.push({ key: body.prompt_cache_key, headers: new Headers(init?.headers) }); + return whamStreamResponse(); + }) as typeof fetch; + + const { OpenAiSubscriptionProvider } = await import("./openai-subscription"); + const provider = await OpenAiSubscriptionProvider.create(account); + const turn = async (messages: unknown[], extra: Record<string, unknown> = {}) => { + const bodyText = SafeJSON.stringify({ + messages: [{ role: "system", content: "Be terse." }, ...messages], + ...extra, + }); + const res = await provider.chatCompletions( + new Request("http://localhost/v1/chat/completions", { method: "POST", body: bodyText }), + "gpt-5.5", + bodyText + ); + await res.text(); + }; + const first = { role: "user", content: "say ok" }; + + // A client that names no key: one key from model, instructions and first message, stable as turns grow. + await turn([first]); + await turn([first, { role: "assistant", content: "ok" }, { role: "user", content: "again" }]); + // Tools are not hashed (model, instructions and the first message only), so their order does not move the key. + const tool = (name: string) => ({ type: "function", function: { name, parameters: { type: "object" } } }); + await turn([first], { tools: [tool("a"), tool("b")] }); + await turn([first], { tools: [tool("b"), tool("a")] }); + // A client key (Claude Code's session id, stamped by the /v1/messages translation) wins as is. + const session = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"; + await turn([first], { prompt_cache_key: session }); + + const [a, b, withTools, reordered, c] = sent; + expect(typeof a?.key).toBe("string"); + expect(b?.key).toBe(a?.key); + expect(withTools?.key).toBe(a?.key); + expect(reordered?.key).toBe(a?.key); + expect(c?.key).toBe(session); + + for (const call of sent) { + for (const header of ["session_id", "session-id", "thread-id"]) { + expect(call.headers.get(header)).toBe(String(call.key)); + } + } + }); + it("forwards a chat request to WHAM and maps the response to chat.completion", async () => { let capturedUrl: unknown; let capturedInit: RequestInit | undefined; @@ -722,3 +907,327 @@ describe("OpenAiSubscriptionProvider", () => { expect(upstreamCalled).toBe(true); }); }); + +describe("native /v1/messages route: reasoning carried in gtr1 envelopes", () => { + const fixture = readFileSync(join(import.meta.dir, "fixtures", "wham-reasoning-parallel-tools.sse"), "utf8"); + const sseResponse = (text: string) => + new Response(text, { status: 200, headers: { "content-type": "text/event-stream" } }); + + interface Frame { + event: string; + data: Record<string, unknown>; + } + + function frames(text: string): Frame[] { + return text + .split("\n\n") + .filter((chunk) => chunk.includes("data:")) + .map((chunk) => { + const event = chunk.match(/^event: (.+)$/m)?.[1] ?? ""; + const data = SafeJSON.parse(chunk.slice(chunk.indexOf("data:") + 5).trim()) as Record<string, unknown>; + return { event, data }; + }); + } + + async function send( + body: Record<string, unknown>, + context?: { promptCacheKey?: { key: string; source: "claude-session" } } + ): Promise<Response> { + const { OpenAiSubscriptionProvider } = await import("./openai-subscription"); + const provider = await OpenAiSubscriptionProvider.create(account); + const bodyText = SafeJSON.stringify(body); + const messages = provider.messages; + + if (!messages) { + throw new Error("the native route is off"); + } + + return messages( + new Request("http://localhost/v1/messages", { method: "POST", body: bodyText }), + "gpt-6.1-sol", + bodyText, + context + ); + } + + function turnOneThinking(text: string): { signature: string; thinking: string } { + let thinking = ""; + let signature = ""; + + for (const frame of frames(text)) { + const delta = frame.data.delta as Record<string, unknown> | undefined; + + if (delta?.type === "thinking_delta") { + thinking += String(delta.thinking); + } + + if (delta?.type === "signature_delta") { + signature = String(delta.signature); + } + } + + return { signature, thinking }; + } + + it("a recorded WHAM stream: thinking first with one gtr1 signature, text, four tool calls with .done-only arguments, never thinking last", async () => { + globalThis.fetch = (async () => sseResponse(fixture)) as unknown as typeof fetch; + const response = await send({ + model: "gpt-6.1-sol", + stream: true, + messages: [{ role: "user", content: "which capital?" }], + }); + const text = await response.text(); + const all = frames(text); + const starts = all + .filter((frame) => frame.event === "content_block_start") + .map((frame) => (frame.data.content_block as Record<string, unknown>).type); + const signatures = all.filter( + (frame) => (frame.data.delta as Record<string, unknown> | undefined)?.type === "signature_delta" + ); + const toolInputs = new Map<number, string>(); + + for (const frame of all) { + const delta = frame.data.delta as Record<string, unknown> | undefined; + + if (delta?.type === "input_json_delta") { + const index = Number(frame.data.index); + toolInputs.set(index, (toolInputs.get(index) ?? "") + String(delta.partial_json)); + } + } + + expect(response.status).toBe(200); + expect(starts).toEqual(["thinking", "text", "tool_use", "tool_use", "tool_use", "tool_use"]); + expect(signatures).toHaveLength(1); + + const { signature, thinking } = turnOneThinking(text); + expect(thinking.length).toBeGreaterThan(0); + expect(signature.startsWith(`gtr1.codex.${accountHash8("codex")}.gpt-6.1-sol.`)).toBe(true); + expect( + openReasoningEnvelope(signature, { + provider: "codex", + accountHash: accountHash8("codex"), + compHash: signature.split(".").at(-2) ?? "", + }) + ).toMatchObject({ + ok: true, + item: { encrypted_content: "REDACTED-ENCRYPTED-2" }, + }); + expect([...toolInputs.values()].map((input) => SafeJSON.parse(input))).toHaveLength(4); + expect(all.find((frame) => frame.event === "message_delta")?.data.usage).toMatchObject({ + input_tokens: 129, + output_tokens: 312, + }); + }); + + it("the next turn replays the item whole, before its tool call, with the conversation's cache key; foreign signatures go silently", async () => { + globalThis.fetch = (async () => sseResponse(fixture)) as unknown as typeof fetch; + const { signature, thinking } = turnOneThinking( + await ( + await send({ model: "gpt-6.1-sol", stream: true, messages: [{ role: "user", content: "q" }] }) + ).text() + ); + const sent: Array<Record<string, unknown>> = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + sent.push(SafeJSON.parse(String(init?.body)) as Record<string, unknown>); + return whamStreamResponse(); + }) as typeof fetch; + const session = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"; + + const response = await send( + { + model: "gpt-6.1-sol", + stream: true, + messages: [ + { role: "user", content: "q" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "old grok thought", signature: "grokrs1:rs_x:blob" }, + { type: "thinking", thinking, signature }, + { type: "tool_use", id: "call_1", name: "get_time", input: { timezone: "Europe/Prague" } }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "12:00" }] }, + ], + }, + { promptCacheKey: { key: session, source: "claude-session" } } + ); + await response.text(); + + const input = sent[0]?.input as Array<Record<string, unknown>>; + const types = input.map((item) => item.type ?? item.role); + expect(response.status).toBe(200); + expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call")); + expect(input.filter((item) => item.type === "reasoning")).toEqual([ + expect.objectContaining({ type: "reasoning", encrypted_content: "REDACTED-ENCRYPTED-2" }), + ]); + expect(((input.find((item) => item.type === "reasoning")?.summary ?? []) as unknown[]).length).toBeGreaterThan( + 0 + ); + expect(sent[0]?.prompt_cache_key).toBe(session); + expect(sent[0]?.reasoning).toMatchObject({ summary: "auto" }); + }); + + it("a 400 about the replayed blob retries once without reasoning, and that blob is never replayed again", async () => { + const envelope = packReasoningEnvelope( + { provider: "codex", accountHash: accountHash8("codex"), model: "gpt-6.1-sol", compHash: "0" }, + { id: "rs_dead", encrypted_content: "stale-blob", summary: [] } + ); + const body = { + model: "gpt-6.1-sol", + stream: true, + messages: [ + { role: "user", content: "q" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "", signature: envelope }, + { type: "text", text: "a" }, + ], + }, + { role: "user", content: "again" }, + ], + }; + const sent: Array<Record<string, unknown>> = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + sent.push(SafeJSON.parse(String(init?.body)) as Record<string, unknown>); + return sent.length === 1 + ? new Response( + SafeJSON.stringify({ + error: { code: "invalid_encrypted_content", message: "could not be verified" }, + }), + { status: 400, headers: { "content-type": "application/json" } } + ) + : whamStreamResponse(); + }) as typeof fetch; + const { OpenAiSubscriptionProvider } = await import("./openai-subscription"); + const provider = await OpenAiSubscriptionProvider.create(account); + const call = async () => { + const bodyText = SafeJSON.stringify(body); + const res = await provider.messages?.( + new Request("http://localhost/v1/messages", { method: "POST", body: bodyText }), + "gpt-6.1-sol", + bodyText + ); + await res?.text(); + return res; + }; + + expect((await call())?.status).toBe(200); + expect(sent).toHaveLength(2); + const reasoningIn = (index: number) => + ((sent[index]?.input ?? []) as Array<Record<string, unknown>>).filter((item) => item.type === "reasoning") + .length; + expect(reasoningIn(0)).toBe(1); + expect(reasoningIn(1)).toBe(0); + + expect((await call())?.status).toBe(200); + expect(sent).toHaveLength(3); + expect(reasoningIn(2)).toBe(0); + }); + + it("Claude Code's compaction turn goes out with tool_choice none", async () => { + const sent: Array<Record<string, unknown>> = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + sent.push(SafeJSON.parse(String(init?.body)) as Record<string, unknown>); + return whamStreamResponse(); + }) as typeof fetch; + await ( + await send({ + model: "gpt-6.1-sol", + stream: true, + tools: [{ name: "Read", description: "read", input_schema: { type: "object", properties: {} } }], + messages: [{ role: "user", content: `${CLAUDE_CODE_COMPACTION_MARK}\n\nSummarize the conversation.` }], + }) + ).text(); + + expect(sent[0]?.tool_choice).toBe("none"); + // WHAM treats a tool without `strict` as strict, and the model then fills every optional field. + expect(sent[0]?.tools).toEqual([expect.objectContaining({ type: "function", name: "Read", strict: false })]); + }); + + it("Claude Code's /effort applies without an :effort suffix, and the suffix wins over it", async () => { + const sent: Array<Record<string, unknown>> = []; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + sent.push(SafeJSON.parse(String(init?.body)) as Record<string, unknown>); + return whamStreamResponse(); + }) as typeof fetch; + const turn = { model: "gpt-6.1-sol", stream: true, messages: [{ role: "user", content: "x" }] }; + + await (await send({ ...turn, output_config: { effort: "xhigh" } })).text(); + // The server stamps a `:medium` suffix as `reasoning_effort` before the provider sees the body. + await (await send({ ...turn, output_config: { effort: "xhigh" }, reasoning_effort: "medium" })).text(); + await (await send(turn)).text(); + + const efforts = sent.map((body) => (body.reasoning as Record<string, unknown> | undefined)?.effort); + expect(efforts.slice(0, 2)).toEqual(["xhigh", "medium"]); + expect(efforts[2]).not.toBe("xhigh"); + }); +}); + +describe("Claude Code's model_validation probe", () => { + const probe = { + model: "codex/gpt-6.1-sol", + max_tokens: 1, + system: [{ type: "text", text: "x-anthropic-billing-header: cc_version=2.1.296" }], + messages: [{ role: "user", content: [{ type: "text", text: "Hi", cache_control: { type: "ephemeral" } }] }], + }; + + it("is the one-token Hi request and nothing else", () => { + expect(isClaudeCodeModelProbe(probe)).toBe(true); + expect(isClaudeCodeModelProbe({ ...probe, max_tokens: 2 })).toBe(false); + expect(isClaudeCodeModelProbe({ ...probe, tools: [] })).toBe(false); + expect(isClaudeCodeModelProbe({ ...probe, messages: [{ role: "user", content: "Hi there" }] })).toBe(false); + expect( + isClaudeCodeModelProbe({ ...probe, messages: [...probe.messages, { role: "assistant", content: "Hi" }] }) + ).toBe(false); + }); + + it("is answered with a one-token message under the requested model", () => { + expect(modelProbeReply(probe.model)).toMatchObject({ + type: "message", + role: "assistant", + model: "codex/gpt-6.1-sol", + content: [{ type: "text", text: "Hi" }], + stop_reason: "max_tokens", + usage: { input_tokens: 0, output_tokens: 1 }, + }); + }); +}); + +describe("reasoning envelope", () => { + const stamp = { provider: "codex", accountHash: accountHash8("work"), model: "gpt-6.1-sol", compHash: "3000" }; + const item = { id: "rs_1", encrypted_content: "blob", summary: [{ type: "summary_text", text: "t" }] }; + + it("opens what it packed, the dotted model id included, and refuses another provider, account or comp_hash", () => { + const signature = packReasoningEnvelope(stamp, item); + const expect_ = { provider: "codex", accountHash: stamp.accountHash, compHash: "3000" }; + + expect(openReasoningEnvelope(signature, expect_)).toEqual({ ok: true, item, stamp }); + expect(openReasoningEnvelope(signature, { ...expect_, provider: "grok" })).toEqual({ + ok: false, + reason: "provider", + }); + expect(openReasoningEnvelope(signature, { ...expect_, accountHash: accountHash8("personal") })).toEqual({ + ok: false, + reason: "account", + }); + expect(openReasoningEnvelope(signature, { ...expect_, compHash: "2000" })).toEqual({ + ok: false, + reason: "comp-hash", + }); + expect(openReasoningEnvelope(`${signature.slice(0, -4)}!!!!`, expect_)).toEqual({ + ok: false, + reason: "malformed", + }); + expect(openReasoningEnvelope("grokrs1:rs:blob", expect_)).toEqual({ ok: false, reason: "not-envelope" }); + expect(openReasoningEnvelope(undefined, expect_)).toEqual({ ok: false, reason: "not-envelope" }); + }); + + it("knows the 400s that reject replayed reasoning, and nothing else", () => { + expect(isReasoningReplayRejection(400, '{"error":{"code":"invalid_encrypted_content"}}')).toBe(true); + expect(isReasoningReplayRejection(400, "Encrypted content was not issued to this caller")).toBe(true); + expect(isReasoningReplayRejection(400, "Unsupported parameter: temperature")).toBe(false); + expect(isReasoningReplayRejection(500, "could not decrypt")).toBe(false); + }); +}); diff --git a/src/ai-proxy/lib/providers/openai-subscription.ts b/src/ai-proxy/lib/providers/openai-subscription.ts index 4d3f6e95da..c8e4e6af71 100644 --- a/src/ai-proxy/lib/providers/openai-subscription.ts +++ b/src/ai-proxy/lib/providers/openai-subscription.ts @@ -1,11 +1,18 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { accountConfigFingerprint } from "@app/ai-proxy/lib/account-config"; import { convertMessagesToInput } from "@app/ai-proxy/lib/chat-to-responses-body"; +import { codexModelInfo } from "@app/ai-proxy/lib/codex-catalog"; import { captureUpstreamFailure } from "@app/ai-proxy/lib/debug-capture"; import { clientAbortResponse } from "@app/ai-proxy/lib/providers/client-abort"; import { cooldownRemainingMs, markRateLimited, markSuccess, markUnhealthy } from "@app/ai-proxy/lib/providers/cooldown"; +import { relayHeaders } from "@app/ai-proxy/lib/providers/http-relay"; import { resolveOpenAiSubFailoverToken, resolveOpenAiSubToken } from "@app/ai-proxy/lib/providers/openai-sub-token"; -import type { OpenAiModel, ProviderInspectionOptions, ProxyProvider } from "@app/ai-proxy/lib/providers/types"; +import type { + OpenAiModel, + ProviderInspectionOptions, + ProviderRequestContext, + ProxyProvider, +} from "@app/ai-proxy/lib/providers/types"; import { mapWhamError, parseRetryAfterSeconds } from "@app/ai-proxy/lib/providers/wham-errors"; import { createWhamItemHarvestTransform, @@ -13,19 +20,45 @@ import { resolveWhamItemReferences, whamItemScope, } from "@app/ai-proxy/lib/providers/wham-item-store"; +import { + anthropicToResponses, + stripReasoningInput, +} from "@app/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses"; +import { + CLAUDE_CODE_AGENT_TOOL, + isClaudeCodeCompactionTurn, + sessionAgentModelWins, +} from "@app/ai-proxy/lib/translators/formats/anthropic/claude-code-turns"; +import { ensureToolRequiredArrays } from "@app/ai-proxy/lib/translators/formats/anthropic/ensure-tool-required"; +import { toAnthropicErrorResponse } from "@app/ai-proxy/lib/translators/formats/anthropic/error-envelope"; +import { + responsesSseToAnthropic, + responsesToAnthropicMessage, +} from "@app/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic"; +import { + accountHash8, + type EnvelopeRejection, + isReasoningReplayRejection, + openReasoningEnvelope, + packReasoningEnvelope, + type ReasoningEnvelopeStamp, +} from "@app/ai-proxy/lib/translators/formats/anthropic/reasoning-envelope"; import { responsesToChat } from "@app/ai-proxy/lib/translators/responses-to-chat"; import type { AiProxyAccountConfig, ThinkingPresentationMode, UsageSummary } from "@app/ai-proxy/lib/types"; import { getTodayUsageSummary, getUsageSummarySince } from "@app/ai-proxy/lib/usage/store"; import { extractPlanType, WHAM_BASE_URL } from "@genesiscz/utils/ai/openai/codex-auth"; +import { type CodexModelInfo, resolveCodexEffort } from "@genesiscz/utils/ai/openai/codex-models"; import { fetchWhamModels, resolveOpenAiSubModel, tryFetchWhamModels, type WhamModelRecord, } from "@genesiscz/utils/ai/openai/sub-models"; +import { type PromptCacheKey, resolvePromptCacheKey } from "@genesiscz/utils/ai/prompt-cache-key"; // The one predicate in the repo for "did this failure happen below HTTP?". // Duplicating it here is how the poll gate and the proxy would drift apart. import { isTransportFailure } from "@genesiscz/utils/ai/usage-poll/poll-gate"; +import { env } from "@genesiscz/utils/env"; import { formatTokens } from "@genesiscz/utils/format"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -46,13 +79,25 @@ const MODALITY_CACHE_TTL_MS = 10 * 60_000; export class OpenAiSubscriptionProvider implements ProxyProvider { readonly id = "openai-subscription"; readonly accountFingerprint: string; + readonly acceptsPromptCacheKey = true; + /** The `:effort` suffix rides in the Anthropic body as `reasoning_effort`, which the Responses body reads. */ + readonly messagesAcceptsReasoningEffort = true; + /** Native `/v1/messages`: set unless `AI_PROXY_CODEX_MESSAGES_ROUTE=chat` restores the chat hop. */ + readonly messages?: ProxyProvider["messages"]; private readonly account: AiProxyAccountConfig; - private modalityRecords: WhamModelRecord[] | null = null; - private modalityFetchedAt = 0; + /** The live WHAM model list, when the image guard or `listModels` already fetched it (10 min). */ + private liveRecords: WhamModelRecord[] | null = null; + private liveFetchedAt = 0; + /** Reasoning item ids the upstream refused to decrypt: never replayed again by this process. */ + private readonly deadReasoning = new Set<string>(); constructor(account: AiProxyAccountConfig) { this.account = account; this.accountFingerprint = accountConfigFingerprint(account); + + if (env.aiProxy.getCodexMessagesRoute() === "responses") { + this.messages = (req, model, bodyText, context) => this.nativeMessages(req, model, bodyText, context); + } } static async create(account: AiProxyAccountConfig): Promise<OpenAiSubscriptionProvider> { @@ -62,6 +107,8 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { async listModels(options?: ProviderInspectionOptions): Promise<OpenAiModel[]> { const { token, accountId } = await resolveOpenAiSubToken(this.account, { noRefresh: options?.probe }); const records = await fetchWhamModels(token, accountId); + this.liveRecords = records; + this.liveFetchedAt = Date.now(); return records .filter((record) => record.visibility === "list") @@ -129,29 +176,37 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { * own error surfaces. */ private async lookupInputModalities(concreteModel: string): Promise<string[] | undefined> { - if (!this.modalityRecords || Date.now() - this.modalityFetchedAt > MODALITY_CACHE_TTL_MS) { + if (!this.liveRecords || Date.now() - this.liveFetchedAt > MODALITY_CACHE_TTL_MS) { try { const { token, accountId } = await resolveOpenAiSubToken(this.account); - this.modalityRecords = await tryFetchWhamModels(token, accountId); - this.modalityFetchedAt = Date.now(); + this.liveRecords = await tryFetchWhamModels(token, accountId); + this.liveFetchedAt = Date.now(); } catch (err) { logger.debug({ err, account: this.account.name }, "ai-proxy: modality lookup failed — skipping guard"); return undefined; } } - return this.modalityRecords?.find((record) => record.slug === concreteModel)?.inputModalities; + return this.liveRecords?.find((record) => record.slug === concreteModel)?.inputModalities; } private async fetchWham({ token, accountId, whamBodyText, + sessionId, signal, }: { token: string; accountId?: string; whamBodyText: string; + /** + * The conversation's cache key. The backend routes the prompt cache by the session HEADER (probed + * 2026-10-25: a fixed header hit 11 of 12 warm requests, the body key alone 0 of 12), so a random id per + * request threw the cache away. Both spellings go out: `session_id` (older clients) and codex-rs's + * `session-id` + `thread-id`. + */ + sessionId: string; signal: AbortSignal; }): Promise<Response> { return fetch(WHAM_RESPONSES_URL, { @@ -162,7 +217,9 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { "Content-Type": "application/json", "OpenAI-Beta": "responses=experimental", originator: "codex_cli_rs", - session_id: crypto.randomUUID(), + session_id: sessionId, + "session-id": sessionId, + "thread-id": sessionId, Accept: "text/event-stream", }, body: whamBodyText, @@ -171,8 +228,6 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { } async responses(req: Request, model: string, bodyText: string): Promise<Response> { - const concreteModel = resolveOpenAiSubModel(model, this.account.openaiSub?.aliases); - let parsed: Record<string, unknown>; try { const parsedBody = SafeJSON.parse(bodyText, { strict: true }); @@ -186,11 +241,187 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { return jsonError(400, "Invalid JSON body"); } + return this.respond(req, model, parsed); + } + + /** The account identity a reasoning envelope is stamped with: the login, else the proxy account. */ + private envelopeAccountHash(): string { + return accountHash8(this.account.openaiSub?.accountName ?? this.account.name); + } + + /** + * Anthropic `/v1/messages` straight to the WHAM Responses wire. The chat hop had no slot for reasoning items; + * here each one rides in a thinking block's signature (a `gtr1.` envelope) and comes back whole next turn. + */ + private async nativeMessages( + req: Request, + model: string, + bodyText: string, + context?: ProviderRequestContext + ): Promise<Response> { + let parsed: Record<string, unknown>; + try { + const parsedBody = SafeJSON.parse(bodyText, { strict: true }); + + if (!isObject(parsedBody)) { + return toAnthropicErrorResponse(jsonError(400, "Invalid JSON body")); + } + + parsed = parsedBody; + } catch (err) { + logger.debug({ err }, "ai-proxy: openai-subscription got an invalid /v1/messages body"); + return toAnthropicErrorResponse(jsonError(400, "Invalid JSON body")); + } + + const concreteModel = resolveOpenAiSubModel(model, this.account.openaiSub?.aliases); + const fresh = Date.now() - this.liveFetchedAt <= MODALITY_CACHE_TTL_MS ? this.liveRecords : null; + const catalog = codexModelInfo(concreteModel, fresh); + const stamp: ReasoningEnvelopeStamp = { + provider: "codex", + accountHash: this.envelopeAccountHash(), + model: concreteModel, + compHash: catalog?.compHash ?? "0", + }; + const replay = { kept: [] as string[], dropped: {} as Partial<Record<EnvelopeRejection | "dead", number>> }; + // System messages stay in place (`appendDeveloperMessage`): hoisted, they broke the prompt cache. + const responsesBody = anthropicToResponses(ensureToolRequiredArrays(parsed), concreteModel, { + reasoningItem: (thinking) => { + const opened = openReasoningEnvelope(thinking.signature, stamp); + + if (!opened.ok || this.deadReasoning.has(opened.item.id)) { + const reason = opened.ok ? "dead" : opened.reason; + replay.dropped[reason] = (replay.dropped[reason] ?? 0) + 1; + return undefined; + } + + replay.kept.push(contentHash(opened.item.encrypted_content)); + return { type: "reasoning", ...opened.item }; + }, + }); + + if (Array.isArray(responsesBody.tools)) { + responsesBody.tools = responsesBody.tools.map((tool) => (isObject(tool) ? nonStrictByDefault(tool) : tool)); + } + + // Claude Code forbids tools in its compaction prompt in words only; a Codex model may still call one, which + // Claude Code refuses, and three refused compactions end the session. + if (isClaudeCodeCompactionTurn(parsed)) { + responsesBody.tool_choice = "none"; + } + + if (context?.promptCacheKey) { + responsesBody.prompt_cache_key = context.promptCacheKey.key; + } + + // The effort rides inside `reasoning`, which every body builder branch reads (a top-level `reasoning_effort` + // is read only when no `reasoning` object exists). An `:effort` suffix, stamped as `reasoning_effort`, wins + // over Claude Code's `/effort` (`output_config.effort`); the catalog check refuses an effort the model lacks. + const clientEffort = + isObject(parsed.output_config) && typeof parsed.output_config.effort === "string" + ? parsed.output_config.effort.trim() + : ""; + const effort = + typeof responsesBody.reasoning_effort === "string" ? responsesBody.reasoning_effort : clientEffort; + delete responsesBody.reasoning_effort; + responsesBody.reasoning = { + ...reasoningOptions(catalog, this.account.openaiSub?.reasoningSummary), + ...(effort ? { effort } : {}), + }; + + logger.debug( + { + model: concreteModel, + replayed: replay.kept.length, + replayedHashes: replay.kept, + dropped: Object.keys(replay.dropped).length > 0 ? replay.dropped : undefined, + compaction: responsesBody.tool_choice === "none" ? true : undefined, + }, + "ai-proxy: codex /v1/messages reasoning replay" + ); + + let response = await this.respond(req, model, responsesBody); + + if (!response.ok) { + const errorText = await response.text(); + const replayed = Array.isArray(responsesBody.input) + ? responsesBody.input.filter((item) => isObject(item) && item.type === "reasoning") + : []; + + if (replayed.length === 0 || !isReasoningReplayRejection(response.status, errorText)) { + return toAnthropicErrorResponse( + new Response(errorText, { status: response.status, headers: response.headers }) + ); + } + + for (const item of replayed) { + if (isObject(item) && typeof item.id === "string") { + this.deadReasoning.add(item.id); + } + } + + logger.warn( + { model: concreteModel, items: replayed.length, status: response.status }, + "ai-proxy: WHAM refused replayed reasoning; retrying once without it, those items stay dropped" + ); + response = await this.respond(req, model, stripReasoningInput(responsesBody)); + + if (!response.ok) { + return toAnthropicErrorResponse(response); + } + } + + const signReasoning = (item: Record<string, unknown>): string => { + if (typeof item.encrypted_content !== "string" || item.encrypted_content.length === 0) { + return ""; + } + + return packReasoningEnvelope(stamp, { + id: typeof item.id === "string" ? item.id : `rs_${randomUUID()}`, + encrypted_content: item.encrypted_content, + summary: Array.isArray(item.summary) ? item.summary : [], + }); + }; + + const sessionAgents = new Set(context?.sessionAgents ?? []); + const keepAgentModel = sessionAgentModelWins(sessionAgents); + const toolInput = + sessionAgents.size > 0 + ? (name: string) => (name === CLAUDE_CODE_AGENT_TOOL ? keepAgentModel : undefined) + : undefined; + + if (parsed.stream === true && response.body) { + return new Response( + responsesSseToAnthropic(response.body, { model, signReasoning, label: "codex", toolInput }), + { + status: response.status, + headers: relayHeaders(response), + } + ); + } + + const envelope = SafeJSON.parse(await response.text(), { strict: true }); + return new Response( + SafeJSON.stringify( + responsesToAnthropicMessage(isObject(envelope) ? envelope : {}, { + model, + signReasoning, + label: "codex", + toolInput, + }) + ), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + /** One Responses-shaped request to WHAM, with the account pool, the 401 refresh, 429 cooling and the error map. */ + private async respond(req: Request, model: string, parsed: Record<string, unknown>): Promise<Response> { + const concreteModel = resolveOpenAiSubModel(model, this.account.openaiSub?.aliases); + if (bodyContainsImageInput(parsed)) { const modalities = await this.lookupInputModalities(concreteModel); if (modalities && !modalities.includes("image")) { - const visionModels = (this.modalityRecords ?? []) + const visionModels = (this.liveRecords ?? []) .filter((record) => record.visibility === "list" && record.inputModalities?.includes("image")) .map((record) => record.slug); const hint = visionModels.length > 0 ? ` Vision-capable: ${visionModels.join(", ")}.` : ""; @@ -211,10 +442,37 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { const wantStream = parsed.stream === true; // Stored items are private to the presenting client; see wham-item-store.ts. const itemScope = whamItemScope(req); - const { body: whamBody, dropped } = buildWhamResponsesBody(parsed, concreteModel, { + const fresh = Date.now() - this.liveFetchedAt <= MODALITY_CACHE_TTL_MS ? this.liveRecords : null; + const catalog = codexModelInfo(concreteModel, fresh); + const { + body: whamBody, + dropped, + effortError, + cacheKey, + } = buildWhamResponsesBody(parsed, concreteModel, { defaultReasoningEffort: this.account.openaiSub?.defaultReasoningEffort, itemScope, + catalog, + clientSessionHeader: req.headers.get("session-id") ?? req.headers.get("session_id") ?? undefined, }); + // A body with nothing to key on (no client key, no instructions, no input) still gets one id per request. + const sessionId = cacheKey?.key ?? randomUUID(); + + if (effortError) { + logger.info({ account: this.account.name, model: concreteModel, effortError }, "ai-proxy: effort refused"); + return new Response( + SafeJSON.stringify({ + error: { + message: effortError, + type: "invalid_request_error", + param: "reasoning.effort", + code: "unsupported_effort", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + warnDroppedParamsOnce(dropped); const whamBodyText = SafeJSON.stringify(whamBody); @@ -248,7 +506,7 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { let attempt: Response; try { - attempt = await this.fetchWham({ token, accountId, whamBodyText, signal: req.signal }); + attempt = await this.fetchWham({ token, accountId, whamBodyText, sessionId, signal: req.signal }); } catch (err) { const aborted = clientAbortResponse(err, { err, account: this.account.name }); if (aborted) { @@ -269,7 +527,7 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { void attempt.body?.cancel(); try { ({ token, accountId } = await candidate.resolve({ forceRefresh: true })); - attempt = await this.fetchWham({ token, accountId, whamBodyText, signal: req.signal }); + attempt = await this.fetchWham({ token, accountId, whamBodyText, sessionId, signal: req.signal }); } catch (err) { const aborted = clientAbortResponse(err, { err, account: this.account.name }); if (aborted) { @@ -370,6 +628,8 @@ export class OpenAiSubscriptionProvider implements ProxyProvider { elapsedMs, dropped: dropped.length > 0 ? dropped : undefined, authSource: this.account.openaiSub?.accountName ? "ai-config" : "codex-auth.json", + // Sent as prompt_cache_key and as the session_id, session-id and thread-id headers. + cacheKey: cacheKey ? `${cacheKey.key} (${cacheKey.source})` : undefined, }, "ai-proxy: WHAM upstream request ok" ); @@ -506,6 +766,17 @@ function extractText(content: unknown): string { return ""; } +/** + * A function tool the proxy built from a non-strict shape (a chat tool, an Anthropic tool), with `strict: false` unless + * the client chose. WHAM treats a function tool without `strict` as strict, and the model then fills every optional + * field: Claude Code's Agent tool came back with `isolation: "worktree"` and a `team_name` in 3 of 3 trials, against + * `prompt` only with `strict: false` (2026-10-25). codex-rs sends `strict: false` on every function tool. A + * `/v1/responses` client's tools pass as sent: its own API has the same default. + */ +function nonStrictByDefault(tool: Record<string, unknown>): Record<string, unknown> { + return typeof tool.strict === "boolean" ? tool : { ...tool, strict: false }; +} + function mapChatToolsToResponses(tools: unknown, dropped?: string[]): unknown[] | undefined { if (!Array.isArray(tools)) { return undefined; @@ -520,19 +791,22 @@ function mapChatToolsToResponses(tools: unknown, dropped?: string[]): unknown[] // Already Responses-shaped (flat function tool). if (tool.type === "function" && typeof tool.name === "string") { - mapped.push(tool); + mapped.push(nonStrictByDefault(tool)); continue; } const fn = isObject(tool.function) ? tool.function : undefined; if (fn && typeof fn.name === "string") { - mapped.push({ - type: "function", - name: fn.name, - description: typeof fn.description === "string" ? fn.description : undefined, - parameters: fn.parameters ?? { type: "object", properties: {} }, - }); + mapped.push( + nonStrictByDefault({ + type: "function", + name: fn.name, + description: typeof fn.description === "string" ? fn.description : undefined, + parameters: fn.parameters ?? { type: "object", properties: {} }, + ...(typeof fn.strict === "boolean" ? { strict: fn.strict } : {}), + }) + ); continue; } @@ -569,6 +843,10 @@ export interface WhamBodyBuildResult { body: Record<string, unknown>; /** Client-sent parameters WHAM cannot enforce, silently stripped from the upstream body. */ dropped: string[]; + /** Set when the catalog says the model has no such effort: the caller answers 400 with this text. */ + effortError?: string; + /** The conversation's cache key, sent as `prompt_cache_key` and as the session headers. */ + cacheKey?: PromptCacheKey; } const warnedDroppedParams = new Set<string>(); @@ -591,7 +869,18 @@ export function buildWhamResponsesBody( model: string, // `itemScope` is required, not optional: a caller that forgot it would share // one store partition with every other client, which is the bug this fixes. - options: { defaultReasoningEffort?: "none" | "low" | "medium" | "high"; itemScope: string } + options: { + defaultReasoningEffort?: "none" | "low" | "medium" | "high"; + itemScope: string; + /** + * The model's catalog record. With efforts listed, the effort is checked against the model's own list: `max` + * passes, `ultra` resolves like codex-rs, anything else is refused. Without one (an unknown model), the + * older fixed allowlist and its `max` to `xhigh` clamp apply. + */ + catalog?: CodexModelInfo; + /** A `session-id` / `session_id` header the client sent (a native Codex client keys its cache by it). */ + clientSessionHeader?: string; + } ): WhamBodyBuildResult { const body: Record<string, unknown> = { model, @@ -632,6 +921,16 @@ export function buildWhamResponsesBody( } else if (parsed.tools) { body.tools = parsed.tools; } + + // Forwarded as sent. Dropping them ran every request as "auto": Claude Code's compaction turn needs "none", + // and a client that turned parallel calls off meant it. + if (parsed.tool_choice !== undefined) { + body.tool_choice = parsed.tool_choice; + } + + if (typeof parsed.parallel_tool_calls === "boolean") { + body.parallel_tool_calls = parsed.parallel_tool_calls; + } } else { const messages = Array.isArray(parsed.messages) ? parsed.messages : []; const instructions: string[] = []; @@ -677,6 +976,17 @@ export function buildWhamResponsesBody( dropped.push("store"); } + const cacheKey = resolveWhamCacheKey(parsed, body, model, options.clientSessionHeader); + + if (cacheKey) { + body.prompt_cache_key = cacheKey.key; + } + + if (options.catalog && options.catalog.efforts.length > 0) { + const effortError = applyCatalogEffort({ parsed, body, model, catalog: options.catalog, options }); + return { body, dropped, effortError, cacheKey }; + } + if (isObject(parsed.reasoning)) { // Pass the client's reasoning through, clamping unknown efforts. The // `:max` suffix lands HERE when the client already sent a reasoning @@ -722,7 +1032,111 @@ export function buildWhamResponsesBody( } } - return { body, dropped }; + return { body, dropped, cacheKey }; +} + +/** + * The key the client chose (its `prompt_cache_key`, else its session header) wins and is forwarded unchanged. A + * `/v1/messages` request arrives with Claude Code's session id already stamped as `prompt_cache_key` by the + * Anthropic translation. Any other client gets a key from the model, the instructions and the first input item, + * which every later turn of the same conversation repeats. + */ +function resolveWhamCacheKey( + parsed: Record<string, unknown>, + body: Record<string, unknown>, + model: string, + clientSessionHeader: string | undefined +): PromptCacheKey | undefined { + const clientKey = typeof parsed.prompt_cache_key === "string" ? parsed.prompt_cache_key : clientSessionHeader; + const first = Array.isArray(body.input) ? body.input[0] : undefined; + + if (!clientKey && body.instructions === undefined && first === undefined) { + return undefined; + } + + return resolvePromptCacheKey({ clientKey, content: { model, system: body.instructions ?? "", first } }); +} + +/** + * Effort for a model the catalog knows. The nested `reasoning.effort` wins over a stamped top-level + * `reasoning_effort` (a `:effort` model-id suffix), as before. With none, the account default applies, then the + * model's own default (codex-rs sends `default_reasoning_level`), then `low`. Returns the refusal text, if any. + */ +function applyCatalogEffort({ + parsed, + body, + model, + catalog, + options, +}: { + parsed: Record<string, unknown>; + body: Record<string, unknown>; + model: string; + catalog: CodexModelInfo; + options: { defaultReasoningEffort?: "none" | "low" | "medium" | "high" }; +}): string | undefined { + const nested = isObject(parsed.reasoning) ? { ...parsed.reasoning } : undefined; + const requested = + nested && typeof nested.effort === "string" + ? nested.effort + : typeof parsed.reasoning_effort === "string" + ? parsed.reasoning_effort + : undefined; + + if (requested === undefined) { + const fallback = options.defaultReasoningEffort ?? catalog.defaultEffort ?? "low"; + + if (fallback === "none") { + if (nested) { + body.reasoning = nested; + } + + return undefined; + } + + body.reasoning = { ...nested, effort: fallback }; + return undefined; + } + + const resolved = resolveCodexEffort(catalog, requested); + + if (!resolved.ok) { + return resolved.message; + } + + if (resolved.mappedFrom) { + logger.debug({ model, from: resolved.mappedFrom, to: resolved.effort }, "ai-proxy: resolved Codex effort"); + } + + body.reasoning = { ...nested, effort: resolved.effort }; + return undefined; +} + +/** The first 12 hex of a SHA-256: enough to match a blob across turns in a log, nothing of its content. */ +function contentHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +/** + * The `reasoning` options of a `/v1/messages` request. A summary shows the model's reasoning as Claude Code + * thinking text (the default where the model takes the parameter). A Responses Lite model keeps the reasoning of every + * turn (`context: "all_turns"`), as codex-rs sends it; WHAM accepts it with classic tools (2026-10-25 probe b). + */ +function reasoningOptions( + catalog: CodexModelInfo | undefined, + summary: "none" | "auto" | "detailed" = "auto" +): Record<string, unknown> { + const reasoning: Record<string, unknown> = {}; + + if (summary !== "none" && catalog?.supportsReasoningSummaryParameter !== false) { + reasoning.summary = summary; + } + + if (catalog?.useResponsesLite) { + reasoning.context = "all_turns"; + } + + return reasoning; } const SSE_LINE_END = /\r\n|\r|\n/; diff --git a/src/ai-proxy/lib/providers/types.ts b/src/ai-proxy/lib/providers/types.ts index f455c22952..469ed42f74 100644 --- a/src/ai-proxy/lib/providers/types.ts +++ b/src/ai-proxy/lib/providers/types.ts @@ -1,4 +1,5 @@ import type { ThinkingPresentationMode, UsageSummary } from "@app/ai-proxy/lib/types"; +import type { PromptCacheKey } from "@genesiscz/utils/ai/prompt-cache-key"; export interface OpenAiModel { id: string; @@ -19,6 +20,14 @@ export interface ProviderInspectionOptions { probe?: boolean; } +/** What the proxy knows about a request beyond its body, for providers that need it. */ +export interface ProviderRequestContext { + /** The conversation's cache key (`@genesiscz/utils/ai/prompt-cache-key`), when one was resolved. */ + promptCacheKey?: PromptCacheKey; + /** The subagents the session defined with their own model: an Agent call's `model` never overrides theirs. */ + sessionAgents?: readonly string[]; +} + export interface ProxyProvider { id: string; readonly accountFingerprint: string; @@ -41,7 +50,13 @@ export interface ProxyProvider { * body with NO reshape — which is the only way `cache_control`, thinking * signatures and exact tool schemas survive the trip. */ - messages?(req: Request, model: string, bodyText: string): Promise<Response>; + messages?(req: Request, model: string, bodyText: string, context?: ProviderRequestContext): Promise<Response>; + /** + * True when this provider routes its prompt cache by the OpenAI `prompt_cache_key` field, so the + * `/v1/messages` translation carries the conversation's key into the OpenAI body (the Codex agent). + * Absent = the field is never added: a strict upstream may answer 400 to a parameter it does not know. + */ + acceptsPromptCacheKey?: boolean; /** * True when the messages() upstream tolerates the OpenAI `reasoning_effort` * field, so an `:<effort>` suffix may be stamped onto the passthrough body diff --git a/src/ai-proxy/lib/reasoning-effort-vocab.ts b/src/ai-proxy/lib/reasoning-effort-vocab.ts index eb240bc6e7..d2ae9834d3 100644 --- a/src/ai-proxy/lib/reasoning-effort-vocab.ts +++ b/src/ai-proxy/lib/reasoning-effort-vocab.ts @@ -24,7 +24,7 @@ import { isObject } from "@genesiscz/utils/object"; const XAI_EFFORT_ORDER = ["low", "medium", "high", "xhigh"] as const; /** Proxy-only vocabulary mapped onto xAI's ladder before clamping. */ -const PROXY_TO_XAI: Record<string, string> = { minimal: "low", max: "xhigh" }; +const PROXY_TO_XAI: Record<string, string> = { minimal: "low", max: "xhigh", ultra: "xhigh" }; const XAI_EFFORT_MODELS: Array<{ test: RegExp; allowed: readonly string[] }> = [ { test: /^grok-4\.[67]/, allowed: XAI_EFFORT_ORDER }, @@ -129,18 +129,21 @@ export function clampXaiReasoningEffort(bodyText: string, upstreamModel: string) /** * OpenRouter's top-level `reasoning_effort` enum has no `max`; its docs give * max and xhigh the same token allocation, so `max` maps to `xhigh` instead of - * 400ing. Every other proxy value is in OpenRouter's enum, and OpenRouter - * normalizes per model on its own. + * 400ing. `ultra` (a Codex UI level) maps the same way. Every other proxy value + * is in OpenRouter's enum, and OpenRouter normalizes per model on its own. */ export function mapOpenRouterReasoningEffort(bodyText: string): string { try { const parsed = SafeJSON.parse(bodyText, { strict: true }); - if (!isObject(parsed) || parsed.reasoning_effort !== "max") { + if (!isObject(parsed) || (parsed.reasoning_effort !== "max" && parsed.reasoning_effort !== "ultra")) { return bodyText; } - logger.debug("ai-proxy: mapped reasoning_effort max → xhigh for OpenRouter's top-level enum"); + logger.debug( + { effort: parsed.reasoning_effort }, + "ai-proxy: mapped reasoning_effort to xhigh for OpenRouter's top-level enum" + ); return SafeJSON.stringify({ ...parsed, reasoning_effort: "xhigh" }); } catch (err) { logger.debug({ err }, "ai-proxy: OpenRouter effort map skipped — body was not parseable JSON"); diff --git a/src/ai-proxy/lib/resolve-model.test.ts b/src/ai-proxy/lib/resolve-model.test.ts index c0d93644a1..8b84d3fe42 100644 --- a/src/ai-proxy/lib/resolve-model.test.ts +++ b/src/ai-proxy/lib/resolve-model.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { sessionRouteDenial } from "@app/ai-proxy/lib/clients"; import { parseProxyModelId, resolveModel } from "@app/ai-proxy/lib/resolve-model"; +import { type AiProxySessionRecord, sessionProxyAccounts } from "@app/ai-proxy/lib/sessions/records"; +import { resolveSessionRoute } from "@app/ai-proxy/lib/sessions/routing"; import type { AiProxyAccountConfig } from "@app/ai-proxy/lib/types"; +import { SafeJSON } from "@genesiscz/utils/json"; function grokAccount(name: string): AiProxyAccountConfig { return { name, provider: "grok-subscription", providerSlug: "grok", enabled: true }; @@ -195,3 +199,137 @@ describe("resolve-model with openrouter accounts", () => { ); }); }); + +describe("resolveSessionRoute", () => { + const accounts: AiProxyAccountConfig[] = [ + { name: "work-codex", provider: "openai-subscription", providerSlug: "codex", enabled: true }, + { name: "side-codex", provider: "openai-subscription", providerSlug: "codex", enabled: true }, + { name: "work-claude", provider: "anthropic-subscription", providerSlug: "claude-sub", enabled: true }, + { name: "work-grok", provider: "grok-subscription", providerSlug: "grok", enabled: true }, + { name: "off-claude", provider: "anthropic-subscription", providerSlug: "claude-sub", enabled: false }, + ]; + + function record(overrides: Partial<AiProxySessionRecord> = {}): AiProxySessionRecord { + return { + version: 1, + id: "0f8a495a-0000-4000-8000-000000000001", + keyHash: "0".repeat(64), + createdAt: "2026-10-25T20:00:00.000Z", + codexProxyAccount: "side-codex", + claudeProxyAccount: "work-claude", + moves: [], + meteredConfirmed: [], + ...overrides, + }; + } + + function routeOf(model: string, session = record()) { + const result = resolveSessionRoute(model, session, accounts); + + if (!result || "refusal" in result) { + throw new Error(`expected a route for ${model}, got ${SafeJSON.stringify(result)}`); + } + + return result.route; + } + + it("routes codex/<id> to the record's Codex account, even with two Codex accounts enabled", () => { + const route = routeOf("codex/gpt-6.1-sol"); + + expect(route.accountName).toBe("side-codex"); + expect(route.upstreamId).toBe("gpt-6.1-sol"); + expect(route.reasoningEffort).toBeUndefined(); + expect(route.effortSource).toBeUndefined(); + }); + + it("keeps an :effort suffix and marks it as the effort's source", () => { + const route = routeOf("codex/gpt-6-astra:xhigh"); + + expect(route.upstreamId).toBe("gpt-6-astra"); + expect(route.reasoningEffort).toBe("xhigh"); + expect(route.effortSource).toBe("suffix"); + }); + + it("routes a bare claude-* id, with or without [1m], to the record's Claude account", () => { + expect(routeOf("claude-opus-5-5").accountName).toBe("work-claude"); + + const oneM = routeOf("claude-opus-5-5[1m]"); + expect(oneM.accountName).toBe("work-claude"); + expect(oneM.upstreamId).toBe("claude-opus-5-5"); + }); + + it("routes grok/<id> only when the record has a Grok account", () => { + expect(routeOf("grok/grok-4.6", record({ grokProxyAccount: "work-grok" })).accountName).toBe("work-grok"); + expect(resolveSessionRoute("grok/grok-4.6", record(), accounts)).toEqual({ + refusal: + "session 0f8a495a-0000-4000-8000-000000000001 has no grok account, so it cannot run 'grok/grok-4.6'", + }); + }); + + it("refuses a Claude id in a session without a Claude account, and a disabled or wrong-kind account", () => { + const noClaude = resolveSessionRoute("claude-haiku-5-5", record({ claudeProxyAccount: undefined }), accounts); + expect(noClaude && "refusal" in noClaude ? noClaude.refusal : "").toContain("has no claude account"); + + const disabled = resolveSessionRoute( + "claude-haiku-5-5", + record({ claudeProxyAccount: "off-claude" }), + accounts + ); + expect(disabled && "refusal" in disabled ? disabled.refusal : "").toContain("is disabled"); + + const wrongKind = resolveSessionRoute( + "codex/gpt-6-luna", + record({ codexProxyAccount: "work-claude" }), + accounts + ); + expect(wrongKind && "refusal" in wrongKind ? wrongKind.refusal : "").toContain( + "is a anthropic-subscription account" + ); + }); + + it("leaves full account/provider/model ids, @account refs and other bare ids to the normal resolver", () => { + for (const model of [ + "work-codex/codex/gpt-6.1-sol:high", + "@account/abc:gpt-6.1-sol", + "grok-4.6", + "openai/gpt-6", + ]) { + expect(resolveSessionRoute(model, record(), accounts)).toBeUndefined(); + } + }); + + it("builds an account in memory for a login the record names (DECISION 1 a), and lets the session use it", () => { + const session = record({ + codexProxyAccount: undefined, + codexAccount: "work", + claudeProxyAccount: undefined, + claudeAccount: "personal", + }); + const codex = routeOf("codex/gpt-6.1-sol", session); + const claude = routeOf("claude-opus-5-5", session); + + expect(codex.account).toEqual({ + name: "login:work", + provider: "openai-subscription", + providerSlug: "codex", + enabled: true, + openaiSub: { accountName: "work" }, + }); + expect(claude.account).toMatchObject({ name: "login:personal", anthropicSub: { accountName: "personal" } }); + expect(sessionRouteDenial(session, codex.account)).toBeNull(); + expect(sessionProxyAccounts(session)).toEqual(["login:work", "login:personal"]); + }); + + it("prefers the configured proxy account the record names over its login (the legacy auth.json account keeps working)", () => { + const route = routeOf("codex/gpt-6.1-sol", record({ codexProxyAccount: "side-codex", codexAccount: "work" })); + + expect(route.accountName).toBe("side-codex"); + }); + + it("marks an effort suffix on the normal resolver too", () => { + const route = resolveModel("work-grok/grok/grok-4.6:high", accounts); + + expect(route.reasoningEffort).toBe("high"); + expect(route.effortSource).toBe("suffix"); + }); +}); diff --git a/src/ai-proxy/lib/resolve-model.ts b/src/ai-proxy/lib/resolve-model.ts index 2b03b100f9..93e8fceac1 100644 --- a/src/ai-proxy/lib/resolve-model.ts +++ b/src/ai-proxy/lib/resolve-model.ts @@ -9,6 +9,7 @@ export const REASONING_EFFORT_SUFFIXES = [ "xhigh", "minimal", "max", + "ultra", ] as const satisfies readonly ReasoningEffort[]; const REASONING_EFFORT_SET = new Set<string>(REASONING_EFFORT_SUFFIXES); @@ -154,7 +155,7 @@ export function resolveModel(proxyModelId: string, accounts: AiProxyAccountConfi return route; } - return { ...route, reasoningEffort }; + return { ...route, reasoningEffort, effortSource: "suffix" }; } function resolveModelWithoutEffort(proxyModelId: string, accounts: AiProxyAccountConfig[]): ResolvedRoute { diff --git a/src/ai-proxy/lib/route-guards.ts b/src/ai-proxy/lib/route-guards.ts index 696afe2d7f..047f0274aa 100644 --- a/src/ai-proxy/lib/route-guards.ts +++ b/src/ai-proxy/lib/route-guards.ts @@ -1,6 +1,6 @@ import { resolveProxyRoute } from "@app/ai-proxy/lib/account-model-ref"; import type { ResolvedClient } from "@app/ai-proxy/lib/clients"; -import { clientProviderDenial, resolveClient } from "@app/ai-proxy/lib/clients"; +import { resolveClient, routeDenial } from "@app/ai-proxy/lib/clients"; import { acquireProvider, providerUnavailableResponse } from "@app/ai-proxy/lib/providers/registry"; import type { ProxyProvider } from "@app/ai-proxy/lib/providers/types"; import type { AiProxyConfig, ResolvedRoute } from "@app/ai-proxy/lib/types"; @@ -52,7 +52,7 @@ export async function guardProxyRoute(input: { return jsonError(400, err instanceof Error ? err.message : String(err)); } - const denial = clientProviderDenial(client, route.account.provider); + const denial = routeDenial(client, route.account); if (denial) { logger.warn( diff --git a/src/ai-proxy/lib/server.ts b/src/ai-proxy/lib/server.ts index 3768c6355a..42aec494d8 100644 --- a/src/ai-proxy/lib/server.ts +++ b/src/ai-proxy/lib/server.ts @@ -2,28 +2,45 @@ import { resolveProxyRoute } from "@app/ai-proxy/lib/account-model-ref"; import { anthropicMessagesPipeline, countAnthropicInputTokens } from "@app/ai-proxy/lib/anthropic-messages"; import { handleAudioTranscriptions } from "@app/ai-proxy/lib/audio"; import { buildProxyModelCatalog } from "@app/ai-proxy/lib/catalog"; -import { clientProviderDenial, type ResolvedClient, resolveClient, validateClients } from "@app/ai-proxy/lib/clients"; +import { + type ResolvedClient, + resolveClient, + routeDenial, + sessionKeyRejection, + validateClients, +} from "@app/ai-proxy/lib/clients"; +import { setCodexModelOverrides } from "@app/ai-proxy/lib/codex-catalog"; import { loadConfigFresh } from "@app/ai-proxy/lib/config"; import { stripBasePath } from "@app/ai-proxy/lib/path-prefix"; import { acquireProvider, buildProviderMap, providerUnavailableResponse } from "@app/ai-proxy/lib/providers/registry"; import type { ProxyProvider } from "@app/ai-proxy/lib/providers/types"; import { handleRealtimeClientSecrets, handleRealtimeUpgrade, realtimeWebsocket } from "@app/ai-proxy/lib/realtime"; import { applyReasoningEffortToBody, findInvalidImageDataPayload } from "@app/ai-proxy/lib/rewrite-upstream-body"; +import { messageToAnthropicSse } from "@app/ai-proxy/lib/server-tools/web-search"; +import { noteSessionRequest, sweepSessionRecords } from "@app/ai-proxy/lib/sessions/records"; +import { resolveSessionRoute } from "@app/ai-proxy/lib/sessions/routing"; +import { requestTagsFor } from "@app/ai-proxy/lib/sessions/tags"; import { resolveThinkingMode } from "@app/ai-proxy/lib/thinking-config"; import { resolveTranslationMode } from "@app/ai-proxy/lib/translation-config"; import { handleChatCompletions } from "@app/ai-proxy/lib/translators"; +import { + isClaudeCodeCompactionTurn, + isClaudeCodeModelProbe, + modelProbeReply, +} from "@app/ai-proxy/lib/translators/formats/anthropic/claude-code-turns"; import { identityPipeline } from "@app/ai-proxy/lib/translators/identity-pipeline"; -import type { AiProxyConfig, ResolvedRoute, ThinkingPresentationMode } from "@app/ai-proxy/lib/types"; +import type { AiProxyConfig, EffortSource, ResolvedRoute, ThinkingPresentationMode } from "@app/ai-proxy/lib/types"; import { scheduleBillingSync } from "@app/ai-proxy/lib/usage/billing-sync"; import type { CallTimeline } from "@app/ai-proxy/lib/usage/call-timeline"; import { checkClientQuota } from "@app/ai-proxy/lib/usage/client-ledger"; import { scheduleUsageTracking } from "@app/ai-proxy/lib/usage/track-response"; -import { readRequestTags } from "@app/ai-proxy/lib/usage/transcripts"; import type { RequestTags } from "@app/ai-proxy/lib/usage/types"; import { CopilotAuthExpiredError } from "@genesiscz/utils/ai/github-copilot"; import { GrokAuthExpiredError } from "@genesiscz/utils/ai/grok"; +import { claudeSessionIdFromMetadata } from "@genesiscz/utils/ai/prompt-cache-key"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024; @@ -96,6 +113,10 @@ function trackProxyRequest(input: { translate?: string; thinking?: string; tags?: RequestTags; + cacheKey?: string; + compaction?: boolean; + effort?: string; + effortSource?: EffortSource; }): void { scheduleUsageTracking({ route: input.route, @@ -113,6 +134,10 @@ function trackProxyRequest(input: { translate: input.translate, thinking: input.thinking, tags: input.tags, + cacheKey: input.cacheKey, + compaction: input.compaction, + effort: input.effort, + effortSource: input.effortSource, }); scheduleBillingSync(input.route.account, input.runtime.providers); } @@ -124,12 +149,55 @@ function jsonError(status: number, error: Record<string, unknown>): Response { }); } +/** + * A refusal in the client's own error shape. Claude Code reads `type: "error"` first and showed an OpenAI-shaped + * body as a blank failure, so `/v1/messages` gets Anthropic's envelope; the OpenAI endpoints keep theirs. + */ +const ANTHROPIC_ERROR_TYPES = { + 400: "invalid_request_error", + 403: "permission_error", + 429: "rate_limit_error", +} as const; + +function refusal( + status: keyof typeof ANTHROPIC_ERROR_TYPES, + message: string, + openAi: { type: string; code: string }, + anthropic: boolean +): Response { + if (!anthropic) { + return jsonError(status, { message, ...openAi }); + } + + const type = ANTHROPIC_ERROR_TYPES[status]; + return new Response(SafeJSON.stringify({ type: "error", error: { type, message } }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + /** * Read a model-bearing POST body once, with the size and shape guards every * inference route needs. Shared by /v1/chat/completions, /v1/responses and * /v1/messages so a guard added for one door is never missing from another. */ -async function readModelBody(req: Request): Promise<{ bodyText: string; model: string } | { error: Response }> { +interface ModelBody { + bodyText: string; + model: string; + /** The session id Claude Code put in `metadata.user_id`. */ + claudeSessionId?: string; + /** The conversation id the client sent on its own: Claude Code's session id, else a body `prompt_cache_key`. */ + conversationId?: string; + /** Claude Code's compaction turn (`/v1/messages`). */ + compaction: boolean; + /** Claude Code's one-token `model_validation` probe (`/v1/messages`). */ + probe: boolean; + stream: boolean; + /** The effort the client set itself: Claude Code's `/effort`, sent as `output_config.effort`. */ + clientEffort?: string; +} + +async function readModelBody(req: Request): Promise<ModelBody | { error: Response }> { const contentLength = req.headers.get("content-length"); if (contentLength) { @@ -146,10 +214,19 @@ async function readModelBody(req: Request): Promise<{ bodyText: string; model: s return { error: jsonError(413, { message: "Request body too large" }) }; } - let parsed: { model?: string }; + let parsed: { + model?: string; + metadata?: unknown; + prompt_cache_key?: unknown; + messages?: unknown; + max_tokens?: unknown; + tools?: unknown; + stream?: unknown; + output_config?: unknown; + }; try { - parsed = SafeJSON.parse(bodyText, { strict: true }) as { model?: string }; + parsed = SafeJSON.parse(bodyText, { strict: true }) as typeof parsed; } catch (err) { logger.debug({ err }, "ai-proxy: invalid JSON body"); return { error: jsonError(400, { message: "Invalid JSON body" }) }; @@ -179,7 +256,31 @@ async function readModelBody(req: Request): Promise<{ bodyText: string; model: s }; } - return { bodyText, model: parsed.model }; + const claudeSessionId = claudeSessionIdFromMetadata(parsed.metadata); + const cacheKey = typeof parsed.prompt_cache_key === "string" ? parsed.prompt_cache_key.trim() : ""; + + return { + bodyText, + model: parsed.model, + claudeSessionId, + conversationId: claudeSessionId ?? (cacheKey || undefined), + compaction: isClaudeCodeCompactionTurn(parsed), + probe: isClaudeCodeModelProbe(parsed), + stream: parsed.stream === true, + clientEffort: + isObject(parsed.output_config) && typeof parsed.output_config.effort === "string" + ? parsed.output_config.effort.trim() || undefined + : undefined, + }; +} + +/** The effort a `/v1/messages` request asked for, and from where: the id's suffix wins over the client's setting. */ +function requestEffort(route: ResolvedRoute, body: ModelBody): { effort?: string; effortSource: EffortSource } { + if (route.reasoningEffort) { + return { effort: route.reasoningEffort, effortSource: "suffix" }; + } + + return body.clientEffort ? { effort: body.clientEffort, effortSource: "client" } : { effortSource: "default" }; } /** Model → account route plus a live provider, with the per-client denial and quota gates. */ @@ -188,18 +289,35 @@ async function authorizeModelRoute({ config, model, providers, + anthropic = false, }: { + /** The request came in on `/v1/messages`: refusals use Anthropic's error envelope. */ + anthropic?: boolean; client: ResolvedClient; config: AiProxyConfig; model: string; providers: Map<string, ProxyProvider>; }): Promise<{ route: ResolvedRoute; provider: ProxyProvider } | { error: Response }> { - const route = await resolveProxyRoute(model, config.accounts); - const denial = clientProviderDenial(client, route.account.provider); + const sessionRoute = client.session ? resolveSessionRoute(model, client.session, config.accounts) : undefined; + + if (sessionRoute && "refusal" in sessionRoute) { + logger.warn({ client: client.name, model, refusal: sessionRoute.refusal }, "ai-proxy: session route refused"); + return { + error: refusal( + 400, + sessionRoute.refusal, + { type: "invalid_request_error", code: "model_not_in_session" }, + anthropic + ), + }; + } + + const route = sessionRoute?.route ?? (await resolveProxyRoute(model, config.accounts)); + const denial = routeDenial(client, route.account); if (denial) { logger.warn({ client: client.name, model, denial }, "ai-proxy: provider denied"); - return { error: jsonError(403, { message: denial, type: "forbidden", code: "provider_not_allowed" }) }; + return { error: refusal(403, denial, { type: "forbidden", code: "provider_not_allowed" }, anthropic) }; } const quota = checkClientQuota(client); @@ -207,11 +325,7 @@ async function authorizeModelRoute({ if (!quota.ok) { logger.warn({ client: client.name, reason: quota.reason }, "ai-proxy: quota exceeded"); return { - error: jsonError(429, { - message: quota.reason, - type: "quota_exceeded", - code: "monthly_quota_exceeded", - }), + error: refusal(429, quota.reason, { type: "quota_exceeded", code: "monthly_quota_exceeded" }, anthropic), }; } @@ -231,6 +345,12 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { throw new Error(`ai-proxy: invalid clients config — ${clientProblems.join("; ")}`); } + try { + sweepSessionRecords({ retentionDays: runtime.config.sessionRetentionDays }); + } catch (err) { + logger.warn({ err }, "ai-proxy: session sweep failed; records stay as they are"); + } + const listen = runtime.config.listen; return Bun.serve({ @@ -252,6 +372,21 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { } const config = await loadConfigFresh(); + setCodexModelOverrides(config.codexModels); + + // An unknown or closed session key is refused before any route runs, and never forwarded anywhere. + const sessionRejection = sessionKeyRejection(req); + + if (sessionRejection) { + logger.warn({ path, reason: sessionRejection }, "ai-proxy: session key refused"); + + return refusal( + 403, + sessionRejection, + { type: "permission_error", code: "session_unknown" }, + path.startsWith("/v1/messages") + ); + } if (path === "/v1/realtime" && req.method === "GET") { return handleRealtimeUpgrade({ req, url, server, config, providers: runtime.providers }); @@ -324,6 +459,8 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { } const { bodyText, model } = body; + const session = client.session ? noteSessionRequest(client.session, body.claudeSessionId) : undefined; + const tags = requestTagsFor(req.headers, session, body.conversationId); try { const routed = await authorizeModelRoute({ @@ -331,6 +468,7 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { config, model, providers: runtime.providers, + anthropic: true, }); if ("error" in routed) { @@ -339,6 +477,26 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { const { route, provider } = routed; + // Answered here once the route and its account check passed: forwarding it would run a whole + // Codex turn (WHAM ignores the one-token cap) inside the probe's 15 s, for an answer nobody reads. + if (client.session && body.probe) { + logger.info( + { model, account: route.accountName, session: session?.id }, + "ai-proxy: answered Claude Code's model_validation probe" + ); + const reply = modelProbeReply(model); + + return body.stream + ? new Response(messageToAnthropicSse(reply), { + headers: { "Content-Type": "text/event-stream" }, + }) + : new Response(SafeJSON.stringify(reply), { + headers: { "Content-Type": "application/json" }, + }); + } + + const effort = requestEffort(route, body); + // Stamped on the Anthropic body: normalizeAnthropicToOpenAI keeps // reasoning_effort, so it rides through to the OpenAI body the // provider forwards. Without this, an @@ -365,15 +523,25 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { ); } - const { response, responseBody, timeline, openAiBodyText, clientResponseBody, captureFailure } = - await anthropicMessagesPipeline({ - startedAt: requestStarted, - provider, - upstreamModel: route.upstreamId, - proxyModel: model, - req, - bodyText: routedBody, - }); + const { + response, + responseBody, + timeline, + openAiBodyText, + clientResponseBody, + captureFailure, + promptCacheKey, + } = await anthropicMessagesPipeline({ + startedAt: requestStarted, + provider, + upstreamModel: route.upstreamId, + proxyModel: model, + req, + bodyText: routedBody, + cacheKeyPolicy: session?.cacheKeyPolicy, + recordId: session?.id, + sessionAgents: session?.agents, + }); const elapsedMs = Math.round(performance.now() - requestStarted); // The usage layer only parses the OpenAI shape, and the exchange it @@ -397,7 +565,10 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { // A stream that stalled and was cut short is not a // success; the other two routes already forward this. captureFailure, - tags: readRequestTags(req.headers), + tags, + cacheKey: promptCacheKey?.key, + compaction: body.compaction, + ...effort, }); logger.info( @@ -407,7 +578,10 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { upstreamModel: route.upstreamId, status: response.status, elapsedMs, - ...readRequestTags(req.headers), + ...tags, + // The whole key: a session id or a content hash, never a secret, and the only place the + // log can be joined with Claude Code's own transcript and the upstream's cache. + cacheKey: promptCacheKey ? `${promptCacheKey.key} (${promptCacheKey.source})` : undefined, userAgent: req.headers.get("User-Agent") ?? undefined, }, "ai-proxy: request" @@ -455,6 +629,8 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { } const { bodyText, model } = body; + const session = client.session ? noteSessionRequest(client.session, body.claudeSessionId) : undefined; + const tags = requestTagsFor(req.headers, session, body.conversationId); try { const routed = await authorizeModelRoute({ @@ -499,7 +675,7 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { // the 2026-08-19 field-loss audit could not quantify // anything on the OpenAI doors (0 of 3,068 recoverable). clientRequestBody: bodyText, - tags: readRequestTags(req.headers), + tags, }); logger.info( @@ -509,7 +685,7 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { upstreamModel: route.upstreamId, status: response.status, elapsedMs, - ...readRequestTags(req.headers), + ...tags, userAgent: req.headers.get("User-Agent") ?? undefined, }, "ai-proxy: request" @@ -562,7 +738,7 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { // Verbatim client bytes, pre effort-stamp — see the // /v1/responses call above. clientRequestBody: bodyText, - tags: readRequestTags(req.headers), + tags, }); logger.info( @@ -574,7 +750,7 @@ export function startAiProxyServer(runtime: AiProxyRuntime) { translate: mode, thinking: thinkingMode, elapsedMs, - ...readRequestTags(req.headers), + ...tags, userAgent: req.headers.get("User-Agent") ?? undefined, }, "ai-proxy: request" diff --git a/src/ai-proxy/lib/sessions/records.test.ts b/src/ai-proxy/lib/sessions/records.test.ts new file mode 100644 index 0000000000..9374892a90 --- /dev/null +++ b/src/ai-proxy/lib/sessions/records.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sinceToMs } from "@app/ai-proxy/commands/sessions"; +import { + clientProviderDenial, + type ResolvedClient, + resolveClient, + routeDenial, + sessionKeyRejection, +} from "@app/ai-proxy/lib/clients"; +import { + _resetSessionKeyIndexForTest, + type AiProxySessionRecord, + closeSessionRecord, + createSessionRecord, + hashSessionKey, + newSessionKey, + noteSessionRequest, + openSessionRecord, + readSessionRecord, + resolveSessionKey, + sessionsDir, + sweepSessionRecords, + updateSessionRecord, + writeSessionRecord, +} from "@app/ai-proxy/lib/sessions/records"; +import { requestTagsFor } from "@app/ai-proxy/lib/sessions/tags"; +import { requestRow, requestRows, statsByAgent } from "@app/ai-proxy/lib/sessions/view"; +import { resetAiProxyStorage } from "@app/ai-proxy/lib/storage"; +import type { AiProxyAccountConfig, AiProxyConfig } from "@app/ai-proxy/lib/types"; +import type { UsageRequestRecord } from "@app/ai-proxy/lib/usage/types"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; +import { env } from "@genesiscz/utils/env"; +import { SafeJSON } from "@genesiscz/utils/json"; + +const SESSION = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"; +const originalHome = env.get("GENESIS_TOOLS_HOME"); + +const codex: AiProxyAccountConfig = { + name: "codex", + provider: "openai-subscription", + providerSlug: "codex", + enabled: true, +}; +const claude: AiProxyAccountConfig = { + name: "personal", + provider: "anthropic-subscription", + providerSlug: "claude-sub", + enabled: true, +}; +const metered: AiProxyAccountConfig = { name: "work", provider: "xai-api-key", providerSlug: "xai", enabled: true }; + +function config(): AiProxyConfig { + return { proxyApiKey: "owner-key-0123456789" } as AiProxyConfig; +} + +function bearer(token: string): Request { + return new Request("http://localhost/v1/messages", { headers: { Authorization: `Bearer ${token}` } }); +} + +function newSession(overrides: Partial<Parameters<typeof createSessionRecord>[0]> = {}) { + return createSessionRecord({ + id: SESSION, + codexProxyAccount: "codex", + claudeProxyAccount: "personal", + ...overrides, + }); +} + +beforeEach(() => { + env.testing.set("GENESIS_TOOLS_HOME", mkdtempSync(join(tmpdir(), "gt-proxy-sessions-"))); + resetAiProxyStorage(); + _resetSessionKeyIndexForTest(); +}); + +afterEach(() => { + resetAiProxyStorage(); + _resetSessionKeyIndexForTest(); + + if (originalHome === undefined) { + env.testing.unset("GENESIS_TOOLS_HOME"); + } else { + env.testing.set("GENESIS_TOOLS_HOME", originalHome); + } +}); + +describe("session records", () => { + it("writes the record 0600 in a 0700 folder, with the key's hash and never the key", () => { + const { record, key } = newSession({ cwd: "/work/project" }); + const path = join(sessionsDir(), `${SESSION}.json`); + const text = readFileSync(path, "utf8"); + + expect(key).toMatch(/^gts_[A-Za-z0-9_-]{43}$/); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(sessionsDir()).mode & 0o777).toBe(0o700); + expect(text).not.toContain(key); + expect(record.keyHash).toBe(hashSessionKey(key)); + expect(readSessionRecord(SESSION)).toEqual(record); + expect(() => newSession()).toThrow("already has a record"); + }); + + it("resolves a key by hash, rescans once for a record another process wrote, and knows a closed one", () => { + const { key } = newSession(); + expect(resolveSessionKey(key).state).toBe("open"); + + // The launcher writes its record from another process: this process's index has never seen it. + const otherKey = newSessionKey(); + const other: AiProxySessionRecord = { + version: 1, + id: "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c", + keyHash: hashSessionKey(otherKey), + createdAt: new Date().toISOString(), + codexProxyAccount: "codex", + moves: [], + meteredConfirmed: [], + }; + writeSessionRecord(other); + expect(resolveSessionKey(otherKey)).toEqual({ state: "open", record: other }); + + expect(resolveSessionKey(newSessionKey()).state).toBe("unknown"); + closeSessionRecord(SESSION); + expect(resolveSessionKey(key).state).toBe("closed"); + }); + + it("reopens the same record on --resume with a fresh key, keeping its history and earlier confirmations", () => { + const first = openSessionRecord({ + id: SESSION, + codexAccount: "work", + meteredConfirmed: [{ account: "billed", confirmedAt: "2026-10-25T20:00:00.000Z", how: "flag" }], + }); + closeSessionRecord(SESSION); + const second = openSessionRecord({ id: SESSION, codexAccount: "side", claudeAccount: "personal" }); + + expect([first.reopened, second.reopened]).toEqual([false, true]); + expect(resolveSessionKey(first.key).state).toBe("unknown"); + expect(resolveSessionKey(second.key).state).toBe("open"); + expect(second.record).toMatchObject({ + createdAt: first.record.createdAt, + codexAccount: "side", + claudeAccount: "personal", + meteredConfirmed: [{ account: "billed", how: "flag" }], + }); + expect(second.record.closedAt).toBeUndefined(); + }); +}); + +describe("session keys at the door", () => { + it("an open key is a session client; an unknown or closed key gets a reason, never another identity", async () => { + const { key } = newSession(); + const client = await resolveClient(bearer(key), config()); + + expect(client?.name).toBe("session"); + expect(client?.isOwner).toBe(false); + expect(client?.session?.id).toBe(SESSION); + expect(sessionKeyRejection(bearer(key))).toBeNull(); + expect(sessionKeyRejection(bearer("owner-key-0123456789"))).toBeNull(); + + const unknown = newSessionKey(); + expect(await resolveClient(bearer(unknown), config())).toBeNull(); + expect(sessionKeyRejection(bearer(unknown))).toContain( + "unknown or closed session; relaunch with tools claude codex" + ); + + closeSessionRecord(SESSION); + expect(await resolveClient(bearer(key), config())).toBeNull(); + expect(sessionKeyRejection(bearer(key))).toContain(`session ${SESSION} closed`); + }); +}); + +describe("routeDenial", () => { + const owner: ResolvedClient = { name: "owner", isOwner: true }; + const alice: ResolvedClient = { + name: "alice", + isOwner: false, + config: { name: "alice", key: "alice-key-0123456789" }, + }; + + it("a session routes to the subscription accounts its record names, and to no other", () => { + const { record } = newSession(); + const client: ResolvedClient = { name: "session", isOwner: false, session: record }; + + expect(routeDenial(client, codex)).toBeNull(); + expect(routeDenial(client, claude)).toBeNull(); + expect(routeDenial(client, { ...claude, name: "olivia" })).toContain('account "olivia" is not part of session'); + // Asked without the account, the provider-type rule refuses rather than guesses. + expect(clientProviderDenial(client, "openai-subscription")).toContain("checked against the accounts"); + }); + + it("a metered account needs the session's confirmation, and then a key of its own (never the environment's)", () => { + const { record } = newSession({ grokProxyAccount: "work" }); + const unconfirmed: ResolvedClient = { name: "session", isOwner: false, session: record }; + + expect(routeDenial(unconfirmed, metered)).toContain( + "billed per token, and this session uses subscription accounts only" + ); + expect(routeDenial(unconfirmed, metered)).toContain("--allow-metered work"); + + const confirmed = updateSessionRecord(SESSION, (current) => { + current.meteredConfirmed.push({ account: "work", confirmedAt: new Date().toISOString(), how: "flag" }); + }); + const client: ResolvedClient = { name: "session", isOwner: false, session: confirmed }; + + expect(routeDenial(client, { ...metered, apiKeyEnv: "XAI_API_KEY", allowEnvApiKey: true })).toContain( + "would spend a key from the proxy's environment" + ); + expect(routeDenial(client, { ...metered, apiKey: "xai-test-key" })).toBeNull(); + }); + + it("keeps the no-resale rule for ordinary client keys and the owner's full access", () => { + expect(routeDenial(alice, codex)).toContain("owner-only"); + expect(routeDenial(alice, metered)).toBeNull(); + expect(routeDenial(owner, codex)).toBeNull(); + }); +}); + +describe("sweepSessionRecords", () => { + const START = 1_760_000_000_000; + + it("closes a record whose Claude process is gone or whose pid now belongs to a newer process", () => { + newSession({ claudePid: 111, claudePidStartMs: START }); + createSessionRecord({ id: "b-recycled", codexProxyAccount: "codex", claudePid: 222, claudePidStartMs: START }); + createSessionRecord({ id: "c-running", codexProxyAccount: "codex", claudePid: 333, claudePidStartMs: START }); + const probe = { + alive: (pid: number) => pid !== 111, + startMs: (pid: number) => (pid === 222 ? START + 60_000 : START + 500), + }; + + const result = sweepSessionRecords({ probe }); + + // readdir order differs per file system. + expect(result.closed.toSorted((a, b) => a.id.localeCompare(b.id))).toEqual([ + { id: SESSION, reason: "process-gone" }, + { id: "b-recycled", reason: "pid-recycled" }, + ]); + expect(readSessionRecord(SESSION)?.closeReason).toBe("process-gone"); + expect(readSessionRecord("c-running")?.closedAt).toBeUndefined(); + }); + + it("deletes closed records past the retention and keeps younger ones", () => { + newSession(); + closeSessionRecord(SESSION); + createSessionRecord({ id: "young", codexProxyAccount: "codex" }); + closeSessionRecord("young"); + updateSessionRecord(SESSION, (record) => { + record.closedAt = new Date(Date.now() - 31 * 86_400_000).toISOString(); + }); + + expect(sweepSessionRecords({ retentionDays: 30 }).pruned).toEqual([SESSION]); + expect(existsSync(join(sessionsDir(), `${SESSION}.json`))).toBe(false); + expect(readSessionRecord("young")).toBeDefined(); + }); +}); + +describe("one session id per session", () => { + it("a fork names its id on the first request; a different id later is logged and never re-keys", () => { + const { record } = newSession({ forkPending: true }); + const forked = "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d"; + + const named = noteSessionRequest(record, forked); + expect(named.claudeSessionId).toBe(forked); + expect(readSessionRecord(SESSION)?.forkPending).toBe(false); + + const later = noteSessionRequest(named, "7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e"); + expect(later.claudeSessionId).toBe(forked); + expect(requestTagsFor(new Headers(), later, undefined)?.session).toBe(forked); + }); + + it("tags a session's request with its id over any header; other clients keep x-gt-session, else their own id", () => { + const { record } = newSession(); + const headers = new Headers({ "x-gt-session": "chosen", "x-gt-stage": "build" }); + + expect(requestTagsFor(headers, record, "other")).toEqual({ session: SESSION, stage: "build" }); + expect(requestTagsFor(headers, undefined, SESSION)).toEqual({ session: "chosen", stage: "build" }); + expect(requestTagsFor(new Headers(), undefined, SESSION)).toEqual({ session: SESSION }); + expect(requestTagsFor(new Headers({ "thread-id": "codex-thread" }), undefined, undefined)).toEqual({ + session: "codex-thread", + }); + expect(requestTagsFor(new Headers(), undefined, undefined)).toBeUndefined(); + }); +}); + +describe("session view rows and stats", () => { + const base: UsageRequestRecord = { + ts: "2026-10-25T20:00:00.000Z", + account: "codex", + provider: "openai-subscription", + proxyModel: "codex/codex/gpt-x:high", + upstreamModel: "gpt-x", + path: "/v1/messages", + status: 200, + elapsedMs: 1000, + stream: true, + tags: { session: SESSION }, + }; + + it("splits each convention once: OpenAI cached tokens inside the prompt, Anthropic cache fields outside", () => { + const codexRow = requestRow({ + ...base, + usage: { prompt_tokens: 1000, completion_tokens: 10, total_tokens: 1010, cached_prompt_tokens: 900 }, + }); + const claudeRow = requestRow({ + ...base, + provider: "anthropic-subscription", + usage: { prompt_tokens: 100, completion_tokens: 5, total_tokens: 105, cache_read_input_tokens: 900 }, + }); + const oldRow = requestRow({ + ...base, + usage: { prompt_tokens: 1000, completion_tokens: 10, total_tokens: 1010 }, + }); + + expect(codexRow).toMatchObject({ agent: "codex", effort: "high", input: 100, cacheRead: 900, hit: 90 }); + expect(claudeRow).toMatchObject({ agent: "claude", input: 100, cacheRead: 900, hit: 90 }); + expect(oldRow.cacheKnown).toBe(false); + }); + + it("takes the cache numbers of an old row from its transcript, also once the day was compressed to .zst", () => { + const day = join(sessionsDir(), "..", "transcripts", "2026-10-24"); + mkdirSync(day, { recursive: true }); + const file = join(day, `${SESSION}.jsonl`); + const entry = { + uuid: "u-1", + type: "assistant", + message: { usage: { prompt_tokens: 2000, prompt_tokens_details: { cached_tokens: 1500 } } }, + }; + writeFileSync( + `${file}.zst`, + compressBytes(`${SafeJSON.stringify({ uuid: "u-0", type: "user" })}\n${SafeJSON.stringify(entry)}\n`) + ); + + const [row] = requestRows([ + { + ...base, + usage: { prompt_tokens: 2000, completion_tokens: 1, total_tokens: 2001 }, + transcript: { file, uuid: "u-1" }, + }, + ]); + + expect(row).toMatchObject({ cacheKnown: true, input: 500, cacheRead: 1500, hit: 75 }); + }); + + it("stats per agent: hit over the known rows, losses inside each session by cause, rows without numbers counted apart", () => { + const row = (minute: number, read: number, extra: Partial<UsageRequestRecord> = {}) => + requestRow({ + ...base, + ts: new Date(Date.parse(base.ts) + minute * 60_000).toISOString(), + usage: { + prompt_tokens: read + 100, + completion_tokens: 1, + total_tokens: read + 101, + cached_prompt_tokens: read, + }, + ...extra, + }); + const rows = [ + row(0, 50_000, { cacheKey: "a" }), + row(1, 1_000, { cacheKey: "b" }), + row(2, 1_000, { tags: { session: "other" } }), + requestRow({ ...base, usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } }), + ]; + const [codex] = statsByAgent(rows); + + expect(codex).toMatchObject({ agent: "codex", requests: 4, unknownCache: 1, sessions: 2, losses: 1 }); + expect(codex.lossesByCause["key changed"]).toBe(1); + expect(codex.hitPercent).toBeCloseTo((52_000 / 52_300) * 100, 6); + expect(sinceToMs("7d", 1_000_000_000_000)).toBe(1_000_000_000_000 - 7 * 86_400_000); + expect(() => sinceToMs("soon")).toThrow("--since"); + }); +}); diff --git a/src/ai-proxy/lib/sessions/records.ts b/src/ai-proxy/lib/sessions/records.ts new file mode 100644 index 0000000000..1779d65049 --- /dev/null +++ b/src/ai-proxy/lib/sessions/records.ts @@ -0,0 +1,505 @@ +/** + * Session records: one file per `tools claude codex` session, the key it presents, and the accounts it may use. + * + * `~/.genesis-tools/ai-proxy/sessions/<id>.json` (file 0600, folder 0700, atomic writes). `<id>` IS Claude Code's + * session id: the launcher generates it and passes `claude --session-id <id>`, and Claude Code keeps it across + * `--resume`, so the record, `tags.session`, the ai-proxy transcript file and the upstream cache key all carry the + * one id Claude Code writes its own transcript under. The record stores only a SHA-256 of the session key. + */ +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { getAiProxyStorage } from "@app/ai-proxy/lib/storage"; +import type { PromptCacheKeyPolicy } from "@genesiscz/utils/ai/prompt-cache-key"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { isProcessAlive } from "@genesiscz/utils/process-alive"; +import { processStartMs, START_MS_TOLERANCE } from "@genesiscz/utils/process-identity"; +import { atomicWriteFileSync, isJsonObject } from "@genesiscz/utils/storage/atomic-file"; + +export const SESSION_KEY_PREFIX = "gts_"; +export const SESSION_RECORD_FILE_MODE = 0o600; +export const SESSION_DIR_MODE = 0o700; +/** Closed records stay for stats this long unless `sessionRetentionDays` says otherwise. */ +export const DEFAULT_SESSION_RETENTION_DAYS = 30; + +/** Which upstream route a manual switch moved. */ +export type SessionAgent = "codex" | "claude" | "grok"; + +export interface SessionMove { + agent: SessionAgent; + from: string; + to: string; + at: string; +} + +/** A metered account the user confirmed for this session (`--allow-metered <account>` or the y/N prompt). */ +export interface MeteredConfirmation { + account: string; + confirmedAt: string; + how: "flag" | "prompt"; +} + +export type SessionCloseReason = "exited" | "process-gone" | "pid-recycled"; + +export interface AiProxySessionRecord { + version: 1; + /** Claude Code's session id; the file name. */ + id: string; + /** SHA-256 (hex) of the `gts_` key; the key itself is never stored. */ + keyHash: string; + createdAt: string; + lastSeenAt?: string; + closedAt?: string; + closeReason?: SessionCloseReason; + launcherPid?: number; + claudePid?: number; + claudePidStartMs?: number; + cwd?: string; + /** `--fork-session`: Claude Code picks a new id, so the first request names it (stored in `claudeSessionId`). */ + forkPending?: boolean; + /** The session id Claude Code sent in `metadata.user_id`, when it is not the record id (fork). */ + claudeSessionId?: string; + codexProxyAccount?: string; + codexAccount?: string; + claudeProxyAccount?: string; + claudeAccount?: string; + grokProxyAccount?: string; + grokAccount?: string; + model?: string; + effort?: string; + window?: number; + compactPercent?: number; + cacheKeyPolicy?: PromptCacheKeyPolicy; + /** The subagents the launcher defined with their own model (`--agents`); an Agent call's `model` cannot override them. */ + agents?: string[]; + moves: SessionMove[]; + meteredConfirmed: MeteredConfirmation[]; +} + +export type NewSessionRecord = Omit< + AiProxySessionRecord, + "version" | "keyHash" | "createdAt" | "moves" | "meteredConfirmed" +> & + Partial<Pick<AiProxySessionRecord, "moves" | "meteredConfirmed">>; + +/** The session id every request of this session is tagged with: the forked id once known, else the record id. */ +export function sessionTagId(record: AiProxySessionRecord): string { + return record.claudeSessionId ?? record.id; +} + +/** The record field that names each agent's configured proxy account. */ +export const SESSION_ACCOUNT_FIELD = { + codex: "codexProxyAccount", + claude: "claudeProxyAccount", + grok: "grokProxyAccount", +} as const satisfies Record<SessionAgent, keyof AiProxySessionRecord>; + +/** The record field that names each agent's login (a `tools codex` / `tools claude` / `tools grok` account). */ +export const SESSION_LOGIN_FIELD = { + codex: "codexAccount", + claude: "claudeAccount", + grok: "grokAccount", +} as const satisfies Record<SessionAgent, keyof AiProxySessionRecord>; + +/** The name of the account the proxy builds in memory for a login (❓ DECISION 1 a): never written to the config. */ +export function loginAccountName(login: string): string { + return `login:${login}`; +} + +/** + * The account a session uses for an agent: the configured proxy account the record names (the legacy Codex account + * that reads ~/.codex/auth.json among them), else the in-memory account of the record's login. + */ +export function sessionAccountName(record: AiProxySessionRecord, agent: SessionAgent): string | undefined { + const proxy = record[SESSION_ACCOUNT_FIELD[agent]]; + + if (proxy) { + return proxy; + } + + const login = record[SESSION_LOGIN_FIELD[agent]]; + return login ? loginAccountName(login) : undefined; +} + +/** Accounts this session may route to. */ +export function sessionProxyAccounts(record: AiProxySessionRecord): string[] { + return (Object.keys(SESSION_ACCOUNT_FIELD) as SessionAgent[]) + .map((agent) => sessionAccountName(record, agent)) + .filter((name): name is string => typeof name === "string" && name.length > 0); +} + +export function sessionsDir(): string { + return join(getAiProxyStorage().getBaseDir(), "sessions"); +} + +/** Ids become file names: a Claude Code UUID, or anything else made of safe characters. */ +function assertSafeId(id: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) { + throw new Error(`invalid session id "${id}"`); + } +} + +function recordPath(id: string): string { + assertSafeId(id); + return join(sessionsDir(), `${id}.json`); +} + +export function isSessionKey(token: string | null | undefined): token is string { + return typeof token === "string" && token.startsWith(SESSION_KEY_PREFIX); +} + +export function newSessionKey(): string { + return `${SESSION_KEY_PREFIX}${randomBytes(32).toString("base64url")}`; +} + +export function hashSessionKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +function isSessionRecord(value: unknown): value is AiProxySessionRecord { + return ( + isJsonObject(value) && + value.version === 1 && + typeof value.id === "string" && + typeof value.keyHash === "string" && + typeof value.createdAt === "string" && + Array.isArray(value.moves) && + Array.isArray(value.meteredConfirmed) + ); +} + +function ensureSessionsDir(): string { + const dir = sessionsDir(); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: SESSION_DIR_MODE }); + } + + // mkdir's mode is masked by the umask, and an older folder may be wider: set it outright. + chmodSync(dir, SESSION_DIR_MODE); + return dir; +} + +export function writeSessionRecord(record: AiProxySessionRecord): void { + ensureSessionsDir(); + atomicWriteFileSync(recordPath(record.id), `${SafeJSON.stringify(record, null, 2)}\n`, { + mode: SESSION_RECORD_FILE_MODE, + }); +} + +export function readSessionRecord(id: string): AiProxySessionRecord | undefined { + const path = recordPath(id); + + if (!existsSync(path)) { + return undefined; + } + + try { + const parsed = SafeJSON.parse(readFileSync(path, "utf8"), { strict: true }); + + if (isSessionRecord(parsed)) { + return parsed; + } + + logger.warn({ path }, "ai-proxy: session record has an unknown shape, ignored"); + } catch (err) { + logger.warn({ path, err }, "ai-proxy: session record is unreadable, ignored"); + } + + return undefined; +} + +export function listSessionRecords(): AiProxySessionRecord[] { + const dir = sessionsDir(); + + if (!existsSync(dir)) { + return []; + } + + const records: AiProxySessionRecord[] = []; + + for (const name of readdirSync(dir)) { + if (!name.endsWith(".json")) { + continue; + } + + const record = readSessionRecord(name.slice(0, -".json".length)); + + if (record) { + records.push(record); + } + } + + return records; +} + +/** Make a record and its key. The key is returned once and never stored. */ +export function createSessionRecord(input: NewSessionRecord): { record: AiProxySessionRecord; key: string } { + if (readSessionRecord(input.id)) { + throw new Error(`session ${input.id} already has a record`); + } + + const key = newSessionKey(); + const record: AiProxySessionRecord = { + ...input, + version: 1, + keyHash: hashSessionKey(key), + createdAt: new Date().toISOString(), + moves: input.moves ?? [], + meteredConfirmed: input.meteredConfirmed ?? [], + }; + + writeSessionRecord(record); + sessionKeyIndex.remember(record); + return { record, key }; +} + +/** + * A launch's record: a new one, or, on `--resume` (Claude Code keeps the session id), the same record reopened with a + * fresh key and this launch's accounts and settings. The old key stops working. `createdAt`, `moves` and earlier + * metered confirmations stay. + */ +export function openSessionRecord(input: NewSessionRecord): { + record: AiProxySessionRecord; + key: string; + reopened: boolean; +} { + const existing = readSessionRecord(input.id); + + if (!existing) { + return { ...createSessionRecord(input), reopened: false }; + } + + const key = newSessionKey(); + const confirmed = [...existing.meteredConfirmed, ...(input.meteredConfirmed ?? [])]; + const record: AiProxySessionRecord = { + ...existing, + ...input, + version: 1, + keyHash: hashSessionKey(key), + createdAt: existing.createdAt, + closedAt: undefined, + closeReason: undefined, + moves: existing.moves, + meteredConfirmed: confirmed.filter( + (entry, index) => confirmed.findIndex((other) => other.account === entry.account) === index + ), + }; + + writeSessionRecord(record); + sessionKeyIndex.remember(record); + return { record, key, reopened: true }; +} + +/** Read, change and write one record; undefined when it does not exist. */ +export function updateSessionRecord( + id: string, + change: (record: AiProxySessionRecord) => void +): AiProxySessionRecord | undefined { + const record = readSessionRecord(id); + + if (!record) { + return undefined; + } + + change(record); + writeSessionRecord(record); + return record; +} + +export function closeSessionRecord( + id: string, + reason: SessionCloseReason = "exited" +): AiProxySessionRecord | undefined { + return updateSessionRecord(id, (record) => { + if (!record.closedAt) { + record.closedAt = new Date().toISOString(); + record.closeReason = reason; + } + }); +} + +/** + * Key hash → record id, so a request costs one map lookup and one small file read (the file, not the map, says + * whether the session is still open). A key the map does not know triggers one rescan of the folder, because the + * launcher writes a new record from another process. + */ +class SessionKeyIndex { + private byHash = new Map<string, string>(); + private scannedDir: string | undefined; + + remember(record: AiProxySessionRecord): void { + this.byHash.set(record.keyHash, record.id); + } + + private rescan(): void { + this.byHash.clear(); + this.scannedDir = sessionsDir(); + + for (const record of listSessionRecords()) { + this.byHash.set(record.keyHash, record.id); + } + } + + resolve(key: string): AiProxySessionRecord | undefined { + const hash = hashSessionKey(key); + + if (this.scannedDir !== sessionsDir()) { + this.rescan(); + } + + let id = this.byHash.get(hash); + + if (!id) { + this.rescan(); + id = this.byHash.get(hash); + } + + const record = id ? readSessionRecord(id) : undefined; + return record?.keyHash === hash ? record : undefined; + } + + reset(): void { + this.byHash.clear(); + this.scannedDir = undefined; + } +} + +const sessionKeyIndex = new SessionKeyIndex(); + +export type SessionKeyResolution = + | { state: "open"; record: AiProxySessionRecord } + | { state: "unknown" } + | { state: "closed"; record: AiProxySessionRecord }; + +export function resolveSessionKey(key: string): SessionKeyResolution { + const record = sessionKeyIndex.resolve(key); + + if (!record) { + return { state: "unknown" }; + } + + return record.closedAt ? { state: "closed", record } : { state: "open", record }; +} + +export function _resetSessionKeyIndexForTest(): void { + sessionKeyIndex.reset(); +} + +export interface ProcessProbe { + alive: (pid: number) => boolean; + startMs: (pid: number) => number | null; +} + +const realProcessProbe: ProcessProbe = { alive: isProcessAlive, startMs: processStartMs }; + +export interface SessionSweepResult { + closed: Array<{ id: string; reason: SessionCloseReason }>; + pruned: string[]; +} + +/** + * Close open records whose Claude process is gone (or whose pid now belongs to a newer process), and delete closed + * records older than the retention. A launcher that exits normally closes its own record; this catches a crash. + */ +export function sweepSessionRecords( + options: { retentionDays?: number; now?: number; probe?: ProcessProbe } = {} +): SessionSweepResult { + const now = options.now ?? Date.now(); + const probe = options.probe ?? realProcessProbe; + const retentionMs = (options.retentionDays ?? DEFAULT_SESSION_RETENTION_DAYS) * 86_400_000; + const result: SessionSweepResult = { closed: [], pruned: [] }; + + for (const record of listSessionRecords()) { + if (record.closedAt) { + if (now - Date.parse(record.closedAt) > retentionMs) { + unlinkSync(recordPath(record.id)); + result.pruned.push(record.id); + } + + continue; + } + + const pid = record.claudePid ?? record.launcherPid; + + if (pid === undefined) { + continue; + } + + let reason: SessionCloseReason | undefined; + + if (!probe.alive(pid)) { + reason = "process-gone"; + } else if (record.claudePid !== undefined && record.claudePidStartMs !== undefined) { + const startMs = probe.startMs(record.claudePid); + + if (startMs !== null && Math.abs(startMs - record.claudePidStartMs) > START_MS_TOLERANCE) { + reason = "pid-recycled"; + } + } + + if (reason) { + closeSessionRecord(record.id, reason); + result.closed.push({ id: record.id, reason }); + } + } + + if (result.closed.length > 0 || result.pruned.length > 0) { + logger.info( + { closed: result.closed, pruned: result.pruned.length, dir: sessionsDir() }, + "ai-proxy: session sweep" + ); + } + + return result; +} + +/** lastSeenAt is written at most this often, so a busy session does not rewrite its record on every request. */ +const LAST_SEEN_WRITE_MS = 60_000; +const warnedMismatches = new Set<string>(); + +/** + * Called for every request of a session with the session id Claude Code put in `metadata.user_id`. A fork names + * its id here once; any other difference is logged (once per pair) and never re-keys the session. Returns the + * record the request should be tagged with. + */ +export function noteSessionRequest( + record: AiProxySessionRecord, + claudeSessionId: string | undefined, + now = Date.now() +): AiProxySessionRecord { + if (record.forkPending && !record.claudeSessionId && claudeSessionId) { + return ( + updateSessionRecord(record.id, (current) => { + current.claudeSessionId = claudeSessionId; + current.forkPending = false; + current.lastSeenAt = new Date(now).toISOString(); + }) ?? record + ); + } + + if (claudeSessionId && claudeSessionId !== sessionTagId(record)) { + const pair = `${record.id}:${claudeSessionId}`; + + if (!warnedMismatches.has(pair)) { + warnedMismatches.add(pair); + logger.warn( + { session: record.id, expected: sessionTagId(record), claudeSessionId }, + "ai-proxy: a session key's request carries another Claude Code session id; tagged with the record's" + ); + } + } + + const lastSeen = record.lastSeenAt ? Date.parse(record.lastSeenAt) : 0; + + if (now - lastSeen >= LAST_SEEN_WRITE_MS) { + return ( + updateSessionRecord(record.id, (current) => { + current.lastSeenAt = new Date(now).toISOString(); + }) ?? record + ); + } + + return record; +} diff --git a/src/ai-proxy/lib/sessions/routing.ts b/src/ai-proxy/lib/sessions/routing.ts new file mode 100644 index 0000000000..f4dee5ebea --- /dev/null +++ b/src/ai-proxy/lib/sessions/routing.ts @@ -0,0 +1,109 @@ +import { splitReasoningEffortSuffix } from "@app/ai-proxy/lib/resolve-model"; +import { + type AiProxySessionRecord, + loginAccountName, + SESSION_ACCOUNT_FIELD, + SESSION_LOGIN_FIELD, + type SessionAgent, +} from "@app/ai-proxy/lib/sessions/records"; +import { agentOf } from "@app/ai-proxy/lib/sessions/view"; +import type { AiProxyAccountConfig, ResolvedRoute } from "@app/ai-proxy/lib/types"; + +/** The agents a session names by a prefix (`codex/<id>`, `grok/<id>`); a Claude model is named by its bare id. */ +const PREFIXED_AGENTS: readonly SessionAgent[] = ["codex", "grok"]; + +/** Claude Code strips its `[1m]` window suffix before it sends a request; another client may not. */ +const ONE_M_SUFFIX = /\[1m\]$/i; + +/** + * The agent a short model id names inside a session: `codex/<id>` and `grok/<id>` by their prefix, a bare + * `claude-*` id by its name (P5). Every other id (a full `account/provider/model`, an `@account/…` ref) is not a + * session form and resolves as for any client. + */ +export function sessionModelAgent(modelId: string): { agent: SessionAgent; upstreamId: string } | undefined { + const slash = modelId.indexOf("/"); + + if (slash === -1) { + return /^claude-/i.test(modelId) + ? { agent: "claude", upstreamId: modelId.replace(ONE_M_SUFFIX, "") } + : undefined; + } + + const prefix = modelId.slice(0, slash); + const upstreamId = modelId.slice(slash + 1); + const agent = PREFIXED_AGENTS.find((name) => name === prefix); + + return agent && upstreamId && !upstreamId.includes("/") ? { agent, upstreamId } : undefined; +} + +export type SessionRouteResult = { route: ResolvedRoute } | { refusal: string }; + +/** + * A session's route by model name: `codex/<id>[:effort]` to the record's Codex account, a bare `claude-*` id to its + * Claude account, `grok/<id>` to its Grok account. Undefined for an id that is not a session form. The account + * check that follows (`sessionRouteDenial`) still applies to every route. + * + * The account is the configured proxy account the record names (the legacy Codex account that reads + * ~/.codex/auth.json among them), else an account built in memory for the record's login (❓ DECISION 1 a, Martin + * 2026-10-25): never written to `ai-proxy/config.json`, and with no failover list, so a session route never moves. + */ +export function resolveSessionRoute( + proxyModelId: string, + record: AiProxySessionRecord, + accounts: AiProxyAccountConfig[] +): SessionRouteResult | undefined { + const { modelId, reasoningEffort } = splitReasoningEffortSuffix(proxyModelId.trim()); + const form = sessionModelAgent(modelId); + + if (!form) { + return undefined; + } + + const name = record[SESSION_ACCOUNT_FIELD[form.agent]]; + const login = record[SESSION_LOGIN_FIELD[form.agent]]; + + if (!name && !login) { + return { refusal: `session ${record.id} has no ${form.agent} account, so it cannot run '${proxyModelId}'` }; + } + + const account = name ? accounts.find((item) => item.name === name) : loginAccount(form.agent, login ?? ""); + + if (!account?.enabled) { + return { + refusal: `the ${form.agent} account "${name}" of session ${record.id} is ${account ? "disabled" : "not in the ai-proxy config"}`, + }; + } + + if (agentOf(account.provider) !== form.agent) { + return { + refusal: `the ${form.agent} account "${name}" of session ${record.id} is a ${account.provider} account`, + }; + } + + return { + route: { + accountName: account.name, + providerSlug: account.providerSlug, + upstreamId: form.upstreamId, + account, + ...(reasoningEffort ? { reasoningEffort, effortSource: "suffix" as const } : {}), + }, + }; +} + +/** The account the proxy builds for a login: the same shape a configured subscription account of that agent has. */ +export function loginAccount(agent: SessionAgent, login: string): AiProxyAccountConfig { + const base = { name: loginAccountName(login), enabled: true }; + const shapes: Record<SessionAgent, AiProxyAccountConfig> = { + codex: { ...base, provider: "openai-subscription", providerSlug: "codex", openaiSub: { accountName: login } }, + claude: { + ...base, + provider: "anthropic-subscription", + providerSlug: "claude-sub", + anthropicSub: { accountName: login }, + }, + grok: { ...base, provider: "grok-subscription", providerSlug: "grok", grok: { accountName: login } }, + }; + + return shapes[agent]; +} diff --git a/src/ai-proxy/lib/sessions/tags.ts b/src/ai-proxy/lib/sessions/tags.ts new file mode 100644 index 0000000000..6e1a118f1e --- /dev/null +++ b/src/ai-proxy/lib/sessions/tags.ts @@ -0,0 +1,44 @@ +import { type AiProxySessionRecord, sessionTagId } from "@app/ai-proxy/lib/sessions/records"; +import { readRequestTags } from "@app/ai-proxy/lib/usage/transcripts"; +import type { RequestTags } from "@app/ai-proxy/lib/usage/types"; + +/** Headers a client uses to name its own conversation: codex-rs (`session-id`, `thread-id`), ai-proxy's older Codex + * shape (`session_id`) and Grok (`x-grok-conv-id`). */ +const CONVERSATION_HEADERS = ["session-id", "session_id", "thread-id", "x-grok-conv-id"] as const; + +/** + * The tags a request is booked under. A session key's requests carry the session's id, whatever header the client + * sends. Any other client keeps its `x-gt-*` headers, and when it names no `x-gt-session`, the conversation id it + * sent on its own (Claude Code's `metadata.user_id` session id, a body `prompt_cache_key`, or a conversation header) + * becomes `tags.session`, so its calls and its transcript file line up with the client's own session. + */ +export function requestTagsFor( + headers: Headers, + session: AiProxySessionRecord | undefined, + conversationId: string | undefined +): RequestTags | undefined { + const tags = readRequestTags(headers); + + if (session) { + return { ...tags, session: sessionTagId(session) }; + } + + if (tags?.session) { + return tags; + } + + const own = conversationId ?? conversationIdFromHeaders(headers); + return own ? { ...tags, session: own } : tags; +} + +function conversationIdFromHeaders(headers: Headers): string | undefined { + for (const name of CONVERSATION_HEADERS) { + const value = headers.get(name)?.trim(); + + if (value) { + return value; + } + } + + return undefined; +} diff --git a/src/ai-proxy/lib/sessions/view.ts b/src/ai-proxy/lib/sessions/view.ts new file mode 100644 index 0000000000..ed6d10c0b3 --- /dev/null +++ b/src/ai-proxy/lib/sessions/view.ts @@ -0,0 +1,332 @@ +/** + * What `tools ai-proxy sessions`, `session <id>` and `stats` show: one row per proxied request, read from the request + * index (`usage/requests.jsonl`), with the cache numbers split once (`splitPromptTokens`) and the cache math shared + * with session-analyzer (`@genesiscz/utils/ai/usage/cache-stats`). + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { splitReasoningEffortSuffix } from "@app/ai-proxy/lib/resolve-model"; +import { getAiProxyStorage } from "@app/ai-proxy/lib/storage"; +import { normalizeUsage } from "@app/ai-proxy/lib/usage/extract"; +import type { TokenUsage, UsageRequestRecord } from "@app/ai-proxy/lib/usage/types"; +import { + type CacheCall, + type CacheLoss, + type CacheSummary, + findCacheLosses, + hitPercent, + splitPromptTokens, + summarizeCache, +} from "@genesiscz/utils/ai/usage/cache-stats"; +import { readLinesBackwardSync, readLinesSync } from "@genesiscz/utils/fs/read-lines"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; + +/** The upstream routes through ai-proxy (Martin's word "agent"); not the coding-agent harness of a session. */ +export const PROXY_AGENTS = ["codex", "claude", "grok", "other"] as const; +export type ProxyAgent = (typeof PROXY_AGENTS)[number]; + +function isProxyAgent(value: string): value is ProxyAgent { + return (PROXY_AGENTS as readonly string[]).includes(value); +} + +const AGENT_BY_PROVIDER: Record<string, ProxyAgent> = { + "openai-subscription": "codex", + "anthropic-subscription": "claude", + "grok-subscription": "grok", + "xai-api-key": "grok", +}; + +export function agentOf(provider: string): ProxyAgent { + return AGENT_BY_PROVIDER[provider] ?? "other"; +} + +/** + * The agent a model id in Claude Code's own transcript names: Claude Code writes the id it sent, so a proxied Codex + * or Grok model keeps its proxy id (`codex/codex/gpt-6.1-sol`, `martin/grok/grok-4.6`). + */ +export function agentOfModel(model: string): ProxyAgent { + const id = model.toLowerCase(); + + if (id.includes("grok")) { + return "grok"; + } + + if (/(^|\/)(gpt-|codex|o\d)|(^|[/-])(sol|astra|luna|terra)\b|openai/.test(id)) { + return "codex"; + } + + return id.includes("claude") || ["opus", "sonnet", "haiku", "fable"].some((alias) => id.includes(alias)) + ? "claude" + : "other"; +} + +/** `--agent anthropic` and `--agent claude` name the same route. */ +export function parseAgent(value: string | undefined): ProxyAgent | undefined { + if (value === undefined) { + return undefined; + } + + const agent = value === "anthropic" ? "claude" : value; + + if (isProxyAgent(agent)) { + return agent; + } + + throw new Error(`unknown agent "${value}" (codex, claude or anthropic, grok, other)`); +} + +export interface RequestRow extends CacheCall { + ts: string; + agent: ProxyAgent; + proxyModel: string; + effort?: string; + status: number; + failure?: string; + ttfbMs?: number; + elapsedMs: number; + output: number; + reasoning: number; + hit: number | undefined; + /** False when neither the row nor its transcript reports cache numbers (rows booked before 2026-10-25). */ + cacheKnown: boolean; + session?: string; +} + +function hasCacheNumbers(usage: TokenUsage | undefined): boolean { + return ( + usage?.cached_prompt_tokens !== undefined || + usage?.cache_read_input_tokens !== undefined || + usage?.cache_creation_input_tokens !== undefined + ); +} + +export function requestRow(record: UsageRequestRecord, fromTranscript?: TokenUsage): RequestRow { + const usage = hasCacheNumbers(record.usage) ? record.usage : (fromTranscript ?? record.usage); + const split = splitPromptTokens(usage); + const { reasoningEffort } = record.proxyModel + ? splitReasoningEffortSuffix(record.proxyModel) + : { reasoningEffort: undefined }; + + return { + t: Date.parse(record.ts), + ts: record.ts, + agent: agentOf(record.provider), + model: record.upstreamModel, + proxyModel: record.proxyModel, + effort: record.effort ?? reasoningEffort, + status: record.status, + failure: record.failure, + ttfbMs: record.timeline?.firstByteMs, + elapsedMs: record.elapsedMs, + input: split.uncached, + cacheRead: split.cacheRead, + cacheWrite: split.cacheWrite, + output: usage?.completion_tokens ?? 0, + reasoning: usage?.reasoning_tokens ?? 0, + hit: hitPercent(split), + cacheKnown: hasCacheNumbers(usage), + key: record.cacheKey, + compaction: record.compaction, + session: record.tags?.session, + }; +} + +function requestsPath(): string { + return join(getAiProxyStorage().getBaseDir(), "usage", "requests.jsonl"); +} + +/** + * Index rows newest first back to `sinceMs`, kept when `keep` says so, returned oldest first. The index is + * append-only and chronological, so the read stops at the first row older than the cutoff. + */ +export function readRequestRecords( + sinceMs: number, + keep: (record: UsageRequestRecord) => boolean = () => true, + path = requestsPath() +): UsageRequestRecord[] { + if (!existsSync(path)) { + return []; + } + + const kept: UsageRequestRecord[] = []; + + for (const line of readLinesBackwardSync(path)) { + if (!line.trim()) { + continue; + } + + let record: UsageRequestRecord; + try { + record = SafeJSON.parse(line, { strict: true }) as UsageRequestRecord; + } catch (err) { + logger.debug({ err }, "ai-proxy sessions: skipped an unreadable index line"); + continue; + } + + if (Date.parse(record.ts) < sinceMs) { + break; + } + + if (keep(record)) { + kept.push(record); + } + } + + return kept.reverse(); +} + +/** The transcript file of a row: the recorded path, or `<path>.zst` once the daily pass compressed its day. */ +export function transcriptOnDisk(file: string): string | undefined { + if (existsSync(file)) { + return file; + } + + return existsSync(`${file}.zst`) ? `${file}.zst` : undefined; +} + +/** + * Upstream usage for rows booked before the ledger kept cache numbers: the transcript entry of each such row carries + * the upstream usage as it came (`prompt_tokens_details.cached_tokens`). One read per transcript file, plain or + * compressed, for every day folder a session spans. + */ +export function usageFromTranscripts(records: UsageRequestRecord[]): Map<string, TokenUsage> { + const wanted = new Map<string, Set<string>>(); + + for (const record of records) { + if (hasCacheNumbers(record.usage) || !record.transcript) { + continue; + } + + const uuids = wanted.get(record.transcript.file) ?? new Set<string>(); + uuids.add(record.transcript.uuid); + wanted.set(record.transcript.file, uuids); + } + + const found = new Map<string, TokenUsage>(); + + for (const [file, uuids] of wanted) { + const path = transcriptOnDisk(file); + + if (!path) { + continue; + } + + try { + for (const line of readLinesSync(path)) { + if (!line.includes('"uuid"')) { + continue; + } + + const entry = SafeJSON.parse(line) as { uuid?: string; message?: { usage?: unknown } }; + + if (entry.uuid && uuids.has(entry.uuid)) { + const usage = normalizeUsage(entry.message?.usage); + + if (usage) { + found.set(entry.uuid, usage); + } + } + } + } catch (err) { + logger.debug({ err, path }, "ai-proxy sessions: transcript unreadable, its rows keep the ledger numbers"); + } + } + + return found; +} + +export function requestRows(records: UsageRequestRecord[]): RequestRow[] { + const fromTranscripts = usageFromTranscripts(records); + return records.map((record) => + requestRow(record, record.transcript ? fromTranscripts.get(record.transcript.uuid) : undefined) + ); +} + +/** A thread that mixes several subagents (Claude Code transcripts do not say which one wrote a row). */ +export const MIXED_SUBAGENT_THREAD = ":subagents"; + +/** + * Losses inside each session, never across two sessions. Rows without a session are left out, and so is a thread of + * mixed subagents: one subagent after another is no cache loss. + */ +export function lossesBySession(rows: RequestRow[]): Array<CacheLoss & { session?: string; row: RequestRow }> { + const bySession = new Map<string, RequestRow[]>(); + + for (const row of rows) { + if (!row.session || !row.cacheKnown || row.session.endsWith(MIXED_SUBAGENT_THREAD)) { + continue; + } + + const list = bySession.get(row.session) ?? []; + list.push(row); + bySession.set(row.session, list); + } + + return [...bySession].flatMap(([session, list]) => + findCacheLosses(list).map((loss) => ({ ...loss, session, row: list[loss.call] })) + ); +} + +export interface AgentStats extends CacheSummary { + agent: ProxyAgent; + sessions: number; + reasoning: number; + /** Rows without cache numbers, left out of the hit %. */ + unknownCache: number; + errors: number; + /** Median of the per-session hit %. */ + sessionMedian: number | undefined; + lossesByCause: Record<CacheLoss["cause"], number>; +} + +function median(values: number[]): number | undefined { + if (values.length === 0) { + return undefined; + } + + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export function statsByAgent(rows: RequestRow[]): AgentStats[] { + const agents = [...new Set(rows.map((row) => row.agent))].sort(); + + return agents.map((agent) => { + const mine = rows.filter((row) => row.agent === agent); + const known = mine.filter((row) => row.cacheKnown); + const losses = lossesBySession(mine); + const lossesByCause: AgentStats["lossesByCause"] = { + "key changed": 0, + "model changed": 0, + compaction: 0, + idle: 0, + unknown: 0, + }; + + for (const loss of losses) { + lossesByCause[loss.cause]++; + } + + const sessionHits = [...new Set(known.map((row) => row.session).filter(Boolean))].flatMap((session) => { + const hit = summarizeCache( + known.filter((row) => row.session === session), + 0 + ).hitPercent; + return hit === undefined ? [] : [hit]; + }); + + return { + agent, + ...summarizeCache(known, losses.length), + requests: mine.length, + sessions: new Set(mine.map((row) => row.session).filter(Boolean)).size, + reasoning: mine.reduce((sum, row) => sum + row.reasoning, 0), + unknownCache: mine.length - known.length, + errors: mine.filter((row) => row.status >= 400 || row.failure).length, + sessionMedian: median(sessionHits), + lossesByCause, + }; + }); +} diff --git a/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.test.ts b/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.test.ts index 6d90173c64..62812b7577 100644 --- a/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.test.ts +++ b/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { anthropicToGrokResponses, + anthropicToResponses, packReasoningSignature, REASONING_SIGNATURE_PREFIX, stripReasoningInput, @@ -201,3 +202,36 @@ describe("stripReasoningInput", () => { expect(body.input).toHaveLength(3); }); }); + +describe("anthropicToResponses: system messages between turns", () => { + const noReplay = { reasoningItem: () => undefined }; + const turn = (messages: unknown[]) => + anthropicToResponses({ system: [{ type: "text", text: "base prompt" }], messages }, "gpt-6.1-sol", noReplay); + + it("keeps each one at its place as a developer message, so instructions and every earlier item stay the same", () => { + const first = [ + { role: "user", content: "hello" }, + { role: "system", content: [{ type: "text", text: "SessionStart hook output" }] }, + ]; + const second = [ + ...first, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + { role: "user", content: "next" }, + { role: "system", content: "UserPromptSubmit hook output" }, + ]; + const one = turn(first); + const two = turn(second); + + expect(one.instructions).toBe("base prompt"); + expect(two.instructions).toBe("base prompt"); + expect((one.input as unknown[])[1]).toEqual({ + role: "developer", + content: [{ type: "input_text", text: "SessionStart hook output" }], + }); + expect((two.input as unknown[]).slice(0, 2)).toEqual(one.input as unknown[]); + expect((two.input as unknown[]).at(-1)).toEqual({ + role: "developer", + content: [{ type: "input_text", text: "UserPromptSubmit hook output" }], + }); + }); +}); diff --git a/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.ts b/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.ts index 592df7acc7..450710fbe7 100644 --- a/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.ts +++ b/src/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses.ts @@ -43,6 +43,33 @@ export function unpackReasoningSignature(signature: unknown): { id: string; encr return { id: rest.slice(0, separator), encryptedContent: rest.slice(separator + 1) }; } +/** What differs per upstream when an Anthropic request becomes a Responses request. */ +export interface ResponsesTranslationHooks { + /** + * The Responses `reasoning` item a thinking block replays, or undefined to drop the block (another provider's + * history, a stale or foreign signature). Items are replayed whole and in their original order. + */ + reasoningItem: (thinking: JsonRecord) => JsonRecord | undefined; +} + +/** Grok: the signature carries the item id and its encrypted content; the thinking text is the summary. */ +export const GROK_RESPONSES_HOOKS: ResponsesTranslationHooks = { + reasoningItem: (thinking) => { + const packed = unpackReasoningSignature(thinking.signature); + + if (packed === undefined) { + return undefined; + } + + return { + type: "reasoning", + id: packed.id, + summary: [{ type: "summary_text", text: String(thinking.thinking ?? "") }], + encrypted_content: packed.encryptedContent, + }; + }, +}; + function systemToInstructions(system: unknown): string | undefined { if (typeof system === "string" && system.length > 0) { return system; @@ -180,7 +207,12 @@ function appendUserMessage(input: JsonRecord[], content: unknown): void { pushUserMessage(input, parts); } -function appendAssistantMessage(input: JsonRecord[], content: unknown, dropped: { thinking: number }): void { +function appendAssistantMessage( + input: JsonRecord[], + content: unknown, + dropped: { thinking: number }, + hooks: ResponsesTranslationHooks +): void { if (typeof content === "string") { if (content.length > 0) { input.push({ role: "assistant", content: [{ type: "output_text", text: content }] }); @@ -201,20 +233,15 @@ function appendAssistantMessage(input: JsonRecord[], content: unknown, dropped: } if (part.type === "thinking") { - const packed = unpackReasoningSignature(part.signature); + const item = hooks.reasoningItem(part); - if (packed === undefined) { + if (item === undefined) { dropped.thinking += 1; continue; } pushAssistantText(input, textParts); - input.push({ - type: "reasoning", - id: packed.id, - summary: [{ type: "summary_text", text: String(part.thinking ?? "") }], - encrypted_content: packed.encryptedContent, - }); + input.push(item); continue; } @@ -301,6 +328,31 @@ export function stripReasoningInput(body: JsonRecord): JsonRecord { } export function anthropicToGrokResponses(body: JsonRecord, model: string): JsonRecord { + return anthropicToResponses(body, model, GROK_RESPONSES_HOOKS); +} + +/** + * A `role: "system"` message in `messages[]`, kept at its place as a developer message (codex-rs sends instructions + * that arrive mid-conversation the same way). Claude Code 2.1.296 sends hook output this way: SessionStart output + * after the first prompt, UserPromptSubmit and tool hook output later. Hoisted into `instructions`, each new one + * changed the instructions, and WHAM's prompt cache (tools, then instructions, then input) kept only the tools. + * The Grok agent still hoists before it translates. + */ +function appendDeveloperMessage(input: JsonRecord[], content: unknown): void { + const parts = + typeof content === "string" + ? [content] + : Array.isArray(content) + ? content.flatMap((block) => (isObject(block) && typeof block.text === "string" ? [block.text] : [])) + : []; + + if (parts.length > 0) { + input.push({ role: "developer", content: parts.map((text) => ({ type: "input_text", text })) }); + } +} + +/** Anthropic Messages request → Responses request, shared by the Grok and Codex agents (hooks: what differs). */ +export function anthropicToResponses(body: JsonRecord, model: string, hooks: ResponsesTranslationHooks): JsonRecord { const input: JsonRecord[] = []; const dropped = { thinking: 0 }; @@ -311,7 +363,9 @@ export function anthropicToGrokResponses(body: JsonRecord, model: string): JsonR } if (message.role === "assistant") { - appendAssistantMessage(input, message.content, dropped); + appendAssistantMessage(input, message.content, dropped, hooks); + } else if (message.role === "system") { + appendDeveloperMessage(input, message.content); } else { appendUserMessage(input, message.content); } @@ -321,7 +375,7 @@ export function anthropicToGrokResponses(body: JsonRecord, model: string): JsonR if (dropped.thinking > 0) { logger.debug( { dropped: dropped.thinking, model }, - "ai-proxy: dropped thinking blocks without a grok reasoning signature" + "ai-proxy: dropped thinking blocks without a replayable reasoning signature" ); } diff --git a/src/ai-proxy/lib/translators/formats/anthropic/claude-code-turns.ts b/src/ai-proxy/lib/translators/formats/anthropic/claude-code-turns.ts new file mode 100644 index 0000000000..6d5ef6aec4 --- /dev/null +++ b/src/ai-proxy/lib/translators/formats/anthropic/claude-code-turns.ts @@ -0,0 +1,91 @@ +import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; + +/** Request shapes Claude Code sends that the proxy treats on their own: the compaction turn and the model probe. */ + +/** + * How Claude Code's compaction prompt opens. It keeps the agent's tools in the request (cache reuse) and forbids them + * in words only. Present in the Claude Code 2.1.296 binary (checked 2026-10-25); switcheroo keys on the same text. + */ +export const CLAUDE_CODE_COMPACTION_MARK = "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools."; + +function textOf(content: unknown): string { + if (typeof content === "string") { + return content; + } + + if (!Array.isArray(content)) { + return ""; + } + + return content + .map((block) => (isObject(block) && block.type === "text" && typeof block.text === "string" ? block.text : "")) + .join("\n"); +} + +/** A Claude Code compaction turn: the request ends on a user message carrying the compaction prompt. */ +export function isClaudeCodeCompactionTurn(body: { messages?: unknown }): boolean { + const last = Array.isArray(body.messages) ? body.messages.at(-1) : undefined; + return isObject(last) && last.role === "user" && textOf(last.content).includes(CLAUDE_CODE_COMPACTION_MARK); +} + +/** + * Claude Code's `model_validation` probe: one user "Hi" at `max_tokens: 1`, sent once when `/model` or `--model` + * names an id it does not know (Claude Code 2.1.296 binary, checked 2026-10-25). It throws on any error, and gives + * up after 15 s with no retry. + */ +export function isClaudeCodeModelProbe(body: { max_tokens?: unknown; messages?: unknown; tools?: unknown }): boolean { + const messages = Array.isArray(body.messages) ? body.messages : []; + const [only] = messages; + + return ( + body.max_tokens === 1 && + messages.length === 1 && + body.tools === undefined && + isObject(only) && + only.role === "user" && + textOf(only.content) === "Hi" + ); +} + +/** The probe's answer: a one-token message, so the probe needs no upstream call (a Codex model ignores the cap). */ +export function modelProbeReply(model: string): Record<string, unknown> { + return { + id: `msg_probe_${crypto.randomUUID().replaceAll("-", "")}`, + type: "message", + role: "assistant", + model, + content: [{ type: "text", text: "Hi" }], + stop_reason: "max_tokens", + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 1 }, + }; +} + +/** Claude Code's subagent tool. */ +export const CLAUDE_CODE_AGENT_TOOL = "Agent"; + +/** + * An Agent call's `model` beats the subagent definition's model in Claude Code, and a main loop that follows a + * "set model on every Agent call" rule from CLAUDE.md picks a Claude alias: a `sol` subagent ran on Sonnet + * (2026-10-25). For the subagents a session defined with their own model, the call's `model` is dropped, so the + * definition decides. Every other subagent keeps the model the main loop chose. + */ +export function sessionAgentModelWins(agents: ReadonlySet<string>): (input: unknown) => unknown { + return (input) => { + if (!isObject(input) || typeof input.subagent_type !== "string" || !agents.has(input.subagent_type)) { + return input; + } + + if (input.model === undefined) { + return input; + } + + const { model, ...rest } = input; + logger.info( + { subagent: input.subagent_type, model }, + "ai-proxy: dropped the Agent call's model, the session's subagent runs on its own" + ); + return rest; + }; +} diff --git a/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.test.ts b/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.test.ts index 588b19880e..f580ff41ef 100644 --- a/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.test.ts +++ b/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.test.ts @@ -3,9 +3,15 @@ import { packReasoningSignature, unpackReasoningSignature, } from "@app/ai-proxy/lib/translators/formats/anthropic/anthropic-to-responses"; +import { + CLAUDE_CODE_AGENT_TOOL, + sessionAgentModelWins, +} from "@app/ai-proxy/lib/translators/formats/anthropic/claude-code-turns"; import { grokResponsesSseToAnthropic, grokResponsesToAnthropicMessage, + responsesSseToAnthropic, + responsesToAnthropicMessage, } from "@app/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic"; import { SafeJSON } from "@genesiscz/utils/json"; @@ -359,3 +365,118 @@ describe("grokResponsesToAnthropicMessage", () => { expect(message.stop_reason).toBe("max_tokens"); }); }); + +describe("a message never ends on a thinking block (Claude Code drops it, and the reasoning with it)", () => { + const reasoning = { + type: "reasoning", + id: "rs_only", + summary: [{ type: "summary_text", text: "cut off while thinking" }], + encrypted_content: "blob", + }; + + it("streaming: a reasoning-only answer gets an empty text block after the thinking block", async () => { + const { frames } = await collect( + grokResponsesSseToAnthropic( + sse([ + { type: "response.output_item.added", output_index: 0, item: { ...reasoning, summary: [] } }, + { type: "response.output_item.done", output_index: 0, item: reasoning }, + { type: "response.incomplete", response: { status: "incomplete", output: [reasoning] } }, + ]), + { model: "grok-x" } + ) + ); + const starts = frames + .filter((frame) => frame.event === "content_block_start") + .map((frame) => (frame.data.content_block as Record<string, unknown>).type); + + expect(starts).toEqual(["thinking", "text"]); + }); + + it("non-streaming: the same", () => { + const message = grokResponsesToAnthropicMessage( + { status: "incomplete", output: [reasoning] }, + { model: "grok-x" } + ); + + expect((message.content as Array<{ type: string }>).map((block) => block.type)).toEqual(["thinking", "text"]); + }); +}); + +describe("a session's own subagents run on their own model", () => { + const toolInput = (name: string) => + name === CLAUDE_CODE_AGENT_TOOL ? sessionAgentModelWins(new Set(["sol", "opus"])) : undefined; + + function agentCall(id: string, outputIndex: number, name: string, args: Record<string, unknown>) { + const json = SafeJSON.stringify(args); + const item = { type: "function_call", id, call_id: `call_${id}`, name, arguments: "" }; + + return [ + { type: "response.output_item.added", output_index: outputIndex, item }, + { type: "response.function_call_arguments.delta", output_index: outputIndex, delta: json.slice(0, 20) }, + { type: "response.function_call_arguments.delta", output_index: outputIndex, delta: json.slice(20) }, + { type: "response.output_item.done", output_index: outputIndex, item: { ...item, arguments: json } }, + ]; + } + + function inputs(frames: Frame[]): Record<string, unknown>[] { + const byIndex = new Map<number, string>(); + + for (const frame of frames) { + const delta = frame.data.delta as Record<string, unknown> | undefined; + + if (frame.event === "content_block_delta" && delta?.type === "input_json_delta") { + const index = Number(frame.data.index); + byIndex.set(index, (byIndex.get(index) ?? "") + String(delta.partial_json)); + } + } + + return [...byIndex.values()].map((json) => SafeJSON.parse(json, { strict: true }) as Record<string, unknown>); + } + + it("streaming: drops the Agent call's model for a session subagent, keeps it for others and leaves other tools alone", async () => { + const { frames } = await collect( + responsesSseToAnthropic( + sse([ + { type: "response.created", response: { id: "resp_1" } }, + ...agentCall("a", 0, "Agent", { prompt: "go", subagent_type: "sol", model: "sonnet" }), + ...agentCall("b", 1, "Agent", { prompt: "go", subagent_type: "Explore", model: "haiku" }), + ...agentCall("c", 2, "Bash", { command: "echo ok", model: "kept" }), + { + type: "response.completed", + response: { id: "resp_1", usage: { input_tokens: 1, output_tokens: 1 } }, + }, + ]), + { model: "gpt-6.1-sol", label: "codex", toolInput } + ) + ); + + expect(inputs(frames)).toEqual([ + { prompt: "go", subagent_type: "sol" }, + { prompt: "go", subagent_type: "Explore", model: "haiku" }, + { command: "echo ok", model: "kept" }, + ]); + }); + + it("non-streaming: the same", () => { + const message = responsesToAnthropicMessage( + { + id: "resp_1", + status: "completed", + output: [ + { + type: "function_call", + call_id: "call_a", + name: "Agent", + arguments: SafeJSON.stringify({ prompt: "go", subagent_type: "opus", model: "opus" }), + }, + ], + }, + { model: "gpt-6.1-sol", label: "codex", toolInput } + ); + + expect((message.content as Array<Record<string, unknown>>)[0]?.input).toEqual({ + prompt: "go", + subagent_type: "opus", + }); + }); +}); diff --git a/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.ts b/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.ts index 5b81687d91..40df7a0496 100644 --- a/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.ts +++ b/src/ai-proxy/lib/translators/formats/anthropic/grok-responses-to-anthropic.ts @@ -68,7 +68,8 @@ function reasoningSummaryText(item: JsonRecord): string { .join("\n\n"); } -function reasoningSignature(item: JsonRecord): string { +/** Grok's signature: the item id and its encrypted content; empty when the item carries none. */ +function grokReasoningSignature(item: JsonRecord): string { if (typeof item.encrypted_content !== "string" || item.encrypted_content.length === 0) { return ""; } @@ -77,6 +78,40 @@ function reasoningSignature(item: JsonRecord): string { return packReasoningSignature(id, item.encrypted_content); } +/** What differs per upstream when a Responses output becomes an Anthropic message. */ +export interface ResponsesToAnthropicOptions { + model: string; + /** The thinking block's signature for a reasoning item (default: Grok's `grokrs1:`). */ + signReasoning?: (item: JsonRecord) => string; + /** Names the upstream in log lines. */ + label?: string; + /** + * A rewrite of one tool's input, chosen by tool name. That call's arguments are held until it is complete and + * then sent once, rewritten; every other call streams as it arrives. + */ + toolInput?: (name: string) => ((input: unknown) => unknown) | undefined; +} + +const warnedItemTypes = new Set<string>(); + +function rewriteToolInput(options: ResponsesToAnthropicOptions, name: unknown, input: unknown): unknown { + const rewrite = typeof name === "string" ? options.toolInput?.(name) : undefined; + return rewrite ? rewrite(input) : input; +} + +/** An output item no content block exists for (agent items, new types): logged once per type, the turn goes on. */ +function warnUnknownItem(item: JsonRecord, label: string): void { + const type = typeof item.type === "string" ? item.type : "(none)"; + + if (!warnedItemTypes.has(type)) { + warnedItemTypes.add(type); + logger.warn( + { type, upstream: label }, + "ai-proxy: skipped a Responses output item type with no Anthropic block" + ); + } +} + function messageText(item: JsonRecord): string { if (!Array.isArray(item.content)) { return ""; @@ -99,7 +134,15 @@ function stopReasonFor(response: JsonRecord, sawFunctionCall: boolean): Anthropi } /** Non-streaming: a Responses envelope → an Anthropic message object. */ -export function grokResponsesToAnthropicMessage(response: JsonRecord, options: { model: string }): JsonRecord { +export function grokResponsesToAnthropicMessage( + response: JsonRecord, + options: ResponsesToAnthropicOptions +): JsonRecord { + return responsesToAnthropicMessage(response, options); +} + +export function responsesToAnthropicMessage(response: JsonRecord, options: ResponsesToAnthropicOptions): JsonRecord { + const sign = options.signReasoning ?? grokReasoningSignature; const output = Array.isArray(response.output) ? response.output : []; const content: JsonRecord[] = []; @@ -112,7 +155,7 @@ export function grokResponsesToAnthropicMessage(response: JsonRecord, options: { content.push({ type: "thinking", thinking: reasoningSummaryText(item), - signature: reasoningSignature(item), + signature: sign(item), }); continue; } @@ -127,12 +170,16 @@ export function grokResponsesToAnthropicMessage(response: JsonRecord, options: { type: "tool_use", id: typeof item.call_id === "string" ? item.call_id : `toolu_${crypto.randomUUID()}`, name: typeof item.name === "string" ? item.name : "unknown", - input: parsedToolInput(item.arguments), + input: rewriteToolInput(options, item.name, parsedToolInput(item.arguments)), }); + continue; } + + warnUnknownItem(item, options.label ?? "grok"); } - if (content.length === 0) { + // Claude Code drops a thinking block that ends a message, and with it the reasoning a next turn would replay. + if (content.length === 0 || content.at(-1)?.type === "thinking") { content.push({ type: "text", text: "" }); } @@ -158,15 +205,23 @@ interface ItemState { streamedChars: number; summaryParts: number; closed: boolean; + /** Set for a tool call whose input is rewritten: its arguments are held in `heldArgs`. */ + rewrite?: (input: unknown) => unknown; + heldArgs: string; } interface StreamState { model: string; + sign: (item: JsonRecord) => string; + label: string; + /** The kind of the last block opened, so a message never ends on a thinking block. */ + lastKind: BlockKind | undefined; messageId: string; started: boolean; nextBlockIndex: number; /** Responses output_index → block state. Ignored item types are absent. */ items: Map<number, ItemState>; + toolInput: ResponsesToAnthropicOptions["toolInput"]; sawFunctionCall: boolean; usage: AnthropicUsage | null; outputChars: number; @@ -222,16 +277,23 @@ function openItem(state: StreamState, outputIndex: number, item: JsonRecord): st const mapped = contentBlockFor(item); if (mapped === null) { + warnUnknownItem(item, state.label); return ""; } const blockIndex = state.nextBlockIndex++; + state.lastKind = mapped.kind; state.items.set(outputIndex, { blockIndex, kind: mapped.kind, streamedChars: 0, summaryParts: 0, closed: false, + rewrite: + mapped.kind === "tool_use" && typeof mapped.block.name === "string" + ? state.toolInput?.(mapped.block.name) + : undefined, + heldArgs: "", }); if (mapped.kind === "tool_use") { @@ -265,6 +327,15 @@ function closeItem(state: StreamState, outputIndex: number, doneItem: JsonRecord let out = ""; + if (item.rewrite) { + const raw = + typeof doneItem?.arguments === "string" && doneItem.arguments.length > 0 + ? doneItem.arguments + : item.heldArgs; + const json = SafeJSON.stringify(item.rewrite(parsedToolInput(raw))); + out += deltaFrame(state, outputIndex, { type: "input_json_delta", partial_json: json }, json.length); + } + if (doneItem !== undefined) { // The done frame carries the complete item; anything the stream never // delivered as deltas is salvaged here. @@ -275,7 +346,7 @@ function closeItem(state: StreamState, outputIndex: number, doneItem: JsonRecord out += deltaFrame(state, outputIndex, { type: "thinking_delta", thinking: summary }, summary.length); } - const signature = reasoningSignature(doneItem); + const signature = state.sign(doneItem); if (signature.length > 0) { out += anthropicSseFrame("content_block_delta", { @@ -342,6 +413,14 @@ function finishFrames(state: StreamState, response: JsonRecord | undefined): str } } + // Claude Code drops a thinking block that ends a message, and with it the reasoning a next turn would replay. + if (state.lastKind === "thinking") { + const index = state.nextBlockIndex++; + state.lastKind = "text"; + out += anthropicSseFrame("content_block_start", { index, content_block: { type: "text", text: "" } }); + out += anthropicSseFrame("content_block_stop", { index }); + } + if (response !== undefined) { state.usage = usageFromResponses(response.usage) ?? state.usage; } @@ -394,6 +473,13 @@ function handleEvent(state: StreamState, event: JsonRecord): string { } if (type === "response.function_call_arguments.delta" && typeof event.delta === "string") { + const held = state.items.get(outputIndex); + + if (held?.rewrite) { + held.heldArgs += event.delta; + return ""; + } + return deltaFrame( state, outputIndex, @@ -424,14 +510,26 @@ function handleEvent(state: StreamState, event: JsonRecord): string { /** Streaming: a Responses SSE body → an Anthropic SSE body. */ export function grokResponsesSseToAnthropic( upstream: ReadableStream<Uint8Array>, - options: { model: string } + options: ResponsesToAnthropicOptions +): ReadableStream<Uint8Array> { + return responsesSseToAnthropic(upstream, options); +} + +export function responsesSseToAnthropic( + upstream: ReadableStream<Uint8Array>, + options: ResponsesToAnthropicOptions ): ReadableStream<Uint8Array> { + const label = options.label ?? "grok"; const state: StreamState = { model: options.model, + sign: options.signReasoning ?? grokReasoningSignature, + label, + lastKind: undefined, messageId: `msg_${crypto.randomUUID()}`, started: false, nextBlockIndex: 0, items: new Map(), + toolInput: options.toolInput, sawFunctionCall: false, usage: null, outputChars: 0, @@ -493,7 +591,7 @@ export function grokResponsesSseToAnthropic( } catch (err) { logger.debug( { err, payloadPreview: payload.slice(0, 120) }, - "ai-proxy: skipped grok /responses SSE event" + `ai-proxy: skipped ${label} /responses SSE event` ); } } @@ -504,7 +602,10 @@ export function grokResponsesSseToAnthropic( emit(finishFrames(state, undefined)); streamSucceeded = true; } catch (err) { - logger.warn({ err, model: options.model }, "ai-proxy: grok /responses SSE translation failed"); + logger.warn( + { err, model: options.model, upstream: label }, + "ai-proxy: /responses SSE translation failed" + ); if (!safeStreamControllerError(controller, err, streamSucceeded)) { logger.debug({ err }, "ai-proxy: skipped controller.error (client abort or detached)"); diff --git a/src/ai-proxy/lib/translators/formats/anthropic/reasoning-envelope.ts b/src/ai-proxy/lib/translators/formats/anthropic/reasoning-envelope.ts new file mode 100644 index 0000000000..8d36cff006 --- /dev/null +++ b/src/ai-proxy/lib/translators/formats/anthropic/reasoning-envelope.ts @@ -0,0 +1,122 @@ +/** + * The Codex agent's reasoning, carried through Claude Code inside a thinking block's `signature`, which Claude Code + * replays verbatim and never reads. Versioned and stamped by its producer, so a later turn replays only what the same + * kind of upstream, the same account and a compatible model produced: + * + * gtr1.<provider>.<accountHash8>.<model>.<compHash>.<base64url(JSON {id, encrypted_content, summary})> + * + * The model id may hold dots (`gpt-6.1-sol`); every other field has none, so it is read from both ends. A + * signature that fails a check is dropped silently: answering 400 about a thinking block makes Claude Code strip + * every thinking block and retry. + */ +import { createHash } from "node:crypto"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { isObject } from "@genesiscz/utils/object"; + +export const REASONING_ENVELOPE_VERSION = "gtr1"; + +export interface ReasoningEnvelopeStamp { + /** The upstream kind that produced the item (`codex`). */ + provider: string; + /** First 8 hex of the SHA-256 of the account identity that produced it (never the identity itself). */ + accountHash: string; + model: string; + /** The model family's reasoning compatibility hash (catalog `comp_hash`); items replay across one hash only. */ + compHash: string; +} + +/** A Responses `reasoning` item, whole: what is sent back upstream. */ +export interface ReplayedReasoning { + id: string; + encrypted_content: string; + summary: unknown[]; +} + +export type EnvelopeRejection = "not-envelope" | "malformed" | "provider" | "account" | "comp-hash"; + +export type EnvelopeCheck = + | { ok: true; item: ReplayedReasoning; stamp: ReasoningEnvelopeStamp } + | { ok: false; reason: EnvelopeRejection }; + +export function accountHash8(identity: string): string { + return createHash("sha256").update(identity).digest("hex").slice(0, 8); +} + +export function packReasoningEnvelope(stamp: ReasoningEnvelopeStamp, item: ReplayedReasoning): string { + const payload = Buffer.from(SafeJSON.stringify(item)).toString("base64url"); + return [REASONING_ENVELOPE_VERSION, stamp.provider, stamp.accountHash, stamp.model, stamp.compHash, payload].join( + "." + ); +} + +function parsePayload(payload: string): ReplayedReasoning | undefined { + try { + const value = SafeJSON.parse(Buffer.from(payload, "base64url").toString("utf8"), { strict: true }); + + if ( + isObject(value) && + typeof value.id === "string" && + typeof value.encrypted_content === "string" && + value.encrypted_content.length > 0 + ) { + return { + id: value.id, + encrypted_content: value.encrypted_content, + summary: Array.isArray(value.summary) ? value.summary : [], + }; + } + } catch { + // A truncated or edited signature: dropped like any other that fails a check. + } + + return undefined; +} + +/** Opens a signature and checks it against what the next request may replay: same provider, account and comp_hash. */ +export function openReasoningEnvelope( + signature: unknown, + expect: Omit<ReasoningEnvelopeStamp, "model"> +): EnvelopeCheck { + if (typeof signature !== "string" || !signature.startsWith(`${REASONING_ENVELOPE_VERSION}.`)) { + return { ok: false, reason: "not-envelope" }; + } + + const parts = signature.split("."); + + if (parts.length < 6) { + return { ok: false, reason: "malformed" }; + } + + const [, provider, accountHash] = parts; + const payload = parts.at(-1) ?? ""; + const compHash = parts.at(-2) ?? ""; + const stamp = { provider, accountHash, model: parts.slice(3, -2).join("."), compHash }; + + if (provider !== expect.provider) { + return { ok: false, reason: "provider" }; + } + + if (accountHash !== expect.accountHash) { + return { ok: false, reason: "account" }; + } + + if (compHash !== expect.compHash) { + return { ok: false, reason: "comp-hash" }; + } + + const item = parsePayload(payload); + return item ? { ok: true, item, stamp } : { ok: false, reason: "malformed" }; +} + +/** A 400 that rejects replayed reasoning (another account's blob, a stale one): retried once without it. */ +const REPLAY_REJECTED = [ + /invalid_encrypted_content/i, + /could not be verified/i, + /could not decrypt/i, + /was not issued to this caller/i, + /unsupported content type/i, +]; + +export function isReasoningReplayRejection(status: number, bodyText: string): boolean { + return status === 400 && REPLAY_REJECTED.some((pattern) => pattern.test(bodyText)); +} diff --git a/src/ai-proxy/lib/types.ts b/src/ai-proxy/lib/types.ts index b4cab2b344..10746fef7c 100644 --- a/src/ai-proxy/lib/types.ts +++ b/src/ai-proxy/lib/types.ts @@ -1,6 +1,7 @@ import type { AccountRef } from "@genesiscz/utils/ai/config/refs"; import type { CopilotAccountType, CopilotUsageSummary } from "@genesiscz/utils/ai/github-copilot/types"; import type { GrokBillingConfig, GrokModelRecord, GrokSettings } from "@genesiscz/utils/ai/grok"; +import type { CodexModelOverride } from "@genesiscz/utils/ai/openai/codex-models"; import type { MaybeSecret } from "@genesiscz/utils/security"; export type CursorTranslationMode = "auto" | "on" | "off"; @@ -126,6 +127,11 @@ export interface AiProxyOpenAiSubAccountConfig { defaultReasoningEffort?: "none" | "low" | "medium" | "high"; /** Extra model aliases for this account, e.g. { "fast": "gpt-5.4-mini" }. */ aliases?: Record<string, string>; + /** + * Reasoning summaries on the Anthropic route (`/v1/messages`), shown in Claude Code as thinking text. `auto` + * (default) when the model supports the parameter, `detailed` asks for more, `none` asks for none. + */ + reasoningSummary?: "none" | "auto" | "detailed"; } /** Provider-routing knobs, shared shape for both the account-level default and a per-model route. */ @@ -271,9 +277,23 @@ export interface AiProxyConfig { realtime?: AiProxyRealtimeConfig; public?: AiProxyPublicConfig; accounts: AiProxyAccountConfig[]; + /** + * Per Codex model (`gpt-6.1-sol`, …): window, usable and compact percents, default effort. They win over the + * live list, the Codex CLI cache and the static table (`@genesiscz/utils/ai/openai/codex-models`). + */ + codexModels?: Record<string, CodexModelOverride>; + /** Closed session records (`sessions/<id>.json`) are deleted after this many days. Default 30. */ + sessionRetentionDays?: number; } -export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "minimal" | "max"; +/** `ultra` is a Codex UI level: the Codex agent resolves it per model (codex-rs `resolve_reasoning_effort`). */ +export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "minimal" | "max" | "ultra"; + +/** + * Where a request's effort came from: the model id's `:effort` suffix (it wins), the client's own setting (Claude + * Code's `/effort`, sent as `output_config.effort`), or neither, so the model's default applies. + */ +export type EffortSource = "suffix" | "client" | "default"; export interface ResolvedRoute { accountName: string; @@ -282,6 +302,8 @@ export interface ResolvedRoute { account: AiProxyAccountConfig; /** Set when the proxy model id ends with `:<effort>` (e.g. grok-4.6:xhigh). */ reasoningEffort?: ReasoningEffort; + /** `suffix` when `reasoningEffort` came from the id. */ + effortSource?: EffortSource; } export interface ProxyModelMeta { diff --git a/src/ai-proxy/lib/usage/extract.test.ts b/src/ai-proxy/lib/usage/extract.test.ts index 3b1230e7e5..9de4259751 100644 --- a/src/ai-proxy/lib/usage/extract.test.ts +++ b/src/ai-proxy/lib/usage/extract.test.ts @@ -31,6 +31,28 @@ describe("extractLatestUsageFromSse", () => { }); }); + it("books OpenAI-shaped cache reads and reasoning tokens apart from the Anthropic fields (chat and Responses)", () => { + const chat = + 'data: {"object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":34668,"completion_tokens":5,"total_tokens":34673,"prompt_tokens_details":{"cached_tokens":32768},"completion_tokens_details":{"reasoning_tokens":3}}}\n\n'; + const responses = + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":7280,"input_tokens_details":{"cached_tokens":7040},"output_tokens":9,"output_tokens_details":{"reasoning_tokens":4},"total_tokens":7289}}}\n\n'; + + expect(extractLatestUsageFromSse(chat)).toEqual({ + prompt_tokens: 34668, + completion_tokens: 5, + total_tokens: 34673, + cached_prompt_tokens: 32768, + reasoning_tokens: 3, + }); + expect(extractLatestUsageFromSse(responses)).toEqual({ + prompt_tokens: 7280, + completion_tokens: 9, + total_tokens: 7289, + cached_prompt_tokens: 7040, + reasoning_tokens: 4, + }); + }); + it("returns undefined when no event carries usage", () => { const sse = 'data: {"type":"response.output_text.delta","delta":"x"}\n\n'; expect(extractLatestUsageFromSse(sse)).toBeUndefined(); @@ -66,6 +88,7 @@ describe("extractLatestUsageFromSse", () => { prompt_tokens: 195, completion_tokens: 129, total_tokens: 324, + reasoning_tokens: 127, cost_in_usd_ticks: 4318500, }); }); @@ -81,6 +104,7 @@ describe("extractLatestUsageFromSse", () => { prompt_tokens: 195, completion_tokens: 129, total_tokens: 324, + reasoning_tokens: 127, }); }); }); diff --git a/src/ai-proxy/lib/usage/extract.ts b/src/ai-proxy/lib/usage/extract.ts index 830a047bc4..9608bf9eeb 100644 --- a/src/ai-proxy/lib/usage/extract.ts +++ b/src/ai-proxy/lib/usage/extract.ts @@ -5,7 +5,7 @@ import { isObject } from "@genesiscz/utils/object"; type JsonObject = Record<string, unknown>; -function normalizeUsage(raw: unknown): TokenUsage | undefined { +export function normalizeUsage(raw: unknown): TokenUsage | undefined { if (!isObject(raw)) { return undefined; } @@ -69,6 +69,27 @@ function normalizeUsage(raw: unknown): TokenUsage | undefined { usage.cache_creation_input_tokens = raw.cache_creation_input_tokens; } + // OpenAI-shaped cache reads sit INSIDE prompt_tokens: kept in their own + // field so the Anthropic convention above stays exact. The transcript had + // these all along; the ledger dropped them, so `tools ai-proxy usage` + // could not see that the Codex agent hit the cache 6% of the time. + const promptDetails = isObject(raw.prompt_tokens_details) + ? raw.prompt_tokens_details + : isObject(raw.input_tokens_details) + ? raw.input_tokens_details + : undefined; + + if (promptDetails && typeof promptDetails.cached_tokens === "number") { + usage.cached_prompt_tokens = promptDetails.cached_tokens; + } + + const outputDetails = + completionDetails ?? (isObject(raw.output_tokens_details) ? raw.output_tokens_details : undefined); + + if (outputDetails && typeof outputDetails.reasoning_tokens === "number") { + usage.reasoning_tokens = outputDetails.reasoning_tokens; + } + // Recorded verbatim but never booked as cost: two measured samples on // differently-priced grok models (grok-4.3 vs grok-4-fast, 2026-08-19) // returned near-identical tick counts for similar token totals, so the tick diff --git a/src/ai-proxy/lib/usage/store.test.ts b/src/ai-proxy/lib/usage/store.test.ts index b7146d92c1..078ecc422c 100644 --- a/src/ai-proxy/lib/usage/store.test.ts +++ b/src/ai-proxy/lib/usage/store.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getTodayUsageSummary, recordUsageRequest, setUsageStoreDirForTests } from "@app/ai-proxy/lib/usage/store"; import type { UsageRequestRecord } from "@app/ai-proxy/lib/usage/types"; +import { hitPercent } from "@genesiscz/utils/ai/usage/cache-stats"; import { SafeJSON } from "@genesiscz/utils/json"; import { atomicWriteIo } from "@genesiscz/utils/storage/atomic-file"; @@ -204,4 +205,42 @@ describe("daily usage store", () => { expect(attempts).toBeGreaterThan(0); expect(readFileSync(dailyFile(), "utf8")).toBe(before); }); + + it("books cache read, cache write, uncached input and reasoning in both usage conventions", () => { + // OpenAI shape (Codex, Grok): the cached count is a part of prompt_tokens. + recordUsageRequest({ + ...request(), + usage: { + prompt_tokens: 1000, + completion_tokens: 20, + total_tokens: 1020, + cached_prompt_tokens: 900, + reasoning_tokens: 12, + }, + }); + // Anthropic shape: the cache counts sit outside prompt_tokens. + recordUsageRequest({ + ...request(), + usage: { + prompt_tokens: 50, + completion_tokens: 5, + total_tokens: 55, + cache_read_input_tokens: 400, + cache_creation_input_tokens: 150, + }, + }); + flushScheduledWrite(); + + const today = getTodayUsageSummary("work"); + const split = { + uncached: today.uncached_input_tokens ?? 0, + cacheRead: today.cache_read_tokens ?? 0, + cacheWrite: today.cache_write_tokens ?? 0, + }; + + expect(split).toEqual({ uncached: 150, cacheRead: 1300, cacheWrite: 150 }); + expect(today.reasoning_tokens).toBe(12); + expect(hitPercent(split)).toBeCloseTo((1300 / 1600) * 100, 6); + expect(hitPercent({ uncached: 0, cacheRead: 0, cacheWrite: 0 })).toBeUndefined(); + }); }); diff --git a/src/ai-proxy/lib/usage/store.ts b/src/ai-proxy/lib/usage/store.ts index d98e69af60..c3eeeed64c 100644 --- a/src/ai-proxy/lib/usage/store.ts +++ b/src/ai-proxy/lib/usage/store.ts @@ -11,6 +11,7 @@ import type { } from "@app/ai-proxy/lib/usage/types"; import { noteUnreadableUsageFile, preserveBeforeReplacing } from "@app/ai-proxy/lib/usage/unreadable-files"; import type { GrokBillingConfig, GrokSettings } from "@genesiscz/utils/ai/grok"; +import { splitPromptTokens } from "@genesiscz/utils/ai/usage/cache-stats"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { atomicWriteFileSync, isJsonObject } from "@genesiscz/utils/storage/atomic-file"; @@ -222,6 +223,14 @@ function bumpDailyAggregate(record: UsageRequestRecord): void { current.estimated_requests = (current.estimated_requests ?? 0) + 1; } + if (record.usage) { + const split = splitPromptTokens(record.usage); + current.uncached_input_tokens = (current.uncached_input_tokens ?? 0) + split.uncached; + current.cache_read_tokens = (current.cache_read_tokens ?? 0) + split.cacheRead; + current.cache_write_tokens = (current.cache_write_tokens ?? 0) + split.cacheWrite; + current.reasoning_tokens = (current.reasoning_tokens ?? 0) + (record.usage.reasoning_tokens ?? 0); + } + store.days[day][key] = current; saveDailyStore(); } @@ -410,6 +419,17 @@ function addDayIntoSummary(day: Record<string, DailyModelUsage>, summary: DailyM if (stats.estimated_requests) { summary.estimated_requests = (summary.estimated_requests ?? 0) + stats.estimated_requests; } + + for (const field of [ + "uncached_input_tokens", + "cache_read_tokens", + "cache_write_tokens", + "reasoning_tokens", + ] as const) { + if (stats[field] !== undefined) { + summary[field] = (summary[field] ?? 0) + stats[field]; + } + } } } diff --git a/src/ai-proxy/lib/usage/track-response.ts b/src/ai-proxy/lib/usage/track-response.ts index d9814a4117..04598d83df 100644 --- a/src/ai-proxy/lib/usage/track-response.ts +++ b/src/ai-proxy/lib/usage/track-response.ts @@ -1,5 +1,5 @@ import { proxyAccountRefOf } from "@app/ai-proxy/lib/account-refs"; -import type { ResolvedRoute } from "@app/ai-proxy/lib/types"; +import type { EffortSource, ResolvedRoute } from "@app/ai-proxy/lib/types"; import type { CallTimeline } from "@app/ai-proxy/lib/usage/call-timeline"; import { recordClientUsage } from "@app/ai-proxy/lib/usage/client-ledger"; import { @@ -34,6 +34,10 @@ export function trackCompletedRequest(input: { timeline?: CallTimeline; /** Set when the exchange never completed — the row is recorded anyway. */ failure?: string; + cacheKey?: string; + compaction?: boolean; + effort?: string; + effortSource?: EffortSource; }): void { const stream = input.stream ?? bodyWantsStream(input.bodyText); let usage = stream ? extractLatestUsageFromSse(input.responseBody) : extractUsageFromJsonBody(input.responseBody); @@ -105,6 +109,10 @@ export function trackCompletedRequest(input: { tags: input.tags, transcript, timeline: input.timeline, + ...(input.cacheKey ? { cacheKey: input.cacheKey } : {}), + ...(input.compaction ? { compaction: true } : {}), + ...(input.effort ? { effort: input.effort } : {}), + ...(input.effortSource ? { effortSource: input.effortSource } : {}), }; recordUsageRequest(record); @@ -150,6 +158,10 @@ export function scheduleUsageTracking(input: { translate?: string; thinking?: string; tags?: RequestTags; + cacheKey?: string; + compaction?: boolean; + effort?: string; + effortSource?: EffortSource; }): void { void input.responseBody .then(async (responseBody) => { diff --git a/src/ai-proxy/lib/usage/types.ts b/src/ai-proxy/lib/usage/types.ts index 08b3841fc2..aae8d1f5f4 100644 --- a/src/ai-proxy/lib/usage/types.ts +++ b/src/ai-proxy/lib/usage/types.ts @@ -1,4 +1,4 @@ -import type { GrokUsageDetails, SubscriptionUsageDetails } from "@app/ai-proxy/lib/types"; +import type { EffortSource, GrokUsageDetails, SubscriptionUsageDetails } from "@app/ai-proxy/lib/types"; import type { CopilotUsageSummary } from "@genesiscz/utils/ai/github-copilot/types"; export interface TokenUsage { @@ -14,6 +14,16 @@ export interface TokenUsage { */ cache_read_input_tokens?: number; cache_creation_input_tokens?: number; + /** + * OpenAI-shaped cache reads (`prompt_tokens_details.cached_tokens`, Responses + * `input_tokens_details.cached_tokens`): a PART of prompt_tokens, unlike the + * Anthropic field above, so a hit rate reads one or the other and never adds + * both (`splitPromptTokens` in `@genesiscz/utils/ai/usage/cache-stats`). + * Before 2026-10-25 the Codex agent booked no cache count at all. + */ + cached_prompt_tokens?: number; + /** Reasoning tokens the upstream reported (`completion_tokens_details` / `output_tokens_details`). */ + reasoning_tokens?: number; cost_in_usd_ticks?: number; /** * USD the upstream itself charged for this exchange, as it reported it. @@ -70,6 +80,13 @@ export interface UsageRequestRecord { transcript?: { file: string; uuid: string }; /** Phase timings (dispatch, TTFB, thinking span, text span) for this call. */ timeline?: CallTimeline; + /** The prompt cache key the request carried upstream (a session id or a content hash; never a secret). */ + cacheKey?: string; + /** Claude Code's compaction turn: the request after it starts a new cache prefix. */ + compaction?: boolean; + /** The effort the request asked for (`/v1/messages`), before the Codex catalog maps it. */ + effort?: string; + effortSource?: EffortSource; } export interface AccountBillingSnapshot { @@ -94,6 +111,12 @@ export interface DailyModelUsage { rate_limits: number; /** Requests whose tokens were locally estimated (upstream sent no usage). */ estimated_requests?: number; + /** Cache reads in either convention (see `TokenUsage.cached_prompt_tokens`); absent on days before 2026-10-25. */ + cache_read_tokens?: number; + cache_write_tokens?: number; + /** Input outside the cache, in either convention. */ + uncached_input_tokens?: number; + reasoning_tokens?: number; } export interface DailyUsageStore { diff --git a/src/ai-spend/README.md b/src/ai-spend/README.md index 84d03289a3..9cc5567082 100644 --- a/src/ai-spend/README.md +++ b/src/ai-spend/README.md @@ -163,7 +163,7 @@ Each agent contributes a driver under `lib/drivers/`. A driver declares only thr | Agent | Files | Usage line | Cost | |---|---|---|---| | `claude` | `~/.claude/projects/**/*.jsonl`, `~/.config/claude/projects/**`, `$CLAUDE_CONFIG_DIR/projects/**` | `type: "assistant"` with `message.usage` | catalog rates for `anthropic` | -| `codex` | `~/.codex/{sessions,archived_sessions}/**/*.jsonl`, or every `$CODEX_HOME` in the comma-separated list | `type: "event_msg"` with `payload.type: "token_count"`; the model comes from the preceding `turn_context` line | catalog rates for `openai` | +| `codex` | `~/.codex/{sessions,archived_sessions}/**/*.jsonl` and `*.jsonl.zst` (Codex 0.164 compresses its old rollouts), or every `$CODEX_HOME` in the comma-separated list | `type: "event_msg"` with `payload.type: "token_count"`; the model comes from the preceding `turn_context` line | catalog rates for `openai` | | `grok` | `~/.grok/sessions/**/updates.jsonl`, or under `$GROK_HOME` | `params.update.sessionUpdate: "turn_completed"`, one event per entry in `usage.modelUsage` | catalog rates for `xai` (xAI's list prices); the `costUsdTicks` Grok recorded only when the model has no rate | Line shapes and token arithmetic mirror ccusage's Rust adapters, so the numbers line up with what ccusage reports for the same files: diff --git a/src/ai-spend/lib/monitor.test.ts b/src/ai-spend/lib/monitor.test.ts index 3b094760df..5784d88749 100644 --- a/src/ai-spend/lib/monitor.test.ts +++ b/src/ai-spend/lib/monitor.test.ts @@ -14,6 +14,7 @@ import { join } from "node:path"; import type { AccountEntry } from "@genesiscz/utils/ai/config/schema"; import { registerBuiltInPlugins } from "@genesiscz/utils/ai/providers/plugins"; import { CLAUDE_ALL_ACCOUNT_ID, UNBOUND_ACCOUNT_ID } from "@genesiscz/utils/ai/usage"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import { Storage } from "@genesiscz/utils/storage/storage"; import { setupStorageSandbox } from "@genesiscz/utils/storage/test-sandbox"; @@ -26,6 +27,7 @@ import { localDayString, type MonitorReport, mondayOfWeek, + readTail, } from "./monitor"; import { DEFAULT_PRICING } from "./pricing"; import { monitorEnvelope } from "./register"; @@ -861,3 +863,16 @@ describe("monitor account rows", () => { expect(report.today.cost).toBeCloseTo(0.8, 6); }); }); + +describe("readTail on a compressed rollout (.jsonl.zst)", () => { + test("reads the whole text once and has no tail after it: the file never grows", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-spend-zst-")); + const path = join(dir, "rollout-2026-10-01T09-00-00-thread.jsonl.zst"); + const text = '{"type":"event_msg"}\n{"type":"turn_context"}\n'; + writeFileSync(path, compressBytes(text)); + const size = statSync(path).size; + + expect(readTail(path, 0, size)).toBe(text); + expect(readTail(path, Buffer.byteLength(text), size)).toBe(""); + }); +}); diff --git a/src/ai-spend/lib/monitor.ts b/src/ai-spend/lib/monitor.ts index 3157b7696b..7e3c7f8b29 100644 --- a/src/ai-spend/lib/monitor.ts +++ b/src/ai-spend/lib/monitor.ts @@ -4,6 +4,8 @@ import { join } from "node:path"; import type { AccountEntry } from "@genesiscz/utils/ai/config/schema"; import type { DiscoveredHome } from "@genesiscz/utils/ai/providers/account-features"; import { CLAUDE_ALL_ACCOUNT_ID, CLAUDE_ALL_ACCOUNT_NAME, UNBOUND_ACCOUNT_ID } from "@genesiscz/utils/ai/usage"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { atomicWriteFileSync, Storage } from "@genesiscz/utils/storage/storage"; @@ -446,6 +448,11 @@ function fastCandidates(driver: MonitorDriver, roots: string[], cache: AgentCach * would drift, and the one that drifted would silently lose events. */ export function readTail(path: string, offset: number, size: number): string { + // A `.jsonl.zst` (a Codex rollout Codex compressed) never grows: read it whole once, then there is no tail. + if (isZstdPath(path)) { + return offset === 0 ? readWholeTextSync(path) : ""; + } + const length = size - offset; const fd = openSync(path, "r"); diff --git a/src/ai-spend/lib/reports/commands.ts b/src/ai-spend/lib/reports/commands.ts index 2b453cac99..a732f60775 100644 --- a/src/ai-spend/lib/reports/commands.ts +++ b/src/ai-spend/lib/reports/commands.ts @@ -3,6 +3,7 @@ import { basename } from "node:path"; import { resolveCachedSessionId, type SessionIdResolution } from "@genesiscz/utils/agent-sessions/cached-title"; import { suggestEnumFlag } from "@genesiscz/utils/cli"; import { out } from "@genesiscz/utils/logger"; +import { transcriptStem } from "@genesiscz/utils/providers/session-paths"; import { findCodexRollout } from "@genesiscz/utils/session-changes/codex"; import { Storage } from "@genesiscz/utils/storage/storage"; import type { Command } from "commander"; @@ -160,7 +161,7 @@ export function resolveSessionFlag( const rollout = sessionId.startsWith("rollout-") ? null : findRollout(sessionId); if (rollout) { - const stem = basename(rollout, ".jsonl"); + const stem = transcriptStem(basename(rollout)); return { id: stem, note: `--id ${id} is Codex rollout ${stem}` }; } } diff --git a/src/ai-spend/lib/reports/native.ts b/src/ai-spend/lib/reports/native.ts index a23293d12d..210d049a53 100644 --- a/src/ai-spend/lib/reports/native.ts +++ b/src/ai-spend/lib/reports/native.ts @@ -3,6 +3,7 @@ import { basename, dirname, join, sep } from "node:path"; import type { AccountEntry } from "@genesiscz/utils/ai/config/schema"; import type { DiscoveredHome } from "@genesiscz/utils/ai/providers/account-features"; import { logger } from "@genesiscz/utils/logger"; +import { transcriptStem } from "@genesiscz/utils/providers/session-paths"; import { resolveDriverRoots, rootForFile } from "../account-roots"; import { claudeDriver } from "../drivers/claude"; import { codexDriver } from "../drivers/codex"; @@ -155,7 +156,7 @@ function sessionFromFile(file: string, source: SourceId): string { return basename(dirname(file)); } - return basename(file).replace(/\.jsonl$/, ""); + return transcriptStem(basename(file)); } function projectFromFile(file: string, source: SourceId): string { diff --git a/src/ai-spend/lib/reports/walk.ts b/src/ai-spend/lib/reports/walk.ts index b1e4cdf0cb..db536644e7 100644 --- a/src/ai-spend/lib/reports/walk.ts +++ b/src/ai-spend/lib/reports/walk.ts @@ -1,5 +1,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; export interface WalkOptions { @@ -77,9 +79,10 @@ export function walkFiles(roots: string[], options: WalkOptions): string[] { return out; } +/** A usage file's text, a `.jsonl.zst` decompressed (Codex compresses its old rollouts). */ export function readText(file: string): string | null { try { - return readFileSync(file, "utf8"); + return readWholeTextSync(file); } catch (err) { logger.debug({ err, file }, "ai-spend: failed to read usage file"); return null; @@ -88,7 +91,7 @@ export function readText(file: string): string | null { export function readBytes(file: string): Buffer | null { try { - return readFileSync(file); + return isZstdPath(file) ? Buffer.from(readWholeTextSync(file), "utf8") : readFileSync(file); } catch (err) { logger.debug({ err, file }, "ai-spend: failed to read usage file"); return null; diff --git a/src/ai-spend/lib/workers/stats.ts b/src/ai-spend/lib/workers/stats.ts index c849d21879..345b9ab31a 100644 --- a/src/ai-spend/lib/workers/stats.ts +++ b/src/ai-spend/lib/workers/stats.ts @@ -1,4 +1,5 @@ import { basename } from "node:path"; +import { transcriptStem } from "@genesiscz/utils/providers/session-paths"; import { findCodexRollout } from "@genesiscz/utils/session-changes/codex"; import type { WorkerBackend } from "@genesiscz/utils/worker/capabilities"; import { addTokens, EMPTY_TOKENS, type WorkerAuthMode, type WorkerTokens } from "@genesiscz/utils/worker/cost"; @@ -122,7 +123,7 @@ export function loadWorkerSessionEvents( return null; } - id = basename(rollout, ".jsonl"); + id = transcriptStem(basename(rollout)); } // The by-name door only: `loadEvents` would fall back to reading EVERY Claude transcript diff --git a/src/ai/commands/agent/run.ts b/src/ai/commands/agent/run.ts index 15ce629fb0..634b992a17 100644 --- a/src/ai/commands/agent/run.ts +++ b/src/ai/commands/agent/run.ts @@ -57,7 +57,9 @@ export function registerAgentRunCommand(program: Command, spec: AgentToolSpec): const run = program .command("run [account] [args...]") .alias("start") - .description(`Open the ${spec.alias} terminal as an account (picker when omitted); args after -- pass through`) + .description( + `Open the ${spec.alias} terminal as an account (picker when omitted; auto ranks by the cached usage); args after -- pass through` + ) .option("-r, --resume [query]", "Resume a session: bare uses the native picker, a query searches the index") .option("-c, --continue", "Continue the most recent session in this directory") .option("-m, --model <model>", "Model id or alias") diff --git a/src/ai/lib/accounts/pick-launch-account.ts b/src/ai/lib/accounts/pick-launch-account.ts index 73e87ca423..c71f5ca4ab 100644 --- a/src/ai/lib/accounts/pick-launch-account.ts +++ b/src/ai/lib/accounts/pick-launch-account.ts @@ -4,8 +4,10 @@ import type { AccountEntry } from "@genesiscz/utils/ai/config/schema"; import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/providers/account-features"; import type { AccountProviderAlias } from "@genesiscz/utils/ai/providers/aliases"; import { PROVIDER_ALIASES } from "@genesiscz/utils/ai/providers/aliases"; +import { pickRanked, rankAccounts } from "@genesiscz/utils/ai/usage/account-rank"; import { readSnapshotsCache } from "@genesiscz/utils/ai/usage-poll/legacy-cache"; -import { logger } from "@genesiscz/utils/logger"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { logger, out } from "@genesiscz/utils/logger"; import { resolveAccountName } from "./select-account"; /** @@ -55,6 +57,44 @@ function hintFrom(snapshot: AccountUsageSnapshot): string | undefined { return windows.length > 0 ? windows.join(" · ") : undefined; } +/** Cached snapshots per account id. */ +async function cachedSnapshots(provider: string): Promise<AccountUsageSnapshot[]> { + try { + return (await readSnapshotsCache())?.providers[provider]?.accounts ?? []; + } catch (error) { + logger.debug({ error, provider }, "[ai] usage cache unreadable; auto has nothing to rank"); + return []; + } +} + +/** + * `run auto`: the shared ranking (`rankAccounts`, the same one `tools claude codex` uses) over the cached usage, with the + * pick and the runner-up printed. Never an account without data, never a blocked one. + */ +async function autoLaunchAccount(input: LaunchAccountInput, provider: string, accounts: AccountEntry[]) { + const ids = new Set(accounts.map((account) => account.id)); + const snapshots = (await cachedSnapshots(provider)).filter((snapshot) => ids.has(snapshot.accountId)); + const picked = pickRanked(rankAccounts(snapshots)); + // A snapshot's accountName is its account's name. + const account = picked && accounts.find((item) => item.name === picked.pick.accountName); + + if (!picked || !account) { + out.error( + `auto found no ${input.alias} account with headroom in the usage cache. Name one, or refresh the cache with ${toolCommand(`${input.alias} usage`)}.` + ); + process.exitCode = 1; + return null; + } + + out.printlnErr(`▸ ${input.alias} auto → ${account.name} (${picked.pick.why})`); + + if (picked.runnerUp) { + out.printlnErr(` runner-up: ${picked.runnerUp.accountName} (${picked.runnerUp.why})`); + } + + return account; +} + export interface LaunchAccountInput { alias: AccountProviderAlias; /** Command name for the non-interactive hint, e.g. `tools codex run`. */ @@ -73,6 +113,14 @@ export interface LaunchAccountInput { export async function pickLaunchAccount(input: LaunchAccountInput): Promise<AccountEntry | null> { const provider = PROVIDER_ALIASES[input.alias]; const accounts = (await AiConfigStore.load()).accounts({ provider, enabled: true }); + + if ( + input.requested?.toLowerCase() === "auto" && + !accounts.some((account) => account.name.toLowerCase() === "auto") + ) { + return autoLaunchAccount(input, provider, accounts); + } + // Only for the prompt: a named account skips the picker, so the cache read would be waste. const hints = input.requested ? new Map<string, string>() : await usageHints(provider); const picked = await resolveAccountName({ diff --git a/src/ai/lib/agent-message/delivery.test.ts b/src/ai/lib/agent-message/delivery.test.ts index 870b601bbf..9f83c7614c 100644 --- a/src/ai/lib/agent-message/delivery.test.ts +++ b/src/ai/lib/agent-message/delivery.test.ts @@ -485,6 +485,29 @@ test("a registry file without a messaging socket is not a target", () => { expect(parseRegistryEntry("not json", "f")).toBeNull(); }); +test("a parked registry entry is no message receiver: its conversation moved to a background job", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-registry-parked-")); + try { + writeFileSync( + join(dir, "11.json"), + SafeJSON.stringify({ pid: 11, sessionId: "live", messagingSocketPath: "/a.sock" }) + ); + writeFileSync( + join(dir, "12.json"), + SafeJSON.stringify({ + pid: 12, + sessionId: "parked", + messagingSocketPath: "/b.sock", + parkedJobId: "68f2bba6", + }) + ); + const sessions = listClaudeLiveSessions(dir, (pids) => new Set(pids)); + expect(sessions.map((session) => session.sessionId)).toEqual(["live"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("--cross-messages puts the accept setting first and refuses a caller's own --settings", () => { expect(withCrossMessages(["--", "fix it"])).toEqual([ "--settings", diff --git a/src/ai/lib/sessions/agent-session-rows.ts b/src/ai/lib/sessions/agent-session-rows.ts index 08afa723ac..2eb8a02299 100644 --- a/src/ai/lib/sessions/agent-session-rows.ts +++ b/src/ai/lib/sessions/agent-session-rows.ts @@ -74,6 +74,8 @@ export interface AgentSessionRow { lastUserAt?: number | null; modelSwitched?: boolean; cmux?: SessionCmuxLocation | null; + /** Claude only: whether a claude process still runs it (registry); null when the registry is unreadable. */ + alive?: boolean | null; } export interface AgentSessionRowsOptions { diff --git a/src/ai/lib/sessions/rows-cache.test.ts b/src/ai/lib/sessions/rows-cache.test.ts index d3c5630c16..e1e2248dcd 100644 --- a/src/ai/lib/sessions/rows-cache.test.ts +++ b/src/ai/lib/sessions/rows-cache.test.ts @@ -12,6 +12,7 @@ import { type SessionRowsCache, sessionRowsCacheKey, sessionRowsJson, + withLiveness, writeSessionRowsCache, } from "./rows-cache"; @@ -313,3 +314,31 @@ describe("refreshSessionRowsCache", () => { expect(result.reason).toBe("nobody has asked for an hour"); }); }); + +describe("withLiveness", () => { + const row = (provider: AgentSessionRow["provider"], sessionId: string): AgentSessionRow => ({ + provider, + sessionId, + title: null, + cwd: "/x", + cwdShort: "/x", + project: null, + mtime: 0, + model: null, + account: null, + filePath: `/x/${sessionId}.jsonl`, + }); + + test("marks Claude rows from the registry, case-insensitively, and leaves other providers alone", () => { + const rows = withLiveness( + [row("claude", "AAAA"), row("claude", "bbbb"), row("codex", "cccc")], + new Set(["aaaa"]) + ); + expect(rows.map((r) => r.alive)).toEqual([true, false, undefined]); + }); + + test("an unreadable registry changes nothing: unknown is not dead", () => { + const rows = [row("claude", "aaaa")]; + expect(withLiveness(rows, null)).toBe(rows); + }); +}); diff --git a/src/ai/lib/sessions/rows-cache.ts b/src/ai/lib/sessions/rows-cache.ts index 1a1071fddd..85cf10088f 100644 --- a/src/ai/lib/sessions/rows-cache.ts +++ b/src/ai/lib/sessions/rows-cache.ts @@ -3,6 +3,7 @@ import { dirname } from "node:path"; import type { AgentSessionRow, AgentSessionRowsOptions } from "@app/ai/lib/sessions/agent-session-rows"; import { computeCacheStatus } from "@app/claude/lib/usage/cache-clock"; import { aiDataDir } from "@genesiscz/utils/ai/config/paths"; +import { liveClaudeSessionIds } from "@genesiscz/utils/claude/peer-message"; import { type TmuxCmuxLocation, tmuxCmuxLocations } from "@genesiscz/utils/cmux/tmux-host"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -171,6 +172,26 @@ async function withTmuxLocations( }); } +/** + * Claude rows with `alive` read now from the session registry, never from the cached file: a cached answer may + * be 90 s old, and a session that exited in that time would still read as running. Other providers have no + * registry and keep what they had. `live` null (registry unreadable) changes nothing: unknown is not dead. + */ +export function withLiveness(rows: AgentSessionRow[], live: Set<string> | null): AgentSessionRow[] { + if (!live) { + return rows; + } + + return rows.map((row) => { + if (row.provider !== "claude") { + return row; + } + + const alive = live.has(row.sessionId.toLowerCase()); + return row.alive === alive ? row : { ...row, alive }; + }); +} + /** * Rows with each prompt-cache clock (`cacheStatus`, `cacheTtlSec`) worked out for `now` from `lastCacheAt` and * `cacheLifetimeSec`, which are facts of the transcript. A cached answer may be 90 s old, and its clocks used to @@ -208,6 +229,7 @@ export async function sessionRowsJson( path = sessionRowsCachePath(), listRows, tmuxLocations = isTestProcess() ? async () => new Map() : () => tmuxCmuxLocations(), + liveSessionIds = isTestProcess() ? () => null : () => liveClaudeSessionIds(), }: { fresh?: boolean; cacheWrite?: boolean; @@ -220,6 +242,8 @@ export async function sessionRowsJson( listRows: (options: AgentSessionRowsOptions) => Promise<AgentSessionRow[]>; /** Live cmux tabs of tmux-hosted sessions. A test that injects none gets none. */ tmuxLocations?: () => Promise<Map<string, TmuxCmuxLocation>>; + /** Running Claude session ids (`liveClaudeSessionIds`); a test that injects none gets `alive: null`. */ + liveSessionIds?: () => Set<string> | null; } ): Promise<{ fetchedAt: number; cached: boolean; rows: AgentSessionRow[] }> { if (!fresh) { @@ -242,7 +266,9 @@ export async function sessionRowsJson( return { fetchedAt: cached.fetchedAt, cached: true, - rows: liveLocations ? await withTmuxLocations(rows, tmuxLocations) : rows, + rows: liveLocations + ? withLiveness(await withTmuxLocations(rows, tmuxLocations), liveSessionIds()) + : rows, }; } } @@ -253,5 +279,9 @@ export async function sessionRowsJson( await writeSessionRowsCache({ query: listing, fetchedAt: now, lastRequestedAt: now, rows }, path); } - return { fetchedAt: now, cached: false, rows: liveLocations ? await withTmuxLocations(rows, tmuxLocations) : rows }; + return { + fetchedAt: now, + cached: false, + rows: liveLocations ? withLiveness(await withTmuxLocations(rows, tmuxLocations), liveSessionIds()) : rows, + }; } diff --git a/src/artifact/runtime/kit/chartjs.tsx b/src/artifact/runtime/kit/chartjs.tsx index ce83c456a0..c4105a970e 100644 --- a/src/artifact/runtime/kit/chartjs.tsx +++ b/src/artifact/runtime/kit/chartjs.tsx @@ -1,4 +1,4 @@ -import { Chart, type ChartConfiguration, registerables } from "chart.js"; +import { Chart, type ChartConfiguration, type ChartType, registerables } from "chart.js"; import { useEffect, useRef } from "react"; import { onColorSchemeChange, resolveCssColor } from "./mermaid-core"; @@ -77,8 +77,9 @@ export function ChartJs({ config, height = 320, ariaLabel }: ChartJsProps) { themeRef.current = themeChartColors(); const base = configRef.current; - // Caller options win: the theme only supplies what the config omits. - const chart = new Chart(canvasRef.current, { + // Caller options win: the theme only supplies what the config omits. The explicit type argument saves every + // TypeScript program about 1 s and 250k symbols of inference over Chart's config union (2026-10-25). + const chart = new Chart<ChartType>(canvasRef.current, { ...base, options: { ...themeRef.current, ...base.options }, }); diff --git a/src/artifact/runtime/kit/gantt.tsx b/src/artifact/runtime/kit/gantt.tsx index c1264a3d1f..b56b9ca992 100644 --- a/src/artifact/runtime/kit/gantt.tsx +++ b/src/artifact/runtime/kit/gantt.tsx @@ -641,7 +641,8 @@ export function Gantt({ const todayIndex = todayDate ? ganttDayIndex(todayDate, axis.start) : -1; const showToday = todayDate !== null && todayIndex >= 0 && todayIndex < totalDays; const cells = ganttCells(axis, locale); - const bandLayer: CSSProperties = { + // Only the property it sets: spreading a whole CSSProperties into a style copies every CSS property into a new type. + const bandLayer: Pick<CSSProperties, "backgroundImage"> = { backgroundImage: [ `repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px ${cellWidth}px)`, `repeating-linear-gradient(to right, transparent 0 ${cellWidth}px, var(--wash) ${cellWidth}px ${cellWidth * 2}px)`, diff --git a/src/claude/README.md b/src/claude/README.md index cf2481a116..98f6a62f8d 100644 --- a/src/claude/README.md +++ b/src/claude/README.md @@ -115,7 +115,42 @@ environment — either one would outrank the proxy token and silently bill the r account. `tools claude start` (alias `run`) is unchanged for Anthropic accounts. The split is the slash: no -Anthropic account name has one, every ai-proxy target does. +Anthropic account name has one, every ai-proxy target does. `-e/--effort` works on both: Claude +Code's own `--effort` for an account, the `:<effort>` suffix for a proxy target. + +## `codex`: Claude Code with its main loop on a Codex model + +```bash +tools claude codex # auto Codex login, auto Claude login, sol at high +tools claude codex cdx-work -m astra -e xhigh # a named Codex login, another model and effort +tools claude codex --claude none # no Claude account: Claude models are refused +tools claude codex --grok auto # grok/<model> ids work in the session too +tools claude codex --resume "the parser bug" # resume: same session id, same subagents, a fresh key +tools claude codex -- -p "one turn" # args after -- go to claude +``` + +The main loop runs on `codex/<model>` through ai-proxy; titles and the Claude-side calls run on +the Claude account; one subagent per Codex model (`sol`, `sol-xhigh`, `sol6`, `astra`, `luna`), +`opus` (and `fable` when the Claude account has it) on the Claude account, and `codex-worker`, +which hands a task to `tools codex spawn`. Each launch writes a session record +(`~/.genesis-tools/ai-proxy/sessions/<Claude Code session id>.json`) and gives Claude Code its +own `gts_` key; the proxy then routes `codex/<id>`, `grok/<id>` and bare `claude-*` ids to that +session's accounts only. + +- **Accounts.** `[account]`, `--claude` and `--grok` take a login (a `tools codex` / `tools claude` + / `tools grok` account) or a configured ai-proxy account; a login gets an account the proxy + builds in memory, nothing is written to the proxy config. A configured account named on the + command line keeps its own settings, so the legacy Codex account that reads `~/.codex/auth.json` + still works. `auto` (the default) ranks the logins by usage and prints the runner-up. +- **Billed accounts.** Subscriptions only by default. An account billed per token needs + `--allow-metered <account>` or a yes at a prompt (no default yes); a non-interactive launch + without the flag stops before anything starts. +- **Window.** `CLAUDE_CODE_MAX_CONTEXT_TOKENS` and the compact window are the model's usable + tokens from the Codex catalog, passed in `--settings` too, because the `env` of + `~/.claude/settings.json` would otherwise win. `-c <percent>` changes the compact percent. +- **`--isolated`** uses its own Claude Code config folder under + `~/.genesis-tools/claude-codex/config/<session id>/` and writes the subagents there as files. +- **`--port`** aims at another ai-proxy (a second one for tests); it is never started for you. --- diff --git a/src/claude/commands/cmux/focus.command.test.ts b/src/claude/commands/cmux/focus.command.test.ts index cef7df953e..91919b2d3a 100644 --- a/src/claude/commands/cmux/focus.command.test.ts +++ b/src/claude/commands/cmux/focus.command.test.ts @@ -202,6 +202,25 @@ describe("focusCommand", () => { expect(events).toEqual(["--id-format both identify"]); }); + test("--exact refuses a pane that only shows the id on screen, and focuses one proven to run it", async () => { + // 2026-10-25: a cache chip pressed with --first focused `ncdu /tmp`, whose listing names a session folder. + setSnapshot([pane({ id: "pane:33", title: "ncdu /tmp", preview: `/tmp/cc/GenesisTools/${SESSION_A}/` })]); + + const weak = await runFocus(SESSION_A, { json: true, first: true, exact: true }); + + expect(weak.exitCode).toBe(1); + expect(weak.result).toContain(`"focused": null`); + expect(events).toEqual(["--id-format both identify"]); + + process.exitCode = 0; + setSnapshot([pane({ id: "pane:34", preview: resumeScreen(SESSION_A) })]); + + const proven = await runFocus(SESSION_A, { json: true, first: true, exact: true, dryRun: true }); + + expect(proven.exitCode).toBe(0); + expect(proven.result).toContain(`"paneId": "pane:34"`); + }); + test("--dry-run resolves a target but performs no cmux mutation", async () => { setSnapshot([pane({ id: "pane:33", preview: resumeScreen(SESSION_A) })]); diff --git a/src/claude/commands/cmux/focus.ts b/src/claude/commands/cmux/focus.ts index 00f5fde507..f9532ebe37 100644 --- a/src/claude/commands/cmux/focus.ts +++ b/src/claude/commands/cmux/focus.ts @@ -1,5 +1,11 @@ import { describeMatch, type FocusTarget, isUnambiguous } from "@app/claude/lib/cmux/focus"; -import { findSessionTargets, type ResolveDeps, retryAfterStaleRefs, tmuxMissHint } from "@app/claude/lib/cmux/resolve"; +import { + findSessionTargets, + identifiesExactSession, + type ResolveDeps, + retryAfterStaleRefs, + tmuxMissHint, +} from "@app/claude/lib/cmux/resolve"; import * as p from "@clack/prompts"; import { isInteractive, suggestCommand } from "@genesiscz/utils/cli"; import { runCmuxOk } from "@genesiscz/utils/cmux/lib/cli"; @@ -19,6 +25,12 @@ export interface FocusOptions { dryRun?: boolean; first?: boolean; includeSelf?: boolean; + /** + * Focus only a pane proven to run the session (the hook journal, its resume command, its id in the tab title). + * A full id printed on some screen is not proof: `ncdu /tmp` lists folders named after session ids, and an agent + * pane that discussed the session prints it too (2026-10-25, a cache chip focused `ncdu /tmp`). + */ + exact?: boolean; } export type FocusCommandDeps = ResolveDeps; @@ -243,6 +255,27 @@ export async function focusCommand(query: string, opts: FocusOptions, deps: Focu process.exit(1); } + if (opts.exact && !identifiesExactSession(result)) { + process.exitCode = 1; + + if (opts.json) { + out.result(SafeJSON.stringify({ query, focused: null, exact: false, matches: targets }, null, 2)); + return; + } + + const [best] = targets; + out.error(pc.red(`No cmux pane is proven to run ${query.slice(0, 8)}; nothing was focused.`)); + out.printlnErr( + pc.dim( + ` ${targets.length} pane(s) only show its id on screen${best ? ` (best: ${describeMatch(best)})` : ""}.` + ) + ); + out.printlnErr( + pc.dim(` Pick one anyway: ${suggestCommand("tools claude", { replaceCommand: ["cmux", "focus", query] })}`) + ); + return; + } + let target = await chooseTarget(targets, query, opts); if (!target) { diff --git a/src/claude/commands/cmux/index.ts b/src/claude/commands/cmux/index.ts index f2212018fe..f7e5d49cbb 100644 --- a/src/claude/commands/cmux/index.ts +++ b/src/claude/commands/cmux/index.ts @@ -43,6 +43,10 @@ export function registerCmuxCommand(program: Command): void { .option("--no-activate", "Focus the pane without bringing the cmux app to the front") .option("--first", "Take the best match instead of asking when several panes match") .option("--include-self", "Also consider the pane this command runs in (excluded by default)") + .option( + "--exact", + "Focus only a pane proven to run the session (hook journal, resume command, id in the tab title), never one that only shows its id on screen" + ) .option("--dry-run", "Print what would be focused and stop") .option("--json", "Emit the match as JSON instead of a status line") .action(focusCommand); diff --git a/src/claude/commands/codex.ts b/src/claude/commands/codex.ts new file mode 100644 index 0000000000..99d4a8c291 --- /dev/null +++ b/src/claude/commands/codex.ts @@ -0,0 +1,476 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { resolveProxyAccountEntry } from "@app/ai-proxy/lib/account-refs"; +import { loadConfigFresh } from "@app/ai-proxy/lib/config"; +import { + closeSessionRecord, + listSessionRecords, + type MeteredConfirmation, + openSessionRecord, + SESSION_ACCOUNT_FIELD, + SESSION_LOGIN_FIELD, + type SessionAgent, + updateSessionRecord, +} from "@app/ai-proxy/lib/sessions/records"; +import type { AiProxyConfig } from "@app/ai-proxy/lib/types"; +import { + agentsArgs, + claudeCodexConfigRoot, + sessionAgents, + writeAgentFiles, +} from "@app/claude/lib/codex-session/agents"; +import { + agentAccounts, + autoAccount, + claudeModelByAlias, + isError, + namedAccount, + passthroughSets, + type Resolved, + resolveSessionEffort, + resolveSessionModel, + SESSION_MODEL_ALIASES, + type SessionAccountChoice, + type SessionLogin, + sessionArgs, + sessionEnv, +} from "@app/claude/lib/codex-session/launch"; +import { ensureOnboardingSkippedForOAuthToken } from "@app/claude/lib/launch-env"; +import { scoreAccounts, sortGrouped } from "@app/claude/lib/usage/account-picker"; +import { getSharedAccountsUsage } from "@app/claude/lib/usage/shared-cache"; +import { pickSmart } from "@app/claude/lib/usage/smart-alias"; +import * as p from "@clack/prompts"; +import { AiConfigStore } from "@genesiscz/utils/ai/config/AiConfigStore"; +import { mergeCodexModelRecords, readCodexModelsCache } from "@genesiscz/utils/ai/openai/codex-models"; +import { resolveOpenAiSubModel } from "@genesiscz/utils/ai/openai/sub-models"; +import { pollAccounts } from "@genesiscz/utils/ai/usage-poll/poll"; +import { isInteractive } from "@genesiscz/utils/cli"; +import { logger, out } from "@genesiscz/utils/logger"; +import { processStartMs } from "@genesiscz/utils/process-identity"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { pickSessionForResume } from "./resume"; +import { ensureProxyUp, proxyBaseUrl, spawnClaude } from "./run"; + +interface CodexSessionOptions { + model?: string; + effort?: string; + claude?: string; + grok?: string; + compact?: string; + isolated?: boolean; + allowMetered: string[]; + resume?: string | boolean; + start?: boolean; + port?: string; +} + +/** The AI-config provider of each agent's logins. */ +const LOGIN_PROVIDER: Record<SessionAgent, string> = { + codex: "openai-sub", + claude: "anthropic-sub", + grok: "grok-sub", +}; + +async function fail(message: string): Promise<never> { + out.error(pc.red(message)); + await out.flush(); + process.exit(1); +} + +function agentLogins(store: AiConfigStore, agent: SessionAgent): SessionLogin[] { + return store + .accounts({ provider: LOGIN_PROVIDER[agent], enabled: true }) + .map((account) => ({ id: account.id, name: account.name })); +} + +/** Open sessions per login, so `auto` spreads parallel sessions. */ +function sessionsPerLogin(agent: SessionAgent): Record<string, number> { + const counts: Record<string, number> = {}; + + for (const record of listSessionRecords()) { + const login = record[SESSION_LOGIN_FIELD[agent]]; + + if (!record.closedAt && login) { + counts[login] = (counts[login] ?? 0) + 1; + } + } + + return counts; +} + +/** The login a choice bills: its own, or the one its configured proxy account is linked to. */ +function choiceLogin(choice: SessionAccountChoice, store: AiConfigStore): string | undefined { + return ( + choice.login ?? (choice.proxyAccount ? resolveProxyAccountEntry(choice.proxyAccount, store)?.name : undefined) + ); +} + +async function codexOrGrokAccount( + agent: "codex" | "grok", + spec: string, + config: AiProxyConfig, + store: AiConfigStore +): Promise<Resolved<SessionAccountChoice>> { + const proxyAccounts = agentAccounts(config.accounts, agent); + const logins = agentLogins(store, agent); + + if (spec !== "auto") { + return namedAccount(spec, proxyAccounts, logins, agent); + } + + return autoAccount({ + agent, + logins, + snapshots: await pollAccounts({ providers: [agent] }), + sessions: sessionsPerLogin(agent), + // A configured account with no login link (the legacy Codex account on ~/.codex/auth.json). + fallback: proxyAccounts.filter((account) => !resolveProxyAccountEntry(account, store)), + }); +} + +/** + * The Claude side keeps Claude's own scorer: `auto` is the `cc opus` pick (weekly Opus headroom) among the Claude + * logins. `hasFable` comes from the same usage reading. + */ +async function claudeAccount( + spec: string, + config: AiProxyConfig, + store: AiConfigStore +): Promise<Resolved<SessionAccountChoice & { hasFable: boolean }>> { + const logins = agentLogins(store, "claude"); + const usage = + logins.length > 0 + ? await getSharedAccountsUsage({ accountFilter: logins.map((login) => login.name) }).catch((error) => { + logger.warn({ error }, "[codex] Claude usage unavailable; auto cannot rank the Claude logins"); + return []; + }) + : []; + const scored = sortGrouped(scoreAccounts(usage, { modelFamily: "opus" })); + const fableOf = (login: string | undefined) => scored.some((row) => row.accountName === login && row.limits?.fable); + + if (spec !== "auto") { + const named = namedAccount(spec, agentAccounts(config.accounts, "claude"), logins, "claude"); + return isError(named) ? named : { ...named, hasFable: fableOf(choiceLogin(named, store)) }; + } + + const pick = pickSmart("opus", scored); + + if (!pick) { + return { + error: "auto found no Claude login with Opus headroom. Name one with --claude, or pass --claude none.", + }; + } + + const runnerUp = scored.find((row) => row.accountName !== pick.accountName && row.tier === "ready"); + return { + login: pick.accountName, + label: pick.accountName, + how: "auto", + why: pick.line, + ...(runnerUp ? { runnerUp: `${runnerUp.accountName} (${runnerUp.why})` } : {}), + metered: false, + hasFable: fableOf(pick.accountName), + }; +} + +function describeChoice(agent: string, choice: SessionAccountChoice): string { + const how = choice.how === "named" ? "" : ` ${pc.dim(`(${choice.how}${choice.why ? `: ${choice.why}` : ""})`)}`; + const kind = choice.proxyAccount ? pc.dim(" (configured proxy account)") : ""; + const runnerUp = choice.runnerUp ? `\n ${pc.dim(`runner-up: ${choice.runnerUp}`)}` : ""; + return `${pc.cyan("▸")} ${agent} → ${pc.bold(choice.label)}${kind}${how}${runnerUp}`; +} + +/** The record fields for one agent: the configured proxy account and the login, whichever the choice has. */ +function recordFields(agent: SessionAgent, choice: SessionAccountChoice | undefined, store: AiConfigStore) { + if (!choice) { + return {}; + } + + const login = choiceLogin(choice, store); + return { + ...(choice.proxyAccount ? { [SESSION_ACCOUNT_FIELD[agent]]: choice.proxyAccount.name } : {}), + ...(login ? { [SESSION_LOGIN_FIELD[agent]]: login } : {}), + }; +} + +/** P6: a metered account needs `--allow-metered <account>` or a yes, and a non-interactive launch without the flag fails. */ +async function confirmMetered(choices: SessionAccountChoice[], allowed: string[]): Promise<MeteredConfirmation[]> { + const confirmations: MeteredConfirmation[] = []; + + for (const choice of choices) { + if (!choice.metered || !choice.proxyAccount) { + continue; + } + + const name = choice.proxyAccount.name; + out.printlnErr( + pc.yellow(`⚠ "${name}" (${choice.proxyAccount.provider}) is billed per token, not a subscription.`) + ); + + if (allowed.includes(name)) { + confirmations.push({ account: name, confirmedAt: new Date().toISOString(), how: "flag" }); + continue; + } + + if (!isInteractive()) { + await fail(`Refusing to bill "${name}" per token without --allow-metered ${name}.`); + } + + const answer = await p.confirm({ message: `Bill "${name}" per token for this session?`, initialValue: false }); + + if (p.isCancel(answer) || answer !== true) { + await fail(`Not launched: "${name}" is billed per token.`); + } + + confirmations.push({ account: name, confirmedAt: new Date().toISOString(), how: "prompt" }); + } + + return confirmations; +} + +function compactPercent(raw: string | undefined, fallback: number): Resolved<{ percent: number }> { + if (raw === undefined) { + return { percent: fallback }; + } + + const percent = Number.parseInt(raw, 10); + return Number.isInteger(percent) && percent >= 1 && percent <= 100 && String(percent) === raw.trim() + ? { percent } + : { error: `--compact takes a whole percent from 1 to 100, got "${raw}".` }; +} + +export async function runCodexSession( + accountSpec: string | undefined, + opts: CodexSessionOptions, + passthrough: string[] +): Promise<void> { + if (opts.model && passthroughSets(passthrough, "--model")) { + await fail("Pass the model once: -m, or --model after --, not both."); + } + + if (opts.effort && passthroughSets(passthrough, "--effort")) { + await fail("Pass the effort once: -e, or --effort after --, not both."); + } + + const loaded = await loadConfigFresh(); + const port = opts.port === undefined ? undefined : Number.parseInt(opts.port, 10); + + if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536)) { + return fail(`--port takes a TCP port, got "${opts.port}".`); + } + + // Another proxy (a second one for tests) is never started by the launcher: only the configured one is. + const config = port === undefined ? loaded : { ...loaded, listen: { ...loaded.listen, port } }; + const catalog = mergeCodexModelRecords({ cache: readCodexModelsCache(), overrides: config.codexModels ?? {} }); + const codexModel = (slug: string) => catalog.get(slug); + const model = resolveSessionModel(opts.model, codexModel); + + if (isError(model)) { + return fail(model.error); + } + + const effort = resolveSessionEffort(model.info, opts.effort); + + if (isError(effort)) { + return fail(effort.error); + } + + const compact = compactPercent(opts.compact, model.info.compactPercent); + + if (isError(compact)) { + return fail(compact.error); + } + + const store = await AiConfigStore.readOnly(); + const codex = await codexOrGrokAccount("codex", accountSpec ?? "auto", config, store); + + if (isError(codex)) { + return fail(codex.error); + } + + const claudeSpec = opts.claude ?? "auto"; + const claude = claudeSpec === "none" ? undefined : await claudeAccount(claudeSpec, config, store); + + if (claude && isError(claude)) { + return fail(claude.error); + } + + const grok = opts.grok ? await codexOrGrokAccount("grok", opts.grok, config, store) : undefined; + + if (grok && isError(grok)) { + return fail(grok.error); + } + + out.printlnErr(describeChoice("codex", codex)); + + if (claude) { + out.printlnErr(describeChoice("claude", claude)); + } else { + out.printlnErr( + pc.yellow( + "⚠ No Claude account: the opus subagent, Claude models and Claude Code's auto-mode classifier are refused." + ) + ); + } + + if (grok) { + out.printlnErr(describeChoice("grok", grok)); + } + + const metered = await confirmMetered( + [codex, ...(claude ? [claude] : []), ...(grok ? [grok] : [])], + opts.allowMetered + ); + + await ensureProxyUp(config, opts.start !== false && port === undefined); + await ensureOnboardingSkippedForOAuthToken(); + + const resumed = + opts.resume === undefined || opts.resume === false + ? undefined + : await pickSessionForResume(typeof opts.resume === "string" ? opts.resume : undefined, { + allProjects: false, + }); + const sessionId = resumed?.sessionId ?? randomUUID(); + const codexLogin = choiceLogin(codex, store); + const claudeLogin = claude ? choiceLogin(claude, store) : undefined; + const agents = sessionAgents({ + codexModel, + ...(claude ? { claude: { fable: claude.hasFable } } : {}), + ...(codexLogin ? { codexAccount: codexLogin } : {}), + }); + const configDir = opts.isolated ? join(claudeCodexConfigRoot(), sessionId) : undefined; + + if (configDir) { + mkdirSync(configDir, { recursive: true }); + writeAgentFiles(configDir, agents); + } + + const agentArgs = configDir ? [] : agentsArgs(passthrough, agents); + const ownAgents = configDir || agentArgs.length > 0 ? Object.keys(agents) : undefined; + const { key, reopened } = openSessionRecord({ + id: sessionId, + ...recordFields("codex", codex, store), + ...recordFields("claude", claude, store), + ...recordFields("grok", grok, store), + cwd: process.cwd(), + launcherPid: process.pid, + model: model.slug, + effort: effort.effort, + window: model.info.window, + compactPercent: compact.percent, + cacheKeyPolicy: "claude-session", + ...(ownAgents ? { agents: ownAgents } : {}), + meteredConfirmed: metered, + }); + const claudeIds = claude + ? { + haiku: claudeModelByAlias("haiku"), + sonnet: claudeModelByAlias("sonnet"), + opus: claudeModelByAlias("opus"), + } + : undefined; + const subscriptionType = claudeLogin + ? (store.account(claudeLogin)?.credentials.secondary?.subscriptionType ?? undefined) + : undefined; + const launchEnv = sessionEnv(process.env, { + baseUrl: proxyBaseUrl(config), + key, + model: `codex/${model.slug}`, + ...(claudeIds?.haiku && claudeIds.opus + ? { + claude: { + haiku: claudeIds.haiku, + ...(claudeIds.sonnet ? { sonnet: claudeIds.sonnet } : {}), + opus: claudeIds.opus, + ...(subscriptionType ? { subscriptionType } : {}), + }, + } + : {}), + smallCodexModel: `codex/${resolveOpenAiSubModel("luna")}:low`, + maxContextTokens: model.info.usableTokens, + compactPercent: compact.percent, + accountLabel: [`codex:${codex.label}`, ...(claude ? [`claude:${claude.label}`] : [])].join("+"), + }); + + if (configDir) { + launchEnv.CLAUDE_CONFIG_DIR = configDir; + } + + const args = sessionArgs({ + sessionId, + resume: resumed !== undefined, + effort: effort.effort, + agents: agentArgs, + settingsEnv: { + CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(model.info.usableTokens), + CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(model.info.usableTokens), + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(compact.percent), + }, + passthrough, + }); + + out.printlnErr( + pc.dim( + `${reopened ? "Resuming" : "Starting"} Claude Code on ${pc.magenta(`codex/${model.slug}`)} at ${effort.effort}, ` + + `window ${Math.round(model.info.usableTokens / 1000)}k, compact at ${compact.percent}%, session ${sessionId}` + + (configDir ? `, config ${configDir}` : "") + ) + ); + logger.info( + { + sessionId, + codex: codex.label, + claude: claude?.label, + model: model.slug, + effort: effort.effort, + }, + "[codex] launching a claude codex session" + ); + + const code = await spawnClaude(args, launchEnv, (pid) => { + updateSessionRecord(sessionId, (record) => { + record.claudePid = pid; + record.claudePidStartMs = processStartMs(pid) ?? undefined; + }); + }); + + closeSessionRecord(sessionId, "exited"); + process.exit(code); +} + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +export function registerCodexCommand(program: Command): void { + program + .command("codex [account]") + .description( + "Claude Code with its main loop on a Codex model through ai-proxy: Claude-side calls and the opus subagent " + + "on a Claude account, one subagent per Codex model, a session record with its own key. [account] is a " + + "Codex proxy account, the login one bills, or auto (default). Args after -- go to claude." + ) + .option("-m, --model <model>", `Codex model: ${SESSION_MODEL_ALIASES.join(", ")} (default sol) or a catalog id`) + .option("-e, --effort <effort>", "Effort the model has (default high); /effort in Claude Code changes it later") + .option("--claude <account>", "Claude proxy account or login, auto (default), or none") + .option("--grok <account>", "Grok proxy account or login, or auto; grok/<model> ids then work in the session") + .option("-c, --compact <percent>", "Compact at this percent of the usable window (default: the model's)") + .option( + "--isolated", + `Own Claude Code config folder under ${claudeCodexConfigRoot()}, agent files written there` + ) + .option("--allow-metered <account>", "Allow an account billed per token (repeatable)", collect, []) + .option("-r, --resume [query]", "Resume a session: an id or a search query (bare: the picker)") + .option("--no-start", "Fail if ai-proxy is not running instead of starting it") + .option("--port <port>", "Use the ai-proxy on this port instead of the configured one (never started for you)") + .allowExcessArguments(true) + .action(async (account: string | undefined, opts: CodexSessionOptions, command: Command) => { + const operands = command.args; + const named = account?.startsWith("-") ? undefined : account; + const passthrough = named === undefined ? operands : operands.slice(1); + await runCodexSession(named, opts, passthrough); + }); +} diff --git a/src/claude/commands/run.ts b/src/claude/commands/run.ts index 543fcddb25..c48c399f44 100644 --- a/src/claude/commands/run.ts +++ b/src/claude/commands/run.ts @@ -174,7 +174,36 @@ async function pickModel(candidates: ProxyModelMeta[], hint: string): Promise<Pr return picked; } -async function ensureProxyUp(config: AiProxyConfig, autoStart: boolean): Promise<void> { +/** Claude Code appends /v1/messages itself, so the base URL is the proxy's origin. */ +export function proxyBaseUrl(config: AiProxyConfig): string { + return `http://${config.listen.host}:${config.listen.port}`; +} + +/** + * Run the user's `claude` (their alias or function, through an interactive shell) with these arguments and this + * environment; resolves with its exit code. `onStart` gets the pid, which is claude's own after the shell's `exec`. + */ +export async function spawnClaude( + args: string[], + launchEnv: Record<string, string | undefined>, + onStart?: (pid: number) => void +): Promise<number> { + const shell = env.paths.getShell("/bin/sh"); + const cmd = await findClaudeCommand(); + const suffix = args.length > 0 ? ` ${args.map(shellQuote).join(" ")}` : ""; + logger.info({ cmd, args: args.length }, "[run] spawning claude"); + + const proc = Bun.spawn({ + cmd: [shell, "-ic", `exec ${cmd}${suffix}`], + stdio: ["inherit", "inherit", "inherit"], + env: launchEnv, + }); + onStart?.(proc.pid); + + return proc.exited; +} + +export async function ensureProxyUp(config: AiProxyConfig, autoStart: boolean): Promise<void> { const healthUrl = `http://${config.listen.host}:${config.listen.port}/health`; try { @@ -283,8 +312,7 @@ export async function runProxySession( await ensureProxyUp(config, opts.start !== false); await ensureOnboardingSkippedForOAuthToken(); - // Claude Code appends /v1/messages itself, so the base URL is the origin. - const baseUrl = `http://${config.listen.host}:${config.listen.port}`; + const baseUrl = proxyBaseUrl(config); const launchEnv: Record<string, string | undefined> = { ...process.env, @@ -317,10 +345,6 @@ export async function runProxySession( delete launchEnv.CLAUDE_CODE_SUBSCRIPTION_TYPE; delete launchEnv.ANTHROPIC_DEFAULT_FABLE_MODEL; - const shell = env.paths.getShell("/bin/sh"); - const cmd = await findClaudeCommand(); - const suffix = passthrough.length > 0 ? ` ${passthrough.map(shellQuote).join(" ")}` : ""; - // A session silently running at the model's default effort looks identical to // one running at the effort you asked for, so say which it is. const effortNote = opts.effort ? `effort ${opts.effort}` : "effort: model default"; @@ -328,15 +352,9 @@ export async function runProxySession( out.printlnErr( pc.dim(`Starting Claude Code on ${pc.magenta(model.proxyId)} (${effortNote}${ctxNote}) via ${baseUrl}...`) ); - logger.info({ cmd, model: model.proxyId, baseUrl, passthrough }, "[run] spawning claude against ai-proxy"); - - const proc = Bun.spawn({ - cmd: [shell, "-ic", `exec ${cmd}${suffix}`], - stdio: ["inherit", "inherit", "inherit"], - env: launchEnv, - }); + logger.info({ model: model.proxyId, baseUrl, passthrough }, "[run] spawning claude against ai-proxy"); - process.exit(await proc.exited); + process.exit(await spawnClaude(passthrough, launchEnv)); } export function registerRunCommand(program: Command): void { diff --git a/src/claude/commands/start.ts b/src/claude/commands/start.ts index d9019d7c37..8e85f56e47 100644 --- a/src/claude/commands/start.ts +++ b/src/claude/commands/start.ts @@ -50,6 +50,7 @@ interface StartOptions { pick?: boolean; autopick?: boolean; model?: string; + effort?: string; resume?: string | boolean; continue?: boolean; keychain?: boolean; @@ -1228,6 +1229,10 @@ export function registerStartCommand(program: Command): void { .option("--pick", "Pick the account from a usage-ranked list (best first, with reasoning)") .option("-a, --autopick", "Auto-pick the best account by usage headroom heuristic") .option("-m, --model <spec>", "Model to launch: alias or substring filter (fable, opus, 4.8 1m, ...)") + .option( + "-e, --effort <level>", + "Reasoning effort: claude's own --effort, or the :<effort> suffix for an ai-proxy target (account/provider)" + ) .option("-r, --resume [query]", "Resume a session: bare uses claude's own picker, query searches locally") .option("-c, --continue", "Continue the most recent session") .option( @@ -1268,14 +1273,24 @@ export function registerStartCommand(program: Command): void { return; } - const passthrough = resolved.args; + const effortGiven = resolved.args.some((arg) => arg === "--effort" || arg.startsWith("--effort=")); + + if (opts.effort && effortGiven) { + out.error("Pass the effort once: -e, or --effort after --, not both."); + process.exitCode = 2; + await out.flush(); + return; + } + + const passthrough = + opts.effort && !nameArg?.includes("/") ? [...resolved.args, "--effort", opts.effort] : resolved.args; // No Anthropic account name contains a slash, but every ai-proxy target // does (`martin/grok`, `work/xai/grok-4.6`). That makes the split // unambiguous, so `claude run martin/grok -m 4.6` reaches the proxy // launcher without a second command name to remember. if (nameArg?.includes("/")) { - await runProxySession(nameArg, { model: opts.model }, passthrough); + await runProxySession(nameArg, { model: opts.model, effort: opts.effort }, passthrough); return; } diff --git a/src/claude/lib/codex-session/agents.test.ts b/src/claude/lib/codex-session/agents.test.ts new file mode 100644 index 0000000000..d9aadfec6b --- /dev/null +++ b/src/claude/lib/codex-session/agents.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + agentsArgs, + claudeCodexConfigRoot, + sessionAgents, + writeAgentFiles, +} from "@app/claude/lib/codex-session/agents"; +import { type CodexModelInfo, mergeCodexModelRecords } from "@genesiscz/utils/ai/openai/codex-models"; +import { SafeJSON } from "@genesiscz/utils/json"; + +const catalog = mergeCodexModelRecords({ cache: [] }); +const codexModel = (slug: string): CodexModelInfo | undefined => catalog.get(slug); + +describe("sessionAgents", () => { + it("lists the Codex agents with their own windows, the codex-worker, and no Claude agent without a Claude account", () => { + const agents = sessionAgents({ codexModel }); + + expect(Object.keys(agents)).toEqual(["sol", "sol-xhigh", "sol6", "astra", "luna", "codex-worker"]); + expect(agents.sol.model).toBe("codex/gpt-6.1-sol:high"); + expect(agents["sol-xhigh"].model).toBe("codex/gpt-6.1-sol:xhigh"); + expect(agents.sol.autoCompactWindow).toBe(codexModel("gpt-6.1-sol")?.usableTokens); + // A 272K model gets its own window, not the main loop's. + expect(agents.luna.autoCompactWindow).toBe(codexModel("gpt-6-luna")?.usableTokens); + expect(agents.luna.autoCompactWindow).toBeLessThan(agents.sol.autoCompactWindow ?? 0); + expect(agents.sol.disallowedTools).toEqual(["Agent"]); + expect(agents["codex-worker"].tools).toEqual(["Bash", "Read", "Write"]); + }); + + it("adds opus on the Claude account ([1m] only where the model is not natively 1M), and fable only with Fable", () => { + const withClaude = sessionAgents({ codexModel, claude: { fable: false } }); + + expect(withClaude.opus.model).toMatch(/^claude-opus-[\d-]+(\[1m\])?$/); + expect(withClaude.opus.effort).toBe("high"); + expect(withClaude.fable).toBeUndefined(); + expect(sessionAgents({ codexModel, claude: { fable: true } }).fable?.model).toMatch(/^claude-fable-/); + }); + + it("pins the codex-worker to the session's Codex login when it has one", () => { + expect(sessionAgents({ codexModel, codexAccount: "work" })["codex-worker"].prompt).toContain(" -a work`"); + expect(sessionAgents({ codexModel })["codex-worker"].prompt).not.toContain(" -a "); + }); + + it("skips an agent whose effort the model does not have", () => { + const noXhigh = (slug: string): CodexModelInfo | undefined => { + const info = codexModel(slug); + return info ? { ...info, efforts: info.efforts.filter((effort) => effort !== "xhigh") } : undefined; + }; + + expect(sessionAgents({ codexModel: noXhigh })["sol-xhigh"]).toBeUndefined(); + }); +}); + +describe("agentsArgs", () => { + const agents = sessionAgents({ codexModel }); + + it("passes --agents JSON, unless the user passed their own after --", () => { + const args = agentsArgs(["--verbose"], agents); + + expect(args[0]).toBe("--agents"); + expect(Object.keys(SafeJSON.parse(args[1] ?? "{}"))).toContain("codex-worker"); + expect(agentsArgs(["--agents", "{}"], agents)).toEqual([]); + expect(agentsArgs(["--agents={}"], agents)).toEqual([]); + }); +}); + +describe("writeAgentFiles", () => { + const agents = sessionAgents({ codexModel, claude: { fable: false } }); + + it("writes agent files into an isolated config folder", () => { + const dir = join(claudeCodexConfigRoot(), "test-session"); + const files = writeAgentFiles(dir, agents); + const sol = readFileSync(join(dir, "agents", "sol.md"), "utf8"); + + expect(files).toHaveLength(Object.keys(agents).length); + expect(sol).toStartWith('---\nname: "sol"\n'); + expect(sol).toContain('model: "codex/gpt-6.1-sol:high"'); + expect(sol).toContain("disallowedTools: Agent"); + }); + + it("refuses any folder outside the config root (a home folder, a project) and the root itself", () => { + for (const dir of [ + homedir(), + process.cwd(), + claudeCodexConfigRoot(), + join(claudeCodexConfigRoot(), "..", "x"), + ]) { + expect(() => writeAgentFiles(dir, agents)).toThrow("agent files are written only into a folder under"); + } + }); +}); diff --git a/src/claude/lib/codex-session/agents.ts b/src/claude/lib/codex-session/agents.ts new file mode 100644 index 0000000000..e34d183105 --- /dev/null +++ b/src/claude/lib/codex-session/agents.ts @@ -0,0 +1,245 @@ +/** + * The subagents of a `tools claude codex` session (P14): one generator, two writers. Writer 1 is the `--agents` flag, + * passed on every launch and resume because Claude Code does not keep it. Writer 2 writes the same definitions as + * agent files, only into an isolated `CLAUDE_CONFIG_DIR` under `~/.genesis-tools/claude-codex/config/`. + * + * Every model id here is a session route form (`codex/<id>:<effort>`, a bare `claude-*` id), which ai-proxy sends to + * the accounts in the session record. The `:effort` suffix wins over the subagent's own effort setting, so the + * Claude Code `effort` field only matters for the Claude ones. + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { CLAUDE_MODELS } from "@app/claude/lib/models"; +import type { CodexModelInfo } from "@genesiscz/utils/ai/openai/codex-models"; +import { resolveOpenAiSubModel } from "@genesiscz/utils/ai/openai/sub-models"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { genesisToolsDir } from "@genesiscz/utils/storage/root"; + +/** A `claude --agents` entry, with the fields Claude Code 2.1.296 takes. */ +export interface SessionAgentDefinition { + description: string; + prompt: string; + model: string; + effort?: string; + /** Absent: the session's tools. */ + tools?: string[]; + disallowedTools?: string[]; + /** The subagent's own window in tokens, so a 272K Codex model does not inherit the main loop's 500K. */ + autoCompactWindow?: number; +} + +export interface SessionAgentsInput { + /** The Codex model catalog (`codexModelInfo`), for each model's window and efforts. */ + codexModel: (slug: string) => CodexModelInfo | undefined; + /** Set when the session has a Claude account: adds `opus`, and `fable` when that account has Fable. */ + claude?: { fable: boolean }; + /** The Codex login the `codex-worker` pins its worker to (`tools codex spawn -a`). */ + codexAccount?: string; +} + +interface CodexAgentSpec { + name: string; + /** A built-in alias of `sub-models.ts`. */ + alias: string; + effort: string; + use: string; +} + +/** Descriptions follow the Codex catalog's own (`~/.codex/models_cache.json`, read 2026-10-25). */ +const CODEX_AGENTS: CodexAgentSpec[] = [ + { + name: "sol", + alias: "sol", + effort: "high", + use: "The default Codex worker for well-specified coding, refactoring, test and research tasks.", + }, + { + name: "sol-xhigh", + alias: "sol", + effort: "xhigh", + use: "For hard or high-stakes work (security, auth, data integrity, a subtle algorithm) where depth matters more than speed.", + }, + { + name: "sol6", + alias: "sol6", + effort: "high", + use: "The previous Sol generation, for a second Codex opinion.", + }, + { + name: "astra", + alias: "astra", + effort: "high", + use: "OpenAI's frontier model, for the most demanding problems. Slower than sol and uses more of the Codex limits.", + }, + { + name: "luna", + alias: "luna", + effort: "high", + use: "A fast and affordable model for easy, quick tasks.", + }, +]; + +const CODEX_PROMPT = [ + "You are a worker subagent of a Claude Code session, running on a Codex model through ai-proxy. The session that spawned you reads only your final message.", + "", + "- Do the task in your brief yourself. Never spawn agents or workflows.", + "- Change files only when the brief asks for changes. Never commit, push or merge unless the brief says so.", + "", + "Final message: a first line `STATUS: done`, `STATUS: blocked` or `STATUS: failed`, then a short summary, the files you changed, and the checks you ran with their results.", +].join("\n"); + +const CLAUDE_PROMPT = [ + "You are Claude, called by a Claude Code session whose main loop runs on a Codex model. The session reads only your final message, so keep it short.", + "", + "Work the brief to its end. Change files only when the brief asks for changes.", + "", + "Final message: a first line `STATUS: done`, `STATUS: blocked` or `STATUS: failed`, then what you found or changed (with file paths), and what the session must decide.", +].join("\n"); + +function codexWorkerPrompt(codexAccount: string | undefined): string { + const account = codexAccount ? ` -a ${codexAccount}` : ""; + + return [ + "You hand one task to a headless Codex CLI worker and report its result. Do not do the task yourself.", + "", + "1. Write the brief you were given, unchanged, to a new file under $TMPDIR.", + `2. Start the worker: \`${toolCommand("codex spawn")} --name <a short unique name> --prompt-file <that file> --cwd <the folder the brief names, else the current one>${account}\`. Add \`--write allow\` when the brief asks for file changes.`, + `3. Wait for its turn to end: \`${toolCommand("codex wait")} <name>\`. Exit 0 means it ended; 3 means it stalled, which you report.`, + `4. Read the result with \`${toolCommand("codex read")} --name <name>\` and take the worker's final message.`, + `5. Stop the worker: \`${toolCommand("codex stop")} --name <name>\`.`, + "", + "Final message: the worker's name, its final message unchanged, and any error from these commands.", + ].join("\n"); +} + +/** The Claude Code id of a Claude model by its catalog alias, with `[1m]` where the model needs the suffix for its 1M window (P5). */ +function claudeModelId(alias: string): string | undefined { + const model = CLAUDE_MODELS.find((item) => item.aliases?.includes(alias)); + + if (!model) { + return undefined; + } + + return model.supports1m ? `${model.id}[1m]` : model.id; +} + +/** The session's subagents: the Codex models of P3, the `codex-worker`, and Opus (and Fable) on the Claude account. */ +export function sessionAgents(input: SessionAgentsInput): Record<string, SessionAgentDefinition> { + const agents: Record<string, SessionAgentDefinition> = {}; + + for (const spec of CODEX_AGENTS) { + const slug = resolveOpenAiSubModel(spec.alias); + const info = input.codexModel(slug); + + if (info && !info.efforts.includes(spec.effort)) { + continue; + } + + const name = info?.displayName ?? slug; + agents[spec.name] = { + description: `Codex ${name} at ${spec.effort} effort. ${spec.use} Bills the session's Codex account.`, + prompt: CODEX_PROMPT, + model: `codex/${slug}:${spec.effort}`, + disallowedTools: ["Agent"], + ...(info ? { autoCompactWindow: info.usableTokens } : {}), + }; + } + + const lunaSlug = resolveOpenAiSubModel("luna"); + const luna = input.codexModel(lunaSlug); + agents["codex-worker"] = { + description: `Hands a task to a headless Codex CLI worker (\`${toolCommand("codex spawn")}\`), waits for it and reports its final message. For long or independent work that should run in the Codex CLI's own sandbox instead of this session.`, + prompt: codexWorkerPrompt(input.codexAccount), + model: `codex/${lunaSlug}:low`, + tools: ["Bash", "Read", "Write"], + ...(luna ? { autoCompactWindow: luna.usableTokens } : {}), + }; + + if (!input.claude) { + return agents; + } + + const opus = claudeModelId("opus"); + + if (opus) { + agents.opus = { + description: + "Claude Opus at high effort, for work that wants Claude's judgment: a hard design call, root-causing a subtle bug, a careful review, UI and frontend craft, or a second opinion. Bills the session's Claude account.", + prompt: CLAUDE_PROMPT, + model: opus, + effort: "high", + }; + } + + const fable = input.claude.fable ? claudeModelId("fable") : undefined; + + if (fable) { + agents.fable = { + description: + "Claude Fable at high effort, Anthropic's strongest model, for the hardest reasoning when Opus is not enough. Bills the session's Claude account.", + prompt: CLAUDE_PROMPT, + model: fable, + effort: "high", + }; + } + + return agents; +} + +/** Writer 1: the `--agents` arguments, or none when the user passed `--agents` after `--` (the user's wins). */ +export function agentsArgs(passthrough: string[], agents: Record<string, SessionAgentDefinition>): string[] { + if (passthrough.some((arg) => arg === "--agents" || arg.startsWith("--agents="))) { + return []; + } + + return ["--agents", SafeJSON.stringify(agents)]; +} + +/** Where `tools claude codex --isolated` keeps its own Claude Code config folders. */ +export function claudeCodexConfigRoot(): string { + return genesisToolsDir("claude-codex", "config"); +} + +function frontmatterValue(value: string | number | string[]): string { + if (Array.isArray(value)) { + return value.join(", "); + } + + return typeof value === "number" ? String(value) : SafeJSON.stringify(value); +} + +/** + * Writer 2: the same definitions as `<configDir>/agents/<name>.md`. Refuses any folder outside + * `claudeCodexConfigRoot()`, so it never writes into `~/.claude/agents` or a project. Returns the files written. + */ +export function writeAgentFiles(configDir: string, agents: Record<string, SessionAgentDefinition>): string[] { + const root = claudeCodexConfigRoot(); + const target = resolve(configDir); + const inside = relative(root, target); + + if (!inside || inside.startsWith("..") || isAbsolute(inside)) { + throw new Error(`agent files are written only into a folder under ${root}, not ${target}`); + } + + const dir = join(target, "agents"); + mkdirSync(dir, { recursive: true }); + + return Object.entries(agents).map(([name, agent]) => { + const fields: Array<[string, string | number | string[] | undefined]> = [ + ["name", name], + ["description", agent.description], + ["model", agent.model], + ["effort", agent.effort], + ["tools", agent.tools], + ["disallowedTools", agent.disallowedTools], + ["autoCompactWindow", agent.autoCompactWindow], + ]; + const header = fields + .filter((field): field is [string, string | number | string[]] => field[1] !== undefined) + .map(([key, value]) => `${key}: ${frontmatterValue(value)}`); + const file = join(dir, `${name}.md`); + writeFileSync(file, `---\n${header.join("\n")}\n---\n\n${agent.prompt}\n`); + return file; + }); +} diff --git a/src/claude/lib/codex-session/launch.test.ts b/src/claude/lib/codex-session/launch.test.ts new file mode 100644 index 0000000000..63eefa072d --- /dev/null +++ b/src/claude/lib/codex-session/launch.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "bun:test"; +import type { AiProxyAccountConfig } from "@app/ai-proxy/lib/types"; +import { + autoAccount, + isError, + namedAccount, + resolveSessionEffort, + resolveSessionModel, + sessionArgs, + sessionEnv, +} from "@app/claude/lib/codex-session/launch"; +import { type CodexModelInfo, mergeCodexModelRecords } from "@genesiscz/utils/ai/openai/codex-models"; +import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/providers/account-features"; + +const catalog = mergeCodexModelRecords({ cache: [] }); +const codexModel = (slug: string): CodexModelInfo | undefined => catalog.get(slug); +const NOW = new Date("2026-10-25T20:00:00.000Z"); + +function account(name: string, provider: AiProxyAccountConfig["provider"]): AiProxyAccountConfig { + return { name, provider, providerSlug: provider === "openai-subscription" ? "codex" : "grok", enabled: true }; +} + +function snapshot(accountId: string, weekly: number): AccountUsageSnapshot { + return { + provider: "openai-sub", + accountId, + accountName: `login-${accountId}`, + fetchedAt: NOW.toISOString(), + limits: [ + { key: "primary", label: "5h", kind: "session", percentUsed: 10 }, + { key: "secondary", label: "Weekly", kind: "weekly", percentUsed: weekly }, + ], + }; +} + +describe("model and effort", () => { + it("defaults to sol at high, takes the P3 aliases, and refuses an unknown model", () => { + const sol = resolveSessionModel(undefined, codexModel); + const astra = resolveSessionModel("astra", codexModel); + + expect(isError(sol) ? sol.error : sol.slug).toBe("gpt-6.1-sol"); + expect(isError(astra) ? astra.error : astra.slug).toBe("gpt-6-astra"); + expect(resolveSessionModel("gpt-9-nope", codexModel)).toEqual({ + error: expect.stringContaining("sol, sol6, astra, luna"), + }); + }); + + it("passes max, maps ultra like codex-rs, and refuses an effort the model lacks", () => { + const info = codexModel("gpt-6.1-sol"); + + if (!info) { + throw new Error("the static catalog lost gpt-6.1-sol"); + } + + expect(resolveSessionEffort(info, undefined)).toEqual({ effort: "high" }); + expect(resolveSessionEffort(info, "max")).toEqual({ effort: "max" }); + expect(resolveSessionEffort(info, "ultra")).toEqual({ effort: "xhigh" }); + expect(resolveSessionEffort(info, "minimal")).toEqual({ error: expect.stringContaining("Allowed:") }); + }); +}); + +describe("accounts (❓ DECISION 1 a: logins, with configured proxy accounts kept)", () => { + const proxyAccounts = [account("legacy", "openai-subscription"), account("billed", "openai")]; + const logins = [ + { id: "id-work", name: "work" }, + { id: "id-side", name: "side" }, + ]; + + it("names a configured proxy account first, then a login, and lists both when neither matches", () => { + const proxy = namedAccount("legacy", proxyAccounts, logins, "codex"); + const login = namedAccount("side", proxyAccounts, logins, "codex"); + const metered = namedAccount("billed", proxyAccounts, logins, "codex"); + + expect(isError(proxy) ? proxy.error : [proxy.proxyAccount?.name, proxy.login]).toEqual(["legacy", undefined]); + expect(isError(login) ? login.error : [login.proxyAccount, login.login, login.metered]).toEqual([ + undefined, + "side", + false, + ]); + expect(isError(metered) ? metered.error : metered.metered).toBe(true); + expect(namedAccount("nope", proxyAccounts, logins, "codex")).toEqual({ + error: expect.stringContaining("work, side, legacy, billed"), + }); + }); + + it("auto ranks the logins by their usage, with the runner-up", () => { + const picked = autoAccount({ + agent: "codex", + logins, + snapshots: [snapshot("id-work", 80), snapshot("id-side", 20), snapshot("id-stranger", 0)], + sessions: {}, + fallback: proxyAccounts, + now: NOW, + }); + + expect(isError(picked) ? picked.error : [picked.login, picked.how, picked.runnerUp]).toEqual([ + "side", + "auto", + expect.stringContaining("work"), + ]); + }); + + it("falls back on the one configured subscription account only when no login has usage data", () => { + const noData = autoAccount({ agent: "codex", logins, snapshots: [], sessions: {}, fallback: proxyAccounts }); + const blocked = autoAccount({ + agent: "codex", + logins, + snapshots: [snapshot("id-work", 100), snapshot("id-side", 100)], + sessions: {}, + fallback: proxyAccounts, + now: NOW, + }); + + expect(isError(noData) ? noData.error : [noData.proxyAccount?.name, noData.how]).toEqual(["legacy", "only"]); + expect(isError(blocked)).toBe(true); + }); +}); + +describe("environment and arguments", () => { + const inherited = { + PATH: "/usr/bin", + CLAUDE_CODE_OAUTH_TOKEN: "inherited-token", + ANTHROPIC_API_KEY: "inherited-key", + CLAUDE_CODE_EFFORT_LEVEL: "low", + CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "1", + CLAUDE_CODE_MAX_CONTEXT_TOKENS: "200000", + ANTHROPIC_AUTH_TOKEN: "gts_old", + }; + + it("sets the session's own values and drops what would bill elsewhere or override the subagents", () => { + const env = sessionEnv(inherited, { + baseUrl: "http://127.0.0.1:8317", + key: "gts_test", + model: "codex/gpt-6.1-sol", + claude: { haiku: "claude-haiku-x", opus: "claude-opus-x", subscriptionType: "max" }, + smallCodexModel: "codex/gpt-6-luna:low", + maxContextTokens: 475_000, + compactPercent: 90, + accountLabel: "codex:work+claude:personal", + }); + + expect(env).toEqual({ + PATH: "/usr/bin", + ANTHROPIC_BASE_URL: "http://127.0.0.1:8317", + ANTHROPIC_AUTH_TOKEN: "gts_test", + ANTHROPIC_MODEL: "codex/gpt-6.1-sol", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "claude-haiku-x", + ANTHROPIC_SMALL_FAST_MODEL: "claude-haiku-x", + ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-x", + CLAUDE_CODE_SUBSCRIPTION_TYPE: "max", + CLAUDE_CODE_MAX_CONTEXT_TOKENS: "475000", + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "475000", + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: "90", + TOOLS_CLAUDE_ACCOUNT: "proxy:codex:work+claude:personal", + }); + }); + + it("puts the small slot on a Codex model when the session has no Claude account", () => { + const env = sessionEnv( + {}, + { + baseUrl: "http://127.0.0.1:8317", + key: "gts_test", + model: "codex/gpt-6.1-sol", + smallCodexModel: "codex/gpt-6-luna:low", + maxContextTokens: 475_000, + compactPercent: 90, + accountLabel: "codex:work", + } + ); + + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("codex/gpt-6-luna:low"); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); + }); + + it("passes --session-id for a new session and --resume for a resumed one; the user's own --effort wins", () => { + const agents = ["--agents", "{}"]; + const settingsEnv = { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "475000" }; + + expect( + sessionArgs({ + sessionId: "s1", + resume: false, + effort: "high", + agents, + settingsEnv, + passthrough: ["-p", "hi"], + }) + ).toEqual([ + "--session-id", + "s1", + "--effort", + "high", + "--agents", + "{}", + "--settings", + '{"env":{"CLAUDE_CODE_AUTO_COMPACT_WINDOW":"475000"}}', + "-p", + "hi", + ]); + expect( + sessionArgs({ + sessionId: "s1", + resume: true, + effort: "high", + agents: [], + settingsEnv, + passthrough: ["--effort", "low", "--settings", "{}"], + }) + ).toEqual(["--resume", "s1", "--effort", "low", "--settings", "{}"]); + }); +}); diff --git a/src/claude/lib/codex-session/launch.ts b/src/claude/lib/codex-session/launch.ts new file mode 100644 index 0000000000..b2a840e6b5 --- /dev/null +++ b/src/claude/lib/codex-session/launch.ts @@ -0,0 +1,268 @@ +/** + * The pure parts of `tools claude codex` (P3, P6, P7, P12, P13, P15): the model and effort from the Codex catalog, the + * account for each agent, Claude Code's environment and arguments. The command (`commands/codex.ts`) does the I/O. + */ +import { SUBSCRIPTION_PROVIDER_TYPES } from "@app/ai-proxy/lib/clients"; +import type { SessionAgent } from "@app/ai-proxy/lib/sessions/records"; +import { agentOf } from "@app/ai-proxy/lib/sessions/view"; +import type { AiProxyAccountConfig } from "@app/ai-proxy/lib/types"; +import { CLAUDE_MODELS } from "@app/claude/lib/models"; +import { type CodexModelInfo, describeCodexEfforts, resolveCodexEffort } from "@genesiscz/utils/ai/openai/codex-models"; +import { resolveOpenAiSubModel } from "@genesiscz/utils/ai/openai/sub-models"; +import type { AccountUsageSnapshot } from "@genesiscz/utils/ai/providers/account-features"; +import { pickRanked, type RankedAccount, rankAccounts } from "@genesiscz/utils/ai/usage/account-rank"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { SafeJSON } from "@genesiscz/utils/json"; + +/** The model aliases P3 names; any catalog id works too. */ +export const SESSION_MODEL_ALIASES = ["sol", "sol6", "astra", "luna"] as const; +export const DEFAULT_SESSION_MODEL = "sol"; +/** switcheroo's `ccx` runs its main loop at high unless asked, and the generated subagents run at high. */ +export const DEFAULT_SESSION_EFFORT = "high"; + +export type Resolved<T> = T | { error: string }; + +export function isError<T>(value: Resolved<T>): value is { error: string } { + return typeof value === "object" && value !== null && "error" in value; +} + +export function resolveSessionModel( + spec: string | undefined, + codexModel: (slug: string) => CodexModelInfo | undefined +): Resolved<{ slug: string; info: CodexModelInfo }> { + const slug = resolveOpenAiSubModel(spec ?? DEFAULT_SESSION_MODEL); + const info = codexModel(slug); + + if (!info) { + return { + error: `Unknown Codex model "${spec}". Use ${SESSION_MODEL_ALIASES.join(", ")} or a catalog id (${toolCommand("codex models")} lists them).`, + }; + } + + return { slug, info }; +} + +/** The effort Claude Code gets as `--effort`: refused when the model lacks it (P13); `ultra` resolves per model. */ +export function resolveSessionEffort(info: CodexModelInfo, effort: string | undefined): Resolved<{ effort: string }> { + const resolved = resolveCodexEffort(info, effort ?? DEFAULT_SESSION_EFFORT); + + if (!resolved.ok) { + return { error: `${resolved.message} (${describeCodexEfforts(info)})` }; + } + + return { effort: resolved.effort }; +} + +/** + * One agent's account for the session (❓ DECISION 1 a, Martin 2026-10-25): a login, which the proxy turns into an + * account in memory, or a configured proxy account named on the command line (the legacy Codex account that reads + * ~/.codex/auth.json keeps working that way). + */ +export interface SessionAccountChoice { + login?: string; + proxyAccount?: AiProxyAccountConfig; + /** The proxy account's name or the login: what the user typed and sees. */ + label: string; + how: "named" | "auto" | "only"; + /** The ranking's reason for an `auto` pick. */ + why?: string; + runnerUp?: string; + /** Billed per token: needs `--allow-metered <account>` or a yes (P6). Only a configured account can be. */ + metered: boolean; +} + +/** A login of one agent: an AI-config account (`tools codex`, `tools claude`, `tools grok`). */ +export interface SessionLogin { + id: string; + name: string; +} + +/** The enabled proxy accounts an agent can run on. */ +export function agentAccounts(accounts: AiProxyAccountConfig[], agent: SessionAgent): AiProxyAccountConfig[] { + return accounts.filter((account) => account.enabled && agentOf(account.provider) === agent); +} + +export function isMetered(account: AiProxyAccountConfig): boolean { + return !SUBSCRIPTION_PROVIDER_TYPES.has(account.provider); +} + +/** A named account: a configured proxy account by name first (it keeps its own settings), else a login. */ +export function namedAccount( + spec: string, + proxyAccounts: AiProxyAccountConfig[], + logins: SessionLogin[], + agent: SessionAgent +): Resolved<SessionAccountChoice> { + const proxyAccount = proxyAccounts.find((account) => account.name === spec); + + if (proxyAccount) { + return { proxyAccount, label: spec, how: "named", metered: isMetered(proxyAccount) }; + } + + if (logins.some((login) => login.name === spec)) { + return { login: spec, label: spec, how: "named", metered: false }; + } + + const known = [...logins.map((login) => login.name), ...proxyAccounts.map((account) => account.name)]; + return { error: `No ${agent} login or proxy account is named "${spec}" (known: ${known.join(", ") || "none"}).` }; +} + +/** + * `auto` for Codex and Grok: every login of the agent ranked by its usage (`rankAccounts`) with the sessions already on + * it. Logins are subscriptions, so a metered account is never picked. When no login has usage data at all, the one + * configured subscription account (today the legacy Codex account) is taken; a blocked login is never guessed onto. + */ +export function autoAccount(input: { + agent: SessionAgent; + logins: SessionLogin[]; + snapshots: AccountUsageSnapshot[]; + /** Open sessions per login name. */ + sessions: Record<string, number>; + /** Configured accounts `auto` may fall back on when no login has data. */ + fallback: AiProxyAccountConfig[]; + now?: Date; +}): Resolved<SessionAccountChoice & { ranked: RankedAccount[] }> { + const byId = new Map(input.logins.map((login) => [login.id, login.name] as const)); + const snapshots = input.snapshots + .filter((snapshot) => byId.has(snapshot.accountId)) + .map((snapshot) => ({ ...snapshot, accountName: byId.get(snapshot.accountId) ?? snapshot.accountName })); + const ranked = rankAccounts(snapshots, { sessions: input.sessions, ...(input.now ? { now: input.now } : {}) }); + const picked = pickRanked(ranked); + + if (picked) { + return { + login: picked.pick.accountName, + label: picked.pick.accountName, + how: "auto", + why: picked.pick.why, + ...(picked.runnerUp ? { runnerUp: `${picked.runnerUp.accountName} (${picked.runnerUp.why})` } : {}), + metered: false, + ranked, + }; + } + + const subscription = input.fallback.filter((account) => !isMetered(account)); + + if (ranked.every((row) => row.tier === "no-data") && subscription.length === 1 && subscription[0]) { + return { + proxyAccount: subscription[0], + label: subscription[0].name, + how: "only", + why: "no login has usage data; the one configured account", + metered: false, + ranked, + }; + } + + const reasons = ranked.map((row) => `${row.accountName}: ${row.tier}, ${row.why}`).join("; "); + return { + error: `auto found no ${input.agent} login with headroom (${reasons || "no usage data"}). Name one instead.`, + }; +} + +/** The Claude Code id of a Claude model by its catalog alias. */ +export function claudeModelByAlias(alias: string): string | undefined { + return CLAUDE_MODELS.find((model) => model.aliases?.includes(alias))?.id; +} + +export interface SessionEnvInput { + baseUrl: string; + /** The session key (`gts_…`). */ + key: string; + /** `codex/<slug>`, no effort suffix, so `/effort` works. */ + model: string; + /** Set when the session has a Claude account. */ + claude?: { haiku: string; sonnet?: string; opus: string; subscriptionType?: string }; + /** The small-model slot without a Claude account. */ + smallCodexModel: string; + /** Window × usable percent, plain digits. */ + maxContextTokens: number; + compactPercent: number; + /** `codex:<account>+claude:<account>` for `tools claude who` and the statusline. */ + accountLabel: string; +} + +/** + * Inherited values that would bill the wrong way or override the session's own settings: a first-party OAuth token + * or an API key beats ANTHROPIC_AUTH_TOKEN and bills Anthropic directly; a forced subagent model or effort beats the + * per-agent ones (S2); an inherited window or percent belongs to another model. + */ +const SCRUBBED_ENV = [ + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_SUBSCRIPTION_TYPE", + "CLAUDE_CODE_RATE_LIMIT_TIER", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE", + "CLAUDE_CODE_EFFORT_LEVEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL_FORCE", + "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_CUSTOM_MODEL_OPTION", + "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", + "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", +] as const; + +export function sessionEnv( + inherited: Record<string, string | undefined>, + input: SessionEnvInput +): Record<string, string | undefined> { + const env = { ...inherited }; + + for (const name of SCRUBBED_ENV) { + delete env[name]; + } + + const small = input.claude?.haiku ?? input.smallCodexModel; + + return { + ...env, + ANTHROPIC_BASE_URL: input.baseUrl, + ANTHROPIC_AUTH_TOKEN: input.key, + ANTHROPIC_MODEL: input.model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: small, + ANTHROPIC_SMALL_FAST_MODEL: small, + ...(input.claude?.sonnet ? { ANTHROPIC_DEFAULT_SONNET_MODEL: input.claude.sonnet } : {}), + ...(input.claude ? { ANTHROPIC_DEFAULT_OPUS_MODEL: input.claude.opus } : {}), + ...(input.claude?.subscriptionType ? { CLAUDE_CODE_SUBSCRIPTION_TYPE: input.claude.subscriptionType } : {}), + CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(Math.floor(input.maxContextTokens)), + CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(Math.floor(input.maxContextTokens)), + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(input.compactPercent), + TOOLS_CLAUDE_ACCOUNT: `proxy:${input.accountLabel}`, + }; +} + +/** A user flag after `--` that the launcher also sets: theirs wins, ours is left out. */ +export function passthroughSets(passthrough: string[], flag: "--effort" | "--model"): boolean { + return passthrough.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); +} + +/** + * Claude Code's arguments: the session id (new) or the resumed id, the effort, the subagents, the settings, the user's + * own. `settingsEnv` rides in `--settings`, because Claude Code applies the `env` of ~/.claude/settings.json over the + * process environment and only CLI settings beat it: a user-wide CLAUDE_CODE_AUTO_COMPACT_WINDOW of 700000 would let + * a 500K Codex session fill its window before compacting. A user's own `--settings` wins and drops ours. + */ +export function sessionArgs(input: { + sessionId: string; + resume: boolean; + effort: string; + agents: string[]; + settingsEnv: Record<string, string>; + passthrough: string[]; +}): string[] { + const ownSettings = input.passthrough.some((arg) => arg === "--settings" || arg.startsWith("--settings=")); + + return [ + ...(input.resume ? ["--resume", input.sessionId] : ["--session-id", input.sessionId]), + ...(passthroughSets(input.passthrough, "--effort") ? [] : ["--effort", input.effort]), + ...input.agents, + ...(ownSettings ? [] : ["--settings", SafeJSON.stringify({ env: input.settingsEnv })]), + ...input.passthrough, + ]; +} diff --git a/src/claude/lib/history/search.test.ts b/src/claude/lib/history/search.test.ts index 5ac82bbb7c..39a4e5032a 100644 --- a/src/claude/lib/history/search.test.ts +++ b/src/claude/lib/history/search.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, utimesSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { DailyStats } from "@genesiscz/utils/agent-sessions/cache-types"; +import { noBackwardRead } from "@genesiscz/utils/agent-sessions/testing/no-backward-read"; import type { HistoryStatisticsRead, NativeSessionReader, @@ -236,6 +237,7 @@ function reader(overrides: Partial<Reader> = {}): Reader { return { kind: "claude", parserVersion: "test", + readBackward: noBackwardRead, roots: () => [root], discover: async () => ({ sources: [], issues: [], completeRoots: [root] }), read: async () => { diff --git a/src/claude/lib/usage/session-rows.test.ts b/src/claude/lib/usage/session-rows.test.ts index 11f095baff..584fe52bce 100644 --- a/src/claude/lib/usage/session-rows.test.ts +++ b/src/claude/lib/usage/session-rows.test.ts @@ -278,6 +278,7 @@ describe("listSessionRows", () => { expect(Object.keys(hot ?? {}).sort()).toEqual( [ "account", + "alive", "cacheCreateTokens", "cacheReadTokens", "cacheLifetimeSec", diff --git a/src/claude/lib/usage/session-rows.ts b/src/claude/lib/usage/session-rows.ts index 1098fb1677..d60a21650f 100644 --- a/src/claude/lib/usage/session-rows.ts +++ b/src/claude/lib/usage/session-rows.ts @@ -1,6 +1,7 @@ import { cleanPromptText } from "@app/claude/lib/cmux/sessions"; import { getSessionListing, type SessionMetadataRecord } from "@app/claude/lib/history/search"; import { loadPins } from "@genesiscz/utils/agent-sessions/pins"; +import { liveClaudeSessionIds } from "@genesiscz/utils/claude/peer-message"; import { readTailBytes } from "@genesiscz/utils/claude/session.utils"; import { loadAllSessionCmuxRefs } from "@genesiscz/utils/cmux/session-refs"; import { FileIdentityCache, fileIdentity } from "@genesiscz/utils/fs/identity-cache"; @@ -76,6 +77,12 @@ export interface SessionRow { account: string | null; /** Recorded cmux location from the refs journal — where the session started, unverified. */ cmux: SessionCmuxLocation | null; + /** + * Whether a claude process still runs this session (`~/.claude/sessions` registry). null when the + * registry could not be read. The Genesis monitor skips cache banners for `false`: a session whose + * process exited cannot be resumed from its pane, and those were 80 of 203 banners in 40 h (2026-10-23). + */ + alive: boolean | null; filePath: string; } @@ -124,6 +131,8 @@ export interface ListSessionRowsOptions { maxDiscoveryAgeMs?: number; /** Passed to `getSessionListing`: `false` reads the index with no refresh. */ refresh?: boolean; + /** Which session ids have a running claude process; tests inject it. Defaults to the registry. */ + liveSessionIds?: () => Set<string> | null; } function simplifyModel(model: string): string { @@ -375,7 +384,24 @@ export async function extractTailUsageUncached(filePath: string): Promise<{ usag const haveUsage = parsed.model != null; const haveUser = parsed.lastUserAt != null; - if ((haveClock && haveUsage && haveUser) || size <= 0 || bytes >= size || bytes >= TAIL_MAX_BYTES) { + if ((haveClock && haveUsage && haveUser) || size <= 0 || bytes >= size) { + return { usage: parsed, read: true }; + } + + if (bytes >= TAIL_MAX_BYTES) { + // A cap that hides a field must say so: `lastUserAt` stayed null for a session whose newest prompt sat + // 1.4 MB from the end, and nothing in the log said why (2026-10-25). + logger.info( + { + filePath, + capBytes: TAIL_MAX_BYTES, + size, + missing: [!haveClock && "lastCacheAt", !haveUsage && "model", !haveUser && "lastUserAt"].filter( + Boolean + ), + }, + "[sessions] tail read stopped at its cap; the missing fields stay empty for this session" + ); return { usage: parsed, read: true }; } @@ -396,6 +422,7 @@ export async function extractTailUsageUncached(filePath: string): Promise<{ usag interface RowJoins { accounts: Map<string, string | null>; cmux: Map<string, SessionCmuxLocation>; + live: Set<string> | null; } function buildRow(record: SessionMetadataRecord, usage: TailUsage, now: number, joins: RowJoins): SessionRow { @@ -431,6 +458,7 @@ function buildRow(record: SessionMetadataRecord, usage: TailUsage, now: number, lastUserAt: usage.lastUserAt, account: joins.accounts.get(sessionId.toLowerCase()) ?? null, cmux: joins.cmux.get(sessionId.toLowerCase()) ?? null, + alive: joins.live ? joins.live.has(sessionId.toLowerCase()) : null, filePath: record.filePath, }; } @@ -527,7 +555,7 @@ export async function listSessionRowsWithTimings( }); } - return { accounts, cmux }; + return { accounts, cmux, live: (opts.liveSessionIds ?? liveClaudeSessionIds)() }; }); const joinMs = performance.now() - joinStarted; const rank: Record<CacheStatus, number> = { HOT: 0, COOLING: 1, CRITICAL: 2, COLD: 3 }; diff --git a/src/claude/registrars.ts b/src/claude/registrars.ts index b6d7256f6a..9842f74cf6 100644 --- a/src/claude/registrars.ts +++ b/src/claude/registrars.ts @@ -48,6 +48,7 @@ export const CLAUDE_REGISTRARS: LazyRegistrar[] = [ { names: ["spending"], load: async () => (await import("./commands/spending")).registerSpendingCommand }, { names: ["start", "run"], load: async () => (await import("./commands/start")).registerStartCommand }, { names: ["proxy"], load: async () => (await import("./commands/run")).registerRunCommand }, + { names: ["codex"], load: async () => (await import("./commands/codex")).registerCodexCommand }, { names: ["teams"], load: async () => (await import("./commands/teams")).registerTeamsCommand }, { names: ["cmux"], load: async () => (await import("./commands/cmux")).registerCmuxCommand }, { diff --git a/src/codex/README.md b/src/codex/README.md index 4f89b2ac03..75f5059437 100644 --- a/src/codex/README.md +++ b/src/codex/README.md @@ -192,7 +192,8 @@ source only for threads the destination lacks and are remapped onto the destinat id, and `selected-project` is left alone. The merged file is written atomically. What it does **not** carry: `auth.json`, `history.jsonl` (the up-arrow recall buffer), the -per-home SQLite databases, and `archived_sessions/` — only `sessions/` is enumerated, so a +per-home SQLite databases, and `archived_sessions/` — only `sessions/` is enumerated (both +`rollout-*.jsonl` and the `rollout-*.jsonl.zst` files Codex 0.164 compresses, copied as they are), so a source home's archived threads stay where they are. ⚠️ `--archive-source` renames `sessions/` only, and a home without one is no longer discovered, so an un-migrated `archived_sessions/` becomes invisible to later runs; copy it by hand, or leave that source unarchived. @@ -240,7 +241,8 @@ Sources: [app-server protocol](https://learn.chatgpt.com/docs/app-server), tools codex spawn \ --name reviewer \ --mode review \ - --prompt "Review the current working tree" + --base origin/master \ + --prompt "Review this branch" tools codex status --name reviewer tools codex tail --name reviewer --follow @@ -266,6 +268,42 @@ worker verbs (`src/ai/commands/agent/worker.ts`); what makes them codex is `code (`src/codex/lib/driver.ts`). `steer` still answers to its older `--body` / `--body-file` spelling, hidden from help. +### Review mode (`--mode review`) + +`--mode review` needs a base: `--base <ref>`, else the branch's PR target, else its upstream (never the branch's own +remote copy); the spawn prints which. It runs `git diff --numstat <base>...HEAD` in the worker's cwd (limited to +`--paths <path...>` when given) and refuses an empty diff. The worker's developer instructions carry the shared worker +contract plus `reviewLines(effort)` from `src/utils/worker/contract.ts` and the manifest (`Files to review (N, +A -D)` +and one line per file). + +The tier is `--effort`, else the diff size: `high` up to 15 files and 800 changed lines, `xhigh` above. `max` only when +named. `low` (triage) and `medium` are refused unless `--allow-shallow`. Above 60 files or 4,000 lines the spawn prints +a split suggestion, one reviewer per folder with `--paths`. + +After each review turn the daemon runs the depth gate (`src/codex/lib/review/gate.ts`): the `Files reviewed: n of N` +line against the manifest (a binary file named with a reason is excused), reasoning tokens per 100 changed lines +against the tier floor (high 100, xhigh and max 250), and the finding shape (`N. [Severity] path:line - defect. +Scenario: ... Fix: ...`, then `Ran:`). The first miss steers the same session once with the unread files; the second +starts `<chain>-<next tier>` as a new session (a steer cannot change a thread's effort), at step 2; a miss after that, +or a miss at `max`, ends the review `shallow`. A medium review with `--allow-shallow` is never tuned up. Each decision +also goes to the agents bus as a `review_gate` event. + +Every review start, review turn and gate decision is one line of `~/.genesis-tools/codex/reviews.jsonl`: + +```bash +tools codex reviews [--since 24h] [--pr <n>] [--label <text>] [--effort <tier>] [--verdict pass|steered|escalated|shallow|open] [--name <text>] [--json] +tools codex review-depth <name> [--json] # one review: manifest, spend, coverage, reasoning per 100 lines, verdict, gate history +tools codex review-depth <name> --base <ref> # the same checks for a session spawned without --mode review +tools codex reviews import <name...> [--base <ref>] [--no-cwd-from-session] [--json] +``` + +`reviews import` backfills sessions that ran before the ledger: a start line, one turn line per booked turn of the +worker cost ledger, and a gate line marked `imported: true`. Its verdict comes from the final agent message (from the +session's event log): `pass` for `Files reviewed: n of n`, `shallow` for fewer, `imported` with no such line. The +manifest is `<base>...<the last commit before the session started>` in the session's cwd, so fix commits made after +the review do not count; with no base (no `--base`, no PR target, no upstream) or a removed cwd its counts are null. +A session already in the ledger is skipped, so a repeated import adds nothing. + `login` and `usage` are doors onto the provider-neutral account core: the same code runs behind `tools ai accounts login --provider codex` and `tools ai usage --provider codex`. `tools ai accounts discover --provider codex` lists every `~/.codex*` profile on the machine and `--bind` turns the unbound ones into accounts. Sessions are read-only by default. For implementation work, use `--write ask` for supervised approvals or diff --git a/src/codex/commands/models.ts b/src/codex/commands/models.ts new file mode 100644 index 0000000000..d9fb0ca44a --- /dev/null +++ b/src/codex/commands/models.ts @@ -0,0 +1,174 @@ +import { loadConfig } from "@app/ai-proxy/lib/config"; +import { CodexAccountBinding } from "@genesiscz/utils/ai/openai/account-binding"; +import { + type CodexModelInfo, + codexModelsCachePath, + describeCodexEfforts, + mergeCodexModelRecords, + readCodexModelsCache, +} from "@genesiscz/utils/ai/openai/codex-models"; +import { + OPENAI_SUB_BUILTIN_ALIAS_NAMES, + resolveOpenAiSubModel, + tryFetchWhamModels, + type WhamModelRecord, +} from "@genesiscz/utils/ai/openai/sub-models"; +import { formatTokens } from "@genesiscz/utils/format"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger, out } from "@genesiscz/utils/logger"; +import { createBoxTable, renderCliHeader, renderCliSection } from "@genesiscz/utils/table"; +import type { Command } from "commander"; +import pc from "picocolors"; + +const { log } = logger.scoped("codex-models"); + +interface ModelsOptions { + account?: string; + model?: string; + all?: boolean; + json?: boolean; +} + +/** + * The live list needs a token. Read-only: a listing never refreshes (a refresh token is single use), so an + * expired login skips the live list and says so. + */ +async function liveRecords(account: string): Promise<WhamModelRecord[] | null> { + try { + const binding = await CodexAccountBinding.create(account, { allowRefresh: false }); + const tokens = await binding.tokens({ refresh: false }); + return await tryFetchWhamModels(tokens.accessToken, tokens.chatgptAccountId); + } catch (err) { + log.warn({ err, account }, "codex models: live list unavailable for this account"); + out.log.warn(`Live model list unavailable for ${account}; showing the CLI cache and the static table.`); + return null; + } +} + +function aliasesFor(slug: string): string[] { + return OPENAI_SUB_BUILTIN_ALIAS_NAMES.filter((alias) => resolveOpenAiSubModel(alias) === slug); +} + +function sourceSummary(info: CodexModelInfo): string { + const counts = new Map<string, number>(); + + for (const source of Object.values(info.sources)) { + counts.set(source, (counts.get(source) ?? 0) + 1); + } + + return [...counts.entries()].map(([source, count]) => `${source} ${count}`).join(", "); +} + +function printDetail(info: CodexModelInfo): void { + renderCliHeader(info.slug, `${info.displayName}; aliases: ${aliasesFor(info.slug).join(", ") || "none"}`); + const table = createBoxTable(["FIELD", "VALUE", "SOURCE"]); + const row = (field: string, value: unknown, sourceKey = field) => { + table.push([field, value === undefined ? pc.dim("—") : String(value), info.sources[sourceKey] ?? pc.dim("—")]); + }; + + row("window", info.window); + row("maxContextWindow", info.maxContextWindow); + row("usablePercent", `${info.usablePercent}% = ${info.usableTokens}`); + row("compactPercent", `${info.compactPercent}% = ${info.compactTokens}`); + row("efforts", describeCodexEfforts(info)); + row("defaultEffort", info.defaultEffort); + row("multiAgentReasoningEffort", info.multiAgentReasoningEffort); + row("useResponsesLite", info.useResponsesLite); + row("compHash", info.compHash); + row("supportsSearchTool", info.supportsSearchTool); + row("webSearchToolType", info.webSearchToolType); + row("defaultReasoningSummary", info.defaultReasoningSummary); + row("supportsReasoningSummaryParameter", info.supportsReasoningSummaryParameter); + row("inputModalities", info.inputModalities?.join(", ")); + out.println(table.toString()); +} + +export function registerModelsCommand(program: Command): void { + program + .command("models") + .description( + "Codex model catalog: window, usable and compact percents, efforts (ultra mapping), Lite, comp_hash, search; each field with its source" + ) + .option("--account <name>", "Also read the live list with this ChatGPT login (read-only, never refreshes)") + .option("--model <id>", "One model, every field with its source (a slug or an alias such as sol, sol6)") + .option("--all", "Include models the backend hides") + .option("--json", "Machine-readable output") + .action(async (options: ModelsOptions) => { + const config = await loadConfig(); + const live = options.account ? await liveRecords(options.account) : null; + const cachePath = codexModelsCachePath(); + const catalog = mergeCodexModelRecords({ + live, + cache: readCodexModelsCache(cachePath), + overrides: config.codexModels, + }); + log.debug( + { account: options.account, live: live?.length ?? 0, cachePath, models: catalog.size }, + "codex models: catalog merged" + ); + + if (options.model) { + const slug = resolveOpenAiSubModel(options.model); + const info = catalog.get(slug); + + if (!info) { + throw new Error(`No Codex model "${options.model}". Known: ${[...catalog.keys()].join(", ")}`); + } + + if (options.json) { + out.result(SafeJSON.stringify({ ...info, aliases: aliasesFor(slug) }, null, 2)); + return; + } + + printDetail(info); + return; + } + + const models = [...catalog.values()].filter((info) => options.all || info.visibility === "list"); + + if (options.json) { + out.result( + SafeJSON.stringify( + models.map((info) => ({ ...info, aliases: aliasesFor(info.slug) })), + null, + 2 + ) + ); + return; + } + + renderCliHeader( + "Codex models", + `live: ${live ? `${live.length} from ${options.account}` : "not read"} · cache: ${cachePath}` + ); + const table = createBoxTable([ + "MODEL", + "ALIASES", + "WINDOW", + "USABLE", + "COMPACT", + "EFFORTS", + "DEFAULT", + "SOURCES", + ]); + + for (const info of models) { + table.push([ + pc.white(info.slug), + aliasesFor(info.slug).join(", "), + formatTokens(info.window), + `${info.usablePercent}% ${formatTokens(info.usableTokens)}`, + `${info.compactPercent}% ${formatTokens(info.compactTokens)}`, + describeCodexEfforts(info), + info.defaultEffort ?? "", + sourceSummary(info), + ]); + } + + out.println(table.toString()); + renderCliSection("Sources"); + out.println( + "live = WHAM /models (--account), cache = the Codex CLI's models_cache.json, static = the built-in table, default = codex-rs rule, override = ai-proxy config codexModels.<slug>" + ); + }); +} diff --git a/src/codex/commands/reviews.ts b/src/codex/commands/reviews.ts new file mode 100644 index 0000000000..009d3512f3 --- /dev/null +++ b/src/codex/commands/reviews.ts @@ -0,0 +1,391 @@ +import { sessionCostSummary } from "@app/ai-spend/lib/workers/ledger"; +import { isInteractive, suggestEnumFlag } from "@genesiscz/utils/cli"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { formatDuration, formatTokens } from "@genesiscz/utils/format"; +import { logger, out } from "@genesiscz/utils/logger"; +import * as p from "@genesiscz/utils/prompts/p"; +import { createBoxTable, formatDotStatus, renderCliHeader, truncateDisplay } from "@genesiscz/utils/table"; +import { + isReviewEffort, + manifestChangedLines, + REVIEW_EFFORTS, + type ReviewEffort, +} from "@genesiscz/utils/worker/contract"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { checkReview, type ReviewGateChecks } from "../lib/review/gate"; +import { importReviewSessions, lastAgentMessage } from "../lib/review/import"; +import { type ReviewGateRecord, readReviewRecords, reviewLedgerPath } from "../lib/review/ledger"; +import { buildManifest, defaultReviewGitReaders } from "../lib/review/prepare"; +import { + currentReviewRecords, + filterReviews, + gateCheckRows, + parseReviewSince, + type ReviewSummary, + reviewTotals, + summarizeReviews, +} from "../lib/review/report"; +import { REVIEW_VERDICTS } from "../lib/review/types"; +import { type CodexSessionMeta, CodexSessionStore } from "../lib/store"; + +const { log } = logger.scoped("codex-reviews"); + +const VERDICT_FILTERS = [...REVIEW_VERDICTS, "open"] as const; + +interface ReviewsOptions { + since?: string; + pr?: string; + label?: string; + effort?: string | true; + verdict?: string | true; + name?: string; + json?: boolean; +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function money(usd: number | null): string { + return usd === null ? "unpriced" : `$${usd.toFixed(2)}`; +} + +function verdictCell(verdict: ReviewSummary["verdict"]): string { + const tone = verdict === "pass" ? "ok" : verdict === "shallow" ? "err" : verdict === "open" ? "dim" : "warn"; + return formatDotStatus(tone, verdict); +} + +/** + * A closed-set flag given without a value or with an unknown one: a TTY picks from the values, anything else gets + * the values and a filled command line, and the run stops with exit 1. + */ +async function enumFilter(flag: string, raw: string | true | undefined, values: readonly string[]) { + if (raw === undefined) { + return { ok: true as const, value: undefined }; + } + + if (raw !== true && values.includes(raw)) { + return { ok: true as const, value: raw }; + } + + if (isInteractive()) { + const picked = await p.select({ + message: `${flag}`, + options: values.map((value) => ({ value, label: value })), + }); + + if (!p.isCancel(picked) && typeof picked === "string") { + return { ok: true as const, value: picked }; + } + } + + out.log.error(suggestEnumFlag("tools codex", flag, values, raw === true ? {} : { given: raw })); + process.exitCode = 1; + return { ok: false as const }; +} + +async function runReviews(options: ReviewsOptions): Promise<void> { + const effort = await enumFilter("--effort", options.effort, REVIEW_EFFORTS); + const verdict = await enumFilter("--verdict", options.verdict, VERDICT_FILTERS); + + if (!effort.ok || !verdict.ok) { + return; + } + + const since = options.since === undefined ? undefined : parseReviewSince(options.since); + + if (since === null) { + out.log.error(`--since ${options.since} is not a duration (24h, 90m, 7d), a time (HH:MM) or a date.`); + process.exitCode = 1; + return; + } + + const pr = options.pr === undefined ? undefined : Number(options.pr.replace(/^#/, "")); + + if (pr !== undefined && !Number.isInteger(pr)) { + out.log.error(`--pr ${options.pr} is not a PR number.`); + process.exitCode = 1; + return; + } + + const path = reviewLedgerPath(); + const { records, skipped } = readReviewRecords(path); + const reviews = filterReviews(summarizeReviews(records), { + ...(since ? { since } : {}), + ...(pr === undefined ? {} : { pr }), + ...(options.label ? { label: options.label } : {}), + ...(effort.value ? { effort: effort.value } : {}), + ...(verdict.value ? { verdict: verdict.value } : {}), + ...(options.name ? { name: options.name } : {}), + }); + const totals = reviewTotals(reviews); + log.debug({ path, records: records.length, skipped, shown: reviews.length }, "codex reviews read"); + + if (options.json) { + out.result({ ledger: path, skipped, reviews, totals }); + return; + } + + if (skipped > 0) { + out.log.warn(`${skipped} ledger line(s) in ${path} did not parse and were skipped.`); + } + + if (reviews.length === 0) { + out.log.info(`No review runs match${records.length === 0 ? `: ${path} has none yet` : ""}.`); + return; + } + + renderCliHeader("Codex reviews", `${reviews.length} review(s) · ledger ${path}`); + const table = createBoxTable([ + "NAME", + "PR", + "FILES", + "EFFORT", + "TIME", + "TOOLS", + "RSN/100L", + "COVERAGE", + "VERDICT", + "COST", + ]); + + for (const review of reviews) { + table.push([ + truncateDisplay(review.label ? `${review.name} ${pc.dim(review.label)}` : review.name, 36), + review.pr === undefined ? (review.branch ? truncateDisplay(review.branch, 18) : "—") : `#${review.pr}`, + review.files === null ? "—" : `${review.files} (${review.lines}L)`, + review.effort, + formatDuration(review.durationMs), + String(review.toolCalls), + review.reasoningPer100Lines === null ? "—" : String(review.reasoningPer100Lines), + review.coverage ?? "—", + review.escalatedTo ? `${verdictCell(review.verdict)} → ${review.escalatedTo}` : verdictCell(review.verdict), + money(review.costUsd), + ]); + } + + out.println(table.toString()); + const verdicts = Object.entries(totals.verdicts) + .map(([name, count]) => `${count} ${name}`) + .join(", "); + out.println( + `Totals: ${plural(totals.reviews, "review")} (${verdicts}) · ${plural(totals.turns, "turn")} · ${formatDuration(totals.durationMs)} · ${totals.toolCalls} tool calls · ${formatTokens(totals.reasoningTokens)} reasoning · $${totals.costUsd.toFixed(2)} API-equivalent${totals.unpricedReviews > 0 ? ` (+${totals.unpricedReviews} unpriced)` : ""}` + ); + out.println(pc.dim(`One review in depth: ${toolCommand("codex review-depth")} <name>`)); +} + +interface ImportOptions { + base?: string; + cwdFromSession: boolean; + json?: boolean; +} + +/** Exported for the test of both output formats; the CLI reaches it through `reviews import`. */ +export async function runImport(names: string[], options: ImportOptions): Promise<void> { + const results = await importReviewSessions({ + names, + ...(options.base ? { base: options.base } : {}), + cwdFromSession: options.cwdFromSession, + }); + + // Before either output: automation reading --json needs the same exit status as a person reading the table. + if (results.some((result) => result.status === "missing" || result.status === "failed")) { + process.exitCode = 1; + } + + if (options.json) { + out.result({ ledger: reviewLedgerPath(), results }); + return; + } + + const table = createBoxTable(["NAME", "STATUS", "VERDICT", "MANIFEST", "NOTES"]); + + for (const result of results) { + table.push([ + result.name, + result.status, + result.verdict ? verdictCell(result.verdict) : "—", + result.manifest + ? `${result.manifest.files} files, ${result.manifest.lines}L (${result.manifest.base}...${result.manifest.head.slice(0, 10)})` + : "—", + truncateDisplay(result.notes.join("; "), 70), + ]); + } + + out.println(table.toString()); + const imported = results.filter((result) => result.status === "imported").length; + out.println(`${imported} imported, ${results.length - imported} not, into ${reviewLedgerPath()}`); +} + +interface DepthOptions { + base?: string; + json?: boolean; +} + +function checkLine(label: string, ok: boolean, detail: string): string { + return `${ok ? pc.green("✓") : pc.red("✗")} ${label.padEnd(9)} ${detail}`; +} + +function gateLines(gate: ReviewGateRecord): string[] { + return gateCheckRows(gate).map((row) => checkLine(row.label, row.ok, row.detail)); +} + +function checksLines(checks: ReviewGateChecks): string[] { + return [ + checkLine("coverage", checks.coverage.ok, checks.coverage.detail), + checkLine("reasoning", checks.reasoning.ok, checks.reasoning.detail), + checkLine("shape", checks.shape.ok, checks.shape.detail), + ]; +} + +/** A session spawned without `--mode review`: the gate's checks against a manifest computed now from `--base`. */ +async function adHocDepth(store: CodexSessionStore, meta: CodexSessionMeta, options: DepthOptions): Promise<void> { + if (!options.base) { + out.log.error( + `${meta.name} was not spawned with --mode review, so it has no manifest. Pass --base <ref> to measure it against git diff <base>...HEAD in ${meta.cwd}.` + ); + process.exitCode = 1; + return; + } + + const readers = defaultReviewGitReaders(); + const manifest = buildManifest(options.base, await readers.numstat(meta.cwd, options.base)); + const effort: ReviewEffort = isReviewEffort(meta.effort) ? meta.effort : "high"; + const cost = sessionCostSummary({ backend: "codex", name: meta.name, sessionId: meta.threadId }); + const reasoning = cost ? cost.session.tokens.reasoning : null; + const checks = checkReview({ + report: await lastAgentMessage(store, meta), + manifest, + effort, + reasoningTokens: reasoning, + }); + + if (options.json) { + out.result({ name: meta.name, review: null, base: options.base, effort, manifest, checks, cost }); + return; + } + + out.println(`${pc.bold(meta.name)} ${pc.dim("not a --mode review session; measured now")} · effort ${effort}`); + out.println( + `manifest ${manifest.files.length} files, +${manifest.added} -${manifest.deleted} against ${options.base}` + ); + + if (cost) { + out.println( + `spent ${cost.session.turns} turns · ${formatDuration(cost.session.durationMs)} · ${cost.session.toolCalls} tool calls · ${formatTokens(cost.session.tokens.reasoning)} reasoning · $${cost.session.apiEquivalentUsd.toFixed(2)}` + ); + } + + for (const line of checksLines(checks)) { + out.println(line); + } +} + +async function runReviewDepth(name: string, options: DepthOptions): Promise<void> { + const store = new CodexSessionStore(); + const meta = await store.readMeta(name); + + if (!meta) { + out.log.error(`No codex session named ${name}. ${toolCommand("codex sessions")} lists them.`); + process.exitCode = 1; + return; + } + + const review = meta.review; + + if (!review) { + await adHocDepth(store, meta, options); + return; + } + + const records = currentReviewRecords(readReviewRecords().records, name, review.chain); + const gates = records.filter((record): record is ReviewGateRecord => record.kind === "gate"); + const lastGate = gates.at(-1); + + if (options.json) { + out.result({ name, review, gates, turns: records.filter((record) => record.kind === "turn") }); + return; + } + + const spent = review.spent; + const lines = manifestChangedLines(review.manifest); + out.println( + `${pc.bold(name)} review · effort ${review.effort} (${review.effortSource === "flag" ? "--effort" : "diff size"}) · ${verdictCell(review.verdict ?? "open")}${review.escalatedTo ? ` → ${review.escalatedTo}` : ""}` + ); + out.println( + `base ${review.base} (${review.baseSource}: ${review.baseDetail})${review.branch ? ` · branch ${review.branch}` : ""}${review.pr === undefined ? "" : ` · PR #${review.pr}`}${review.parent ? ` · escalated from ${review.parent}` : ""}` + ); + out.println( + `manifest ${review.manifest.files.length} files, +${review.manifest.added} -${review.manifest.deleted} (${lines} lines)` + ); + + if (spent) { + out.println( + `spent ${spent.turns} turns · ${formatDuration(spent.durationMs)} · ${spent.toolCalls} tool calls · ${formatTokens(spent.reasoningTokens)} reasoning · ${money(spent.costUsd)}` + ); + } + + if (lastGate) { + for (const line of gateLines(lastGate)) { + out.println(line); + } + } + + if (review.reason) { + out.println(`reason: ${review.reason}`); + } + + if (gates.length > 1) { + out.println(pc.dim("gate history:")); + + for (const gate of gates) { + // Local time, like every time this tool prints; the ledger keeps UTC. + out.println( + pc.dim( + ` ${new Date(gate.ts).toLocaleString("sv-SE")} step ${gate.step} ${gate.verdict}: ${gate.reason}` + ) + ); + } + } +} + +export function registerReviewsCommands(program: Command): void { + const reviews = program + .command("reviews") + .description("Every --mode review run from the review ledger: one row per review, with totals") + .option("--since <when>", "Only reviews active since: a duration back (24h, 7d), HH:MM today, or a date") + .option("--pr <n>", "Only reviews of this PR number") + .option("--label <text>", "Only reviews whose spawn --label contains this text") + .option("--effort [tier]", `Only reviews at this tier: ${REVIEW_EFFORTS.join(" | ")}`) + .option("--verdict [verdict]", `Only reviews with this gate verdict: ${VERDICT_FILTERS.join(" | ")}`) + .option("--name <text>", "Only reviews whose session or chain name contains this text") + .option("--json", "Emit the reviews and totals as JSON") + .action(async (options: ReviewsOptions) => { + await runReviews(options); + }); + + reviews + .command("import <name...>") + .description( + "Backfill the ledger from codex sessions that ran before it: start, turns from the cost ledger, and a gate line whose verdict the final Files reviewed line implies" + ) + .option("--base <ref>", "The base of every named session; default each branch's PR target, else its upstream") + .option("--cwd-from-session", "Read the manifest in each session's own cwd (the default)", true) + .option("--no-cwd-from-session", "Leave every manifest null") + .option("--json", "Emit the import results as JSON") + .action(async (names: string[], options: ImportOptions) => { + await runImport(names, options); + }); + + program + .command("review-depth <name>") + .description( + "One review's depth: manifest, effort, spend, coverage, reasoning per 100 lines and the gate verdict" + ) + .option("--base <ref>", "For a session spawned without --mode review: the base to measure it against") + .option("--json", "Emit as JSON") + .action(async (name: string, options: DepthOptions) => { + await runReviewDepth(name, options); + }); +} diff --git a/src/codex/daemon.ts b/src/codex/daemon.ts index 6f238a0d49..8200b3a1cd 100644 --- a/src/codex/daemon.ts +++ b/src/codex/daemon.ts @@ -14,6 +14,7 @@ import { computerUseOverrides } from "./lib/computer-use"; import { ControlLogCursor, respondToControl } from "./lib/control-channel"; import { buildAccountLaunchOptions } from "./lib/launch-options"; import { sessionControlPath, sessionDaemonLogPath, sessionLaunchPath } from "./lib/paths"; +import { escalateCodexReview } from "./lib/review/escalate"; import { CodexSessionRuntime } from "./lib/session"; import type { LaunchConfig } from "./lib/spawn"; import { CodexSessionStore } from "./lib/store"; @@ -131,6 +132,11 @@ async function run(): Promise<void> { store, meta: (await store.readMeta(name)) ?? meta, ...(launch.project ? { project: launch.project } : {}), + escalateReview: (request) => escalateCodexReview(request, launch), + onReviewGate: async (event) => { + store.appendEvent(name, { source: "daemon", method: "review_gate", params: event }); + await bridge?.publishEvent(event); + }, onApprovalRequest: async (notice) => { store.appendEvent(name, { source: "daemon", method: "approval_request", params: notice }); if (!meta.agentsEnabled) { diff --git a/src/codex/index.ts b/src/codex/index.ts index 75bff2694a..5da1f0c913 100644 --- a/src/codex/index.ts +++ b/src/codex/index.ts @@ -7,8 +7,10 @@ import { registerApprovalCommands } from "./commands/approve"; import { registerDesktopCommand } from "./commands/desktop"; import { registerLogsCommand } from "./commands/logs"; import { registerMigrateHomeCommand } from "./commands/migrate-home"; +import { registerModelsCommand } from "./commands/models"; import { registerCodexMessageCommands } from "./commands/queue"; import { registerReviewCommand } from "./commands/review"; +import { registerReviewsCommands } from "./commands/reviews"; import { registerRollbackCommand } from "./commands/rollback"; import { codexSpec } from "./lib/spec"; @@ -24,7 +26,9 @@ registerMigrateHomeCommand(program); registerDesktopCommand(program); registerRollbackCommand(program); registerReviewCommand(program); +registerReviewsCommands(program); registerApprovalCommands(program); registerLogsCommand(program); +registerModelsCommand(program); await runTool(program, { tool: "codex" }); diff --git a/src/codex/lib/driver.ts b/src/codex/lib/driver.ts index 95dba8be46..759aa82692 100644 --- a/src/codex/lib/driver.ts +++ b/src/codex/lib/driver.ts @@ -8,6 +8,7 @@ import { WorkerDeliveryRejectedError, workerSourceHome } from "@genesiscz/utils/ import type { WorkerDriver, WorkerVerbOutcome } from "@genesiscz/utils/worker/driver"; import { type CodexControl, parseControlTarget } from "./control"; import { sendControlRequest } from "./control-channel"; +import { prepareReview } from "./review/prepare"; import { isCodexDaemonPid, parseWritePolicy, schemaDriftWarning, spawnCodexSession } from "./spawn"; import { type CodexSessionMeta, CodexSessionStore, deriveSessionStatus } from "./store"; import { followSessionEvents } from "./tail-events"; @@ -52,12 +53,28 @@ export const codexDriver: WorkerDriver<CodexSessionMeta> = { command .option("--computer-use", "Configure installed official Mac Computer Use for this worker") .option("--home <path>", "CODEX_HOME override") - .option("--effort <effort>", "Reasoning effort") + .option( + "--effort <effort>", + "Reasoning effort; for --mode review a tier (high | xhigh | max, or low | medium with --allow-shallow), default from the diff size" + ) .option( "--write <policy>", "ask | allow | deny | full (full = no sandbox, refused in a main checkout: use a throwaway worktree)" ) - .option("--mode <mode>", "review | task", "task") + .option( + "--mode <mode>", + "review | task. review: injects the diff manifest and the tier's review contract, then gates the depth after each turn", + "task" + ) + .option( + "--base <ref>", + "--mode review: the ref to diff against (git diff <base>...HEAD); default the branch's PR target, else its upstream" + ) + .option("--allow-shallow", "--mode review: accept --effort low or medium, which the gate never tunes up") + .option( + "--paths <path...>", + "--mode review: limit the manifest to these paths (one slice of a split review)" + ) .option("--no-agents", `Disable ${toolCommand("agents")} integration`) .option("--session <id>", `Parent ${toolCommand("agents")} session id`) .option("--writable-root <path...>", "Additional writable roots") @@ -95,12 +112,19 @@ export const codexDriver: WorkerDriver<CodexSessionMeta> = { session?: string; writableRoot?: string[]; project?: string; + base?: string; + allowShallow?: boolean; + paths?: string[]; }; if (extras.mode !== "review" && extras.mode !== "task") { throw new Error("--mode must be review or task"); } + if (extras.mode === "task" && (extras.base || extras.allowShallow || extras.paths)) { + throw new Error("--base, --allow-shallow and --paths apply to --mode review only"); + } + // A silent no-op here reads as isolation that was applied. It never was, so this // refuses instead of pretending: a caller who asked for isolation and did not get it // must find out now, not from a worker that turned out to load every skill. @@ -116,6 +140,25 @@ export const codexDriver: WorkerDriver<CodexSessionMeta> = { ); } + const prepared = + extras.mode === "review" + ? await prepareReview({ + cwd: input.cwd, + ...(extras.base ? { base: extras.base } : {}), + ...(extras.effort === undefined ? {} : { effort: extras.effort }), + allowShallow: extras.allowShallow === true, + ...(extras.paths ? { paths: extras.paths } : {}), + }) + : null; + + for (const note of prepared?.notes ?? []) { + out.log.info(note); + } + + for (const warning of prepared?.warnings ?? []) { + out.log.warn(warning); + } + const meta = await spawnCodexSession({ name: input.name, cwd: input.cwd, @@ -125,6 +168,7 @@ export const codexDriver: WorkerDriver<CodexSessionMeta> = { ...(extras.computerUse === undefined ? {} : { computerUse: extras.computerUse }), ...(extras.home === undefined ? {} : { home: extras.home }), ...(extras.effort === undefined ? {} : { effort: extras.effort }), + ...(prepared ? { review: { ...prepared.config, step: 0, chain: input.name } } : {}), write: parseWritePolicy(extras.write), mode: extras.mode, agents: extras.agents, diff --git a/src/codex/lib/migrate-home.test.ts b/src/codex/lib/migrate-home.test.ts index 46de6cba37..1b43f9b366 100644 --- a/src/codex/lib/migrate-home.test.ts +++ b/src/codex/lib/migrate-home.test.ts @@ -3,8 +3,9 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { basename, join, relative } from "node:path"; import { listArchive } from "@genesiscz/utils/compress/tar"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import type { OpenFilesQuery, OpenFilesResult } from "@genesiscz/utils/process/open-files"; import { @@ -957,3 +958,29 @@ describe("inspectHome", () => { expect(report.holders[0]?.pid).toBe(4242); }); }); + +describe("compressed rollouts (Codex 0.164 writes old ones as .jsonl.zst)", () => { + test("are listed by thread id and count as already migrated when the destination holds the plain form", async () => { + const root = scratch(); + const thread = uuid("0091"); + const destination = makeHome(root, "destination", [{ date: "2026-10-01", uuid: thread }]); + const plain = enumerateRollouts(join(destination, "sessions"))[0]; + const source = makeHome(root, "source", []); + const day = join(source, "sessions", "2026", "10", "01"); + mkdirSync(day, { recursive: true }); + writeFileSync(join(day, `${basename(plain?.path ?? "")}.zst`), compressBytes(readFileSync(plain?.path ?? ""))); + + expect(enumerateRollouts(join(source, "sessions")).map((file) => file.nativeId)).toEqual([thread]); + + const plan = await migrateHome({ + from: [source], + to: destination, + apply: false, + backupRoot: join(root, "backups"), + stamp: "20261025-223000", + inspectOpenFiles: clear, + }); + + expect(plan.totals).toMatchObject({ rollouts: 1, toCopy: 0, alreadyPresent: 1, collisions: 0 }); + }); +}); diff --git a/src/codex/lib/migrate-home.ts b/src/codex/lib/migrate-home.ts index 126c5c25db..ec54d143f9 100644 --- a/src/codex/lib/migrate-home.ts +++ b/src/codex/lib/migrate-home.ts @@ -5,10 +5,12 @@ import { appendFile, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join, relative, resolve } from "node:path"; import { applyFolderRetention, planFolderRetention } from "@genesiscz/utils/compress/retention"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readFirstLines, readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { type OpenFilesQuery, type OpenFilesResult, openFiles } from "@genesiscz/utils/process/open-files"; -import { codexHomesIn } from "@genesiscz/utils/providers/session-paths"; +import { codexHomesIn, isNativeTranscript, transcriptStem } from "@genesiscz/utils/providers/session-paths"; import { Storage } from "@genesiscz/utils/storage"; const { log } = logger.scoped("codex-migrate-home"); @@ -25,7 +27,8 @@ export const PROVENANCE_NOTE = "AILaunchers/Verify-CodexAccountProvenance.md in the notes vault holds the per-rollout home mapping; " + "after the move `session_metadata.source_home` is re-keyed to the destination and that attribution is gone."; -const ROLLOUT_ID = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +/** Codex 0.164 compresses old rollouts to `.jsonl.zst`; the thread id is the same in both names. */ +const ROLLOUT_ID = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i; export interface RolloutFile { /** Absolute path of the rollout. */ @@ -329,7 +332,7 @@ export function enumerateRollouts(sessionsDir: string): RolloutFile[] { continue; } - if (!entry.isFile() || !entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl")) { + if (!entry.isFile() || !entry.name.startsWith("rollout-") || !isNativeTranscript("codex", entry.name)) { continue; } @@ -348,8 +351,7 @@ export function enumerateRollouts(sessionsDir: string): RolloutFile[] { export async function readRolloutMetaIds(path: string): Promise<RolloutMetaIds> { try { - const head = await Bun.file(path).slice(0, 16_384).text(); - const firstLine = head.split("\n", 1)[0]; + const firstLine = readFirstLines(path, { maxBytes: 16_384 }).lines[0]; if (!firstLine) { return {}; @@ -369,7 +371,18 @@ async function sha256(path: string): Promise<string> { .digest("hex"); } +/** SHA-256 of the rollout's text: a `.jsonl.zst` is decompressed first, so both forms of one rollout compare equal. */ +async function textSha256(path: string): Promise<string> { + return createHash("sha256") + .update(isZstdPath(path) ? readWholeTextSync(path) : await readFile(path)) + .digest("hex"); +} + async function sameContent(left: RolloutFile, right: RolloutFile): Promise<boolean> { + if (isZstdPath(left.path) !== isZstdPath(right.path)) { + return (await textSha256(left.path)) === (await textSha256(right.path)); + } + if (left.size !== right.size) { return false; } @@ -1077,7 +1090,9 @@ export async function migrateHome(options: MigrateHomeOptions = {}): Promise<Mig // Same id at the same place with the same bytes is this command's own earlier run, // which is what makes a repeat a no-op. Anything else sharing an id is a genuine // clash and the whole run refuses rather than picking a winner. - const migrated = existing.relativePath === file.relativePath && (await sameContent(existing, file)); + const migrated = + transcriptStem(existing.relativePath) === transcriptStem(file.relativePath) && + (await sameContent(existing, file)); if (migrated) { source.alreadyPresent += 1; diff --git a/src/codex/lib/review/controller.ts b/src/codex/lib/review/controller.ts new file mode 100644 index 0000000000..9f09b87a4e --- /dev/null +++ b/src/codex/lib/review/controller.ts @@ -0,0 +1,207 @@ +import type { CodexFinishedTurn } from "@genesiscz/utils/codex/turn-cost"; +import { logger } from "@genesiscz/utils/logger"; +import type { ReviewEffort } from "@genesiscz/utils/worker/contract"; +import { sumPartTokens } from "@genesiscz/utils/worker/cost"; +import { checkReview, decideGate, type GateDecision, parseReviewReport, type ReviewGateChecks } from "./gate"; +import { appendReviewRecord, type ReviewGateRecord, reviewLedgerBase } from "./ledger"; +import type { CodexReviewConfig, CodexReviewSpent, ReviewVerdict } from "./types"; + +const log = logger.child({ component: "codex:review-gate" }); + +const UNREAD_RECORDED = 40; + +export type ReviewGateAction = + | { kind: "none" } + | { kind: "steer"; text: string } + | { kind: "escalate"; effort: ReviewEffort; name: string }; + +export interface ReviewTurnOutcome { + /** The session's review record after this turn: spent, step and verdict updated. */ + review: CodexReviewConfig; + action: ReviewGateAction; + /** Absent when the gate did not run: a failed or interrupted turn, or a review whose verdict is final. */ + decision?: GateDecision; + checks?: ReviewGateChecks; +} + +export interface ReviewTurnInput { + name: string; + label?: string; + review: CodexReviewConfig; + finished: CodexFinishedTurn; + /** The final agent message of the turn. */ + report: string; + costUsd: number | null; + ledgerPath?: string; +} + +/** The name an escalated review runs under: the chain's first name and the new tier. */ +export function escalatedReviewName(review: Pick<CodexReviewConfig, "chain">, effort: ReviewEffort): string { + return `${review.chain}-${effort}`; +} + +function addSpent(spent: CodexReviewSpent | undefined, finished: CodexFinishedTurn, costUsd: number | null) { + const tokens = sumPartTokens(finished.parts); + const before = spent ?? { turns: 0, durationMs: 0, toolCalls: 0, reasoningTokens: 0, outputTokens: 0, costUsd: 0 }; + + return { + turns: before.turns + 1, + durationMs: before.durationMs + (finished.durationMs ?? 0), + toolCalls: before.toolCalls + finished.toolCalls, + reasoningTokens: before.reasoningTokens + tokens.reasoning, + outputTokens: before.outputTokens + tokens.output, + costUsd: before.costUsd === null || costUsd === null ? null : before.costUsd + costUsd, + } satisfies CodexReviewSpent; +} + +/** A review whose verdict is final takes follow-up turns without the gate: the lead asked something else. */ +function gateIsOpen(review: CodexReviewConfig): boolean { + return review.verdict === undefined || review.verdict === "steered"; +} + +/** + * Books one finished review turn and decides the gate step. Writes one `turn` line, and one `gate` line when the + * gate ran, to the review ledger. The caller performs the action: a steer into the same session, or an escalated + * spawn under {@link escalatedReviewName}. Nothing here spawns or steers, so the whole decision is testable. + */ +export function recordReviewTurn(input: ReviewTurnInput): ReviewTurnOutcome { + const { finished, report } = input; + const tokens = sumPartTokens(finished.parts); + const knownTokens = finished.parts.length > 0; + const spent = addSpent(input.review.spent, finished, input.costUsd); + const parsed = parseReviewReport(report); + const base = reviewLedgerBase({ name: input.name, review: input.review, label: input.label, at: finished.endedAt }); + + appendReviewRecord( + base, + { + kind: "turn", + turn: finished.turn, + outcome: finished.outcome, + model: finished.model, + durationMs: finished.durationMs, + toolCalls: finished.toolCalls, + reasoningTokens: knownTokens ? tokens.reasoning : null, + outputTokens: knownTokens ? tokens.output : null, + costUsd: input.costUsd, + coverage: parsed.coverage, + }, + input.ledgerPath + ); + + const counted: CodexReviewConfig = { ...input.review, spent }; + + if (finished.outcome !== "completed" || !gateIsOpen(input.review)) { + return { review: counted, action: { kind: "none" } }; + } + + const checks = checkReview({ + report, + manifest: input.review.manifest, + effort: input.review.effort, + reasoningTokens: knownTokens || spent.reasoningTokens > 0 ? spent.reasoningTokens : null, + }); + const decision = decideGate(checks, input.review); + const escalatedTo = + decision.verdict === "escalated" && decision.nextEffort + ? escalatedReviewName(input.review, decision.nextEffort) + : undefined; + const review: CodexReviewConfig = { + ...counted, + verdict: decision.verdict, + reason: decision.reason, + step: + decision.verdict === "steered" || decision.verdict === "escalated" + ? input.review.step + 1 + : input.review.step, + ...(escalatedTo ? { escalatedTo } : {}), + }; + + writeGateRecord({ input, review, checks, decision, escalatedTo }); + log.debug( + { name: input.name, verdict: decision.verdict, reason: decision.reason, step: input.review.step }, + "review gate decided" + ); + + if (decision.verdict === "steered" && decision.steer) { + return { review, action: { kind: "steer", text: decision.steer }, decision, checks }; + } + + if (decision.verdict === "escalated" && decision.nextEffort && escalatedTo) { + return { + review, + action: { kind: "escalate", effort: decision.nextEffort, name: escalatedTo }, + decision, + checks, + }; + } + + return { review, action: { kind: "none" }, decision, checks }; +} + +function writeGateRecord(options: { + input: Pick<ReviewTurnInput, "name" | "label" | "ledgerPath">; + review: CodexReviewConfig; + checks: ReviewGateChecks | null; + decision: Pick<GateDecision, "verdict" | "reason">; + escalatedTo?: string; +}): ReviewGateRecord | null { + const { input, review, checks, decision } = options; + const record = appendReviewRecord( + reviewLedgerBase({ name: input.name, review, label: input.label }), + { + kind: "gate", + step: review.step, + verdict: decision.verdict, + reason: decision.reason, + coverage: { + reviewed: checks?.coverage.reviewed ?? null, + total: checks?.coverage.total ?? review.manifest.files.length, + }, + reasoningPer100Lines: checks?.reasoning.per100Lines ?? null, + floor: checks?.reasoning.floor ?? 0, + findings: checks?.shape.findings ?? 0, + malformedFindings: checks?.shape.malformed.length ?? 0, + unread: checks?.coverage.unread.slice(0, UNREAD_RECORDED) ?? [], + ...(checks + ? { + checks: { + coverage: { ok: checks.coverage.ok, detail: checks.coverage.detail }, + reasoning: { ok: checks.reasoning.ok, detail: checks.reasoning.detail }, + shape: { ok: checks.shape.ok, detail: checks.shape.detail }, + }, + } + : {}), + ...(options.escalatedTo ? { escalatedTo: options.escalatedTo } : {}), + spent: review.spent ?? { + turns: 0, + durationMs: 0, + toolCalls: 0, + reasoningTokens: 0, + outputTokens: 0, + costUsd: null, + }, + }, + input.ledgerPath + ); + + return record?.kind === "gate" ? record : null; +} + +/** + * The gate wanted a step the runtime could not take (the steer was refused, the escalated spawn failed): the review + * ends `shallow` with that reason, in the session record and as one more gate line. + */ +export function markReviewShallow(input: { + name: string; + label?: string; + review: CodexReviewConfig; + reason: string; + ledgerPath?: string; +}): CodexReviewConfig { + const verdict: ReviewVerdict = "shallow"; + const review: CodexReviewConfig = { ...input.review, verdict, reason: input.reason, escalatedTo: undefined }; + writeGateRecord({ input, review, checks: null, decision: { verdict, reason: input.reason } }); + log.warn({ name: input.name, reason: input.reason }, "review marked shallow"); + return review; +} diff --git a/src/codex/lib/review/escalate.ts b/src/codex/lib/review/escalate.ts new file mode 100644 index 0000000000..3170698324 --- /dev/null +++ b/src/codex/lib/review/escalate.ts @@ -0,0 +1,67 @@ +import { logger } from "@genesiscz/utils/logger"; +import type { EscalateReviewRequest } from "../session"; +import { type LaunchConfig, spawnCodexSession } from "../spawn"; +import type { CodexReviewConfig } from "./types"; + +const log = logger.child({ component: "codex:review-escalate" }); + +const FALLBACK_PROMPT = "Review the change between the base and HEAD that the manifest lists."; + +/** + * The review record of the escalated session: the same base, manifest and prompt one tier higher, at step 2 so + * its own miss ends the chain `shallow`. Its verdict, reason and spend start empty. + */ +export function escalatedReview( + review: CodexReviewConfig, + request: Pick<EscalateReviewRequest, "effort" | "meta"> +): CodexReviewConfig { + return { + base: review.base, + baseSource: review.baseSource, + baseDetail: review.baseDetail, + ...(review.branch ? { branch: review.branch } : {}), + ...(review.pr === undefined ? {} : { pr: review.pr }), + manifest: review.manifest, + effort: request.effort, + effortSource: "flag", + allowShallow: review.allowShallow, + step: 2, + chain: review.chain, + parent: request.meta.name, + ...(review.prompt ? { prompt: review.prompt } : {}), + }; +} + +/** + * Starts the escalated review from inside the daemon: the same cwd, home, account, write policy, swarm and + * writable roots as the session that missed, under the name the gate recorded. A steer cannot change a running + * thread's effort, which is why the higher tier needs a new session. + */ +export async function escalateCodexReview(request: EscalateReviewRequest, launch: LaunchConfig): Promise<void> { + const { meta } = request; + + if (!meta.review) { + throw new Error(`${meta.name} is not a review session`); + } + + const review = escalatedReview(meta.review, request); + log.info({ from: meta.name, to: request.name, effort: request.effort }, "starting the escalated review"); + + await spawnCodexSession({ + name: request.name, + cwd: meta.cwd, + ...(meta.home ? { home: meta.home } : {}), + ...(meta.model ? { model: meta.model } : {}), + ...(launch.accountId ? { account: launch.accountId } : {}), + ...(launch.computerUse ? { computerUse: true } : {}), + ...(launch.project ? { project: launch.project } : {}), + ...(meta.label ? { label: meta.label } : {}), + write: meta.writePolicy, + mode: "review", + agents: meta.agentsEnabled, + rendezvousSession: meta.rendezvousSession, + writableRoots: launch.writableRoots, + prompt: review.prompt ?? FALLBACK_PROMPT, + review, + }); +} diff --git a/src/codex/lib/review/gate.ts b/src/codex/lib/review/gate.ts new file mode 100644 index 0000000000..8e9222c164 --- /dev/null +++ b/src/codex/lib/review/gate.ts @@ -0,0 +1,355 @@ +import { + manifestChangedLines, + REVIEW_EFFORTS, + type ReviewEffort, + type ReviewManifest, +} from "@genesiscz/utils/worker/contract"; +import type { CodexReviewConfig, ReviewVerdict } from "./types"; + +/** + * Reasoning tokens per 100 changed lines a tier must reach. Measured 2026-10-22 on a 6,200-line diff: medium + * spent about 40, xhigh about 390. First guesses from one day of data; tune them from `tools codex reviews`. + */ +export const REASONING_FLOOR_PER_100_LINES: Record<ReviewEffort, number> = { + low: 0, + medium: 0, + high: 100, + xhigh: 250, + max: 250, +}; + +/** The most unread files one steer names; the rest are counted. */ +const STEER_FILE_LIMIT = 40; + +export interface ParsedFinding { + index: number; + text: string; + ok: boolean; +} + +export interface ParsedReviewReport { + coverage: { reviewed: number; total: number } | null; + notReviewed: Array<{ path: string; reason: string | null }>; + findings: ParsedFinding[]; + findingsNone: boolean; + ran: boolean; +} + +/** + * A pair of `*`, `**`, `_` or `__` around a phrase: opened after a space or the line start, closed before a space, + * punctuation or the line end. An underscore inside a word (`app_icon.png`, `__init__.py`) is not emphasis. + */ +const EMPHASIS = /(^|[\s([])(\*{1,2}|_{1,2})(?=\S)(.*?\S)\2(?=$|[\s)\]:,;!?]|\.(?:\s|$))/g; + +/** Markdown emphasis, code ticks and a bullet a model wraps around the fixed lines; they carry no meaning here. */ +function plain(line: string): string { + let text = line.replace(/`/g, "").replace(/^\s*[*-]\s+/, ""); + + // Twice at most: `***both***` is one pair inside another. + for (let pass = 0; pass < 2; pass++) { + text = text.replace(EMPHASIS, "$1$3"); + } + + return text.trim(); +} + +const FINDING_START = /^(\d+)[.)]\s+(.*)$/; +/** `[Severity] path:line`: the path runs up to its `:line`, so a path with spaces in it (`src/my file.ts:12`) parses. */ +const FINDING_SHAPE = /^\[(critical|high|medium|low)\]\s+\S(?:[^:]*?\S)?:\d+(?=$|[\s:,;)\-\u2013\u2014])/i; +const SECTION_END = /^(ran|files reviewed|not reviewed|result|at|changed|verify|open|findings):/i; + +function parseNotReviewed(value: string): ParsedReviewReport["notReviewed"] { + if (/^none\.?$/i.test(value.trim())) { + return []; + } + + return value + .split(/,\s+(?![^()]*\))/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const match = /^(.*?)\s*\((.*)\)\s*$/.exec(entry); + return match ? { path: match[1].trim(), reason: match[2].trim() || null } : { path: entry, reason: null }; + }); +} + +/** Reads the review shape the contract asks for (`reviewLines`): numbered findings, `Ran:`, `Files reviewed:`, `Not reviewed:`. */ +export function parseReviewReport(report: string): ParsedReviewReport { + const parsed: ParsedReviewReport = { + coverage: null, + notReviewed: [], + findings: [], + findingsNone: false, + ran: false, + }; + let current: ParsedFinding | null = null; + + for (const raw of report.split("\n")) { + const line = plain(raw); + const coverage = /^files reviewed:\s*(\d+)\s*(?:of|\/)\s*(\d+)/i.exec(line); + + if (coverage) { + parsed.coverage = { reviewed: Number(coverage[1]), total: Number(coverage[2]) }; + } + + const notReviewed = /^not reviewed:\s*(.*)$/i.exec(line); + + if (notReviewed) { + parsed.notReviewed = parseNotReviewed(notReviewed[1]); + } + + if (/^ran:/i.test(line)) { + parsed.ran = true; + } + + if (/^findings:\s*none\b/i.test(line)) { + parsed.findingsNone = true; + } + + if (SECTION_END.test(line)) { + current = null; + continue; + } + + const start = FINDING_START.exec(line); + + if (start && !parsed.ran && !parsed.coverage) { + current = { index: Number(start[1]), text: start[2], ok: false }; + parsed.findings.push(current); + continue; + } + + if (current && line) { + current.text = `${current.text} ${line}`; + } + } + + for (const finding of parsed.findings) { + finding.ok = FINDING_SHAPE.test(finding.text) && /scenario:/i.test(finding.text) && /fix:/i.test(finding.text); + } + + return parsed; +} + +export interface GateCheck { + ok: boolean; + detail: string; +} + +export interface ReviewGateChecks { + coverage: GateCheck & { reviewed: number | null; total: number; unread: string[] }; + reasoning: GateCheck & { per100Lines: number | null; floor: number }; + shape: GateCheck & { findings: number; malformed: number[] }; +} + +/** Reasoning tokens per 100 changed lines, rounded; null when the tokens are unknown. */ +export function reasoningPer100Lines(reasoningTokens: number | null, manifest: ReviewManifest): number | null { + if (reasoningTokens === null) { + return null; + } + + return Math.round((reasoningTokens * 100) / Math.max(1, manifestChangedLines(manifest))); +} + +/** + * Coverage against the manifest: the reported total is the manifest's, the reviewed count plus the excused binary + * files reach it, and the Not reviewed line names no text file. Exported for `reviews import`, which reaches the + * same verdict from a backfilled final message. + */ +export function checkCoverage( + parsed: ParsedReviewReport, + manifest: ReviewManifest, + report: string +): ReviewGateChecks["coverage"] { + const total = manifest.files.length; + + if (!parsed.coverage) { + return { + ok: false, + detail: "no `Files reviewed: n of N` line", + reviewed: null, + total, + unread: manifest.files.map((file) => file.path), + }; + } + + const { reviewed } = parsed.coverage; + const binary = new Set(manifest.files.filter((file) => file.binary).map((file) => file.path)); + // A binary file has no text to read: named with a reason, it does not count as unread. Each one counts once, + // so naming the same binary twice cannot make up for a text file left unread. + const excused = new Set( + parsed.notReviewed.filter((entry) => entry.reason && binary.has(entry.path)).map((entry) => entry.path) + ).size; + // A text file on the Not reviewed line is an admitted gap, whatever the count says. + const named = [...new Set(parsed.notReviewed.map((entry) => entry.path).filter((path) => !binary.has(path)))]; + const ok = parsed.coverage.total === total && reviewed + excused >= total && named.length === 0; + + if (ok) { + return { ok, detail: `${reviewed} of ${total} files reviewed`, reviewed, total, unread: [] }; + } + + const missing = total - reviewed - excused; + // The worker named fewer files than it skipped: the manifest files its report never mentions are the best guess. + const unmentioned = + named.length >= missing + ? [] + : manifest.files.map((file) => file.path).filter((path) => !named.includes(path) && !report.includes(path)); + const detail = + parsed.coverage.total !== total + ? `reported ${reviewed} of ${parsed.coverage.total}, but the manifest has ${total} files` + : reviewed + excused >= total + ? `${reviewed} of ${total} files reviewed, but Not reviewed names ${named.length} text file(s)` + : `${reviewed} of ${total} files reviewed`; + + return { ok, detail, reviewed, total, unread: [...new Set([...named, ...unmentioned])] }; +} + +function checkShape(parsed: ParsedReviewReport): ReviewGateChecks["shape"] { + const malformed = parsed.findings.filter((finding) => !finding.ok).map((finding) => finding.index); + const problems: string[] = []; + + if (parsed.findings.length === 0 && !parsed.findingsNone) { + problems.push("no numbered findings and no `Findings: none` line"); + } + + if (malformed.length > 0) { + problems.push( + `findings ${malformed.map((index) => `#${index}`).join(", ")} lack severity, path:line, Scenario or Fix` + ); + } + + if (!parsed.ran) { + problems.push("no `Ran:` line"); + } + + return { + ok: problems.length === 0, + detail: problems.length === 0 ? `${parsed.findings.length} findings parse` : problems.join("; "), + findings: parsed.findings.length, + malformed, + }; +} + +/** The three depth checks of one review turn: coverage against the manifest, the reasoning floor, and finding shape. */ +export function checkReview(input: { + report: string; + manifest: ReviewManifest; + effort: ReviewEffort; + /** Reasoning tokens of every review turn of the session so far; null when the backend did not report them. */ + reasoningTokens: number | null; +}): ReviewGateChecks { + const parsed = parseReviewReport(input.report); + const floor = REASONING_FLOOR_PER_100_LINES[input.effort]; + const per100Lines = reasoningPer100Lines(input.reasoningTokens, input.manifest); + const reasoningOk = per100Lines === null || per100Lines >= floor; + + return { + coverage: checkCoverage(parsed, input.manifest, input.report), + reasoning: { + ok: reasoningOk, + detail: + per100Lines === null + ? "reasoning tokens unknown" + : `${per100Lines} reasoning tokens per 100 changed lines, ${input.effort} floor ${floor}`, + per100Lines, + floor, + }, + shape: checkShape(parsed), + }; +} + +export function nextReviewEffort(effort: ReviewEffort): ReviewEffort | null { + const index = REVIEW_EFFORTS.indexOf(effort); + return index >= 0 && index < REVIEW_EFFORTS.length - 1 ? REVIEW_EFFORTS[index + 1] : null; +} + +function failedDetails(checks: ReviewGateChecks): string[] { + return (["coverage", "reasoning", "shape"] as const) + .filter((key) => !checks[key].ok) + .map((key) => `${key}: ${checks[key].detail}`); +} + +/** The one steer the gate sends: the unread files by name, the reasoning gap, the findings that do not parse. */ +export function steerText(checks: ReviewGateChecks, effort: ReviewEffort): string { + const lines = ["The review gate found this review incomplete. Continue the same review now:"]; + const unread = checks.coverage.unread; + + if (!checks.coverage.ok && unread.length > 0) { + const named = unread.slice(0, STEER_FILE_LIMIT).join(", "); + const more = + unread.length > STEER_FILE_LIMIT ? `, and ${unread.length - STEER_FILE_LIMIT} more manifest files` : ""; + lines.push(`You did not read: ${named}${more}. Review them now with the same output shape.`); + } else if (!checks.coverage.ok) { + lines.push(`Coverage: ${checks.coverage.detail}. Account for every file of the manifest.`); + } + + if (!checks.reasoning.ok) { + lines.push( + `Depth: ${checks.reasoning.detail}. Go deeper at the ${effort} tier: open the callers and tests of the riskiest changes and probe each finding.` + ); + } + + if (!checks.shape.ok) { + lines.push( + `Shape: ${checks.shape.detail}. Write each finding as \`N. [Severity] path:line - defect. Scenario: ... Fix: ...\`.` + ); + } + + lines.push( + `End with the full findings list (earlier ones included), the Ran line, \`Files reviewed: <n> of ${checks.coverage.total}\`, the Not reviewed line and the five report lines.` + ); + return lines.join("\n"); +} + +export interface GateDecision { + verdict: ReviewVerdict; + reason: string; + /** For `steered`: the text sent to the same session. */ + steer?: string; + /** For `escalated`: the tier of the new session. */ + nextEffort?: ReviewEffort; +} + +/** + * What the gate does after a review turn. At most two automatic steps per review chain: the first miss steers the + * same session (step 0), the second reruns one tier higher in a new session (step 1), and a miss after that, or a + * miss with no tier left, is `shallow`. A shallow tier the caller asked for (`--allow-shallow`) is never tuned up. + */ +export function decideGate( + checks: ReviewGateChecks, + config: Pick<CodexReviewConfig, "effort" | "step" | "allowShallow"> +): GateDecision { + const misses = failedDetails(checks); + + if (config.effort === "low") { + return { verdict: "shallow", reason: "low is triage only (--allow-shallow); the depth gate does not apply" }; + } + + if (misses.length === 0) { + return { + verdict: "pass", + reason: `${checks.coverage.detail}; ${checks.reasoning.detail}; ${checks.shape.detail}`, + }; + } + + const missed = misses.join("; "); + + if (config.effort === "medium" && config.allowShallow) { + return { verdict: "shallow", reason: `${missed} (medium with --allow-shallow: no automatic step)` }; + } + + if (config.step === 0) { + return { verdict: "steered", reason: missed, steer: steerText(checks, config.effort) }; + } + + const next = nextReviewEffort(config.effort); + + if (config.step === 1 && next) { + return { verdict: "escalated", reason: `${missed}; rerun at ${next}`, nextEffort: next }; + } + + return { + verdict: "shallow", + reason: `${missed}; ${config.step >= 2 ? "both automatic steps are spent" : `no tier above ${config.effort}`}`, + }; +} diff --git a/src/codex/lib/review/import.ts b/src/codex/lib/review/import.ts new file mode 100644 index 0000000000..c19cbcd98f --- /dev/null +++ b/src/codex/lib/review/import.ts @@ -0,0 +1,380 @@ +import { existsSync } from "node:fs"; +import { readWorkerCostRows, type WorkerCostRow } from "@app/ai-spend/lib/workers/ledger"; +import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; +import { isReviewEffort, manifestChangedLines, type ReviewManifest } from "@genesiscz/utils/worker/contract"; +import { type CodexSessionMeta, CodexSessionStore } from "../store"; +import { + checkCoverage, + type ParsedReviewReport, + parseReviewReport, + REASONING_FLOOR_PER_100_LINES, + reasoningPer100Lines, +} from "./gate"; +import { + appendReviewRecords, + type ReviewLedgerBase, + type ReviewLedgerRecord, + readReviewRecords, + reviewLedgerPath, +} from "./ledger"; +import { buildManifest, type ChosenBase, chooseBase, defaultReviewGitReaders, type ReviewGitReaders } from "./prepare"; +import type { CodexReviewSpent, ReviewVerdict } from "./types"; + +const log = logger.child({ component: "codex:review-import" }); + +/** The last agent message of the session's own thread, from its event log; "" when it has none. */ +export async function lastAgentMessage(store: CodexSessionStore, meta: CodexSessionMeta): Promise<string> { + const events = await store.readEvents(meta.name); + + for (const event of events.toReversed()) { + const params = event.params; + + if (event.method !== "item/completed" || !isObject(params) || !isObject(params.item)) { + continue; + } + + const { item, threadId } = params; + + if ( + item.type === "agentMessage" && + typeof item.text === "string" && + (threadId === undefined || threadId === meta.threadId) + ) { + return item.text; + } + } + + return ""; +} + +/** Everything an import reads; injected so tests need no codex, no git and no network. */ +export interface ReviewImportReaders { + readMeta(name: string): Promise<CodexSessionMeta | null>; + /** The session's booked turns from the worker cost ledger, oldest first. */ + costRows(meta: CodexSessionMeta): WorkerCostRow[]; + lastAgentMessage(meta: CodexSessionMeta): Promise<string>; + cwdExists(path: string): boolean; + git: ReviewGitReaders; + /** The review ledger as it is now, for the already-imported check. */ + ledgerRecords(): ReviewLedgerRecord[]; +} + +export function defaultReviewImportReaders(ledgerPath = reviewLedgerPath()): ReviewImportReaders { + const store = new CodexSessionStore(); + + return { + readMeta: async (name) => store.readMeta(name), + costRows: (meta) => + readWorkerCostRows() + .filter( + (row) => + row.backend === "codex" && + row.name === meta.name && + (!meta.threadId || !row.sessionId || row.sessionId === meta.threadId) + ) + .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts)), + lastAgentMessage: (meta) => lastAgentMessage(store, meta), + cwdExists: (path) => existsSync(path), + git: defaultReviewGitReaders(), + ledgerRecords: () => readReviewRecords(ledgerPath).records, + }; +} + +export interface ReviewImportManifest { + base: string; + /** The commit the manifest ends at: the last one before the session started, else HEAD. */ + head: string; + files: number; + lines: number; +} + +export interface ReviewImportResult { + name: string; + /** `failed`: the ledger write failed, nothing of this review is in the ledger, and a rerun imports it. */ + status: "imported" | "skipped" | "missing" | "failed"; + verdict?: ReviewVerdict; + manifest: ReviewImportManifest | null; + /** Why something is missing or approximate, in one sentence per fact. */ + notes: string[]; +} + +export interface ReviewImportInput { + names: string[]; + /** One base for every named session; without it the PR target or upstream of each session's branch. */ + base?: string; + /** Read the manifest in the session's own cwd (default). False leaves every manifest null. */ + cwdFromSession?: boolean; + readers?: ReviewImportReaders; + ledgerPath?: string; +} + +interface ManifestOutcome { + manifest: ReviewManifest | null; + chosen: ChosenBase | null; + branch: string | null; + head: string | null; + notes: string[]; +} + +async function importManifest(meta: CodexSessionMeta, input: ReviewImportInput, git: ReviewGitReaders) { + const none = (note: string): ManifestOutcome => ({ + manifest: null, + chosen: null, + branch: null, + head: null, + notes: [note], + }); + + if (input.cwdFromSession === false) { + return none("manifest skipped (--no-cwd-from-session): file and line counts are null"); + } + + const cwd = meta.cwd; + const branch = await git.currentBranch(cwd); + let chosen: ChosenBase; + + try { + chosen = await chooseBase({ cwd, ...(input.base ? { base: input.base } : {}), readers: git, branch }); + } catch (err) { + return none(`no base: ${err instanceof Error ? err.message : String(err)} File and line counts are null.`); + } + + // The worktree may have gained fix commits since the review: diff up to the commit the review saw. + const seen = await git.commitBefore(cwd, meta.startedAt); + const notes = seen + ? [ + `manifest ${chosen.ref}...${seen.slice(0, 10)} (the last commit before the session started at ${meta.startedAt})`, + ] + : [`no commit before ${meta.startedAt}; the manifest runs to HEAD and may include later commits`]; + const head = seen ?? "HEAD"; + const manifest = buildManifest(chosen.ref, await git.numstat(cwd, chosen.ref, undefined, head)); + return { manifest, chosen, branch, head, notes } satisfies ManifestOutcome; +} + +function spentOf(rows: readonly WorkerCostRow[]): CodexReviewSpent { + return { + turns: rows.length, + durationMs: rows.reduce((sum, row) => sum + (row.durationMs ?? 0), 0), + toolCalls: rows.reduce((sum, row) => sum + (row.toolCalls ?? 0), 0), + reasoningTokens: rows.reduce((sum, row) => sum + (row.tokens?.reasoning ?? 0), 0), + outputTokens: rows.reduce((sum, row) => sum + (row.tokens?.output ?? 0), 0), + costUsd: rows.some((row) => row.cost.apiEquivalentUsd === null) + ? null + : rows.reduce((sum, row) => sum + (row.cost.apiEquivalentUsd ?? 0), 0), + }; +} + +/** + * The verdict the final message's coverage line implies: fewer files than its own total is `shallow`; a pass needs + * the gate's coverage check against the rebuilt manifest. A full count whose scope cannot be confirmed (no manifest, + * another total, an unread text file named) stays `imported`, and so does a message with no coverage line. + */ +function impliedVerdict( + parsed: ParsedReviewReport, + manifest: ReviewManifest | null, + report: string +): { verdict: ReviewVerdict; reason: string } { + const { coverage } = parsed; + + if (!coverage) { + return { + verdict: "imported", + reason: "imported from a session that ran before the ledger; its final message has no Files reviewed line", + }; + } + + const mismatch = + manifest && coverage.total !== manifest.files.length + ? `; the reviewed manifest has ${manifest.files.length} files` + : ""; + + if (coverage.reviewed < coverage.total) { + return { + verdict: "shallow", + reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}, ${coverage.total - coverage.reviewed} unread${mismatch}`, + }; + } + + if (!manifest) { + return { + verdict: "imported", + reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}, but no manifest to confirm the scope`, + }; + } + + const check = checkCoverage(parsed, manifest, report); + + return check.ok + ? { verdict: "pass", reason: `imported: Files reviewed ${coverage.reviewed} of ${coverage.total}` } + : { verdict: "imported", reason: `imported: ${check.detail}; the scope is not confirmed` }; +} + +/** + * Whether the ledger already holds this name: a live review line, or an import that reached its gate line. An + * imported start without a gate is a partial import (an older run whose later writes failed): it is redone. + */ +function ledgerState(records: readonly ReviewLedgerRecord[], name: string): "absent" | "present" | "partial" { + const mine = records.filter((record) => record.name === name); + + if (mine.length === 0) { + return "absent"; + } + + return mine.some((record) => !record.imported || record.kind === "gate") ? "present" : "partial"; +} + +async function importOne(name: string, input: ReviewImportInput, readers: ReviewImportReaders, ledgerPath: string) { + const meta = await readers.readMeta(name); + + if (!meta) { + return { + name, + status: "missing", + manifest: null, + notes: ["no codex session record with that name"], + } satisfies ReviewImportResult; + } + + const state = ledgerState(readers.ledgerRecords(), name); + + if (state === "present") { + return { + name, + status: "skipped", + manifest: null, + notes: ["already in the review ledger"], + } satisfies ReviewImportResult; + } + + const outcome = readers.cwdExists(meta.cwd) + ? await importManifest(meta, input, readers.git) + : { + manifest: null, + chosen: null, + branch: null, + head: null, + notes: [`${meta.cwd} is gone: file and line counts are null`], + }; + const { manifest, chosen } = outcome; + const rows = readers.costRows(meta); + const report = await readers.lastAgentMessage(meta); + const parsed = parseReviewReport(report); + const notes = [...outcome.notes]; + + if (state === "partial") { + notes.push("an earlier import of this name has no gate line; this import replaces it"); + } + + if (rows.length === 0) { + notes.push("no booked turns in the worker cost ledger"); + } + + if (!report) { + notes.push("no final agent message in the session's event log"); + } + + const effort = meta.effort ?? "default"; + const base: ReviewLedgerBase = { + v: 1, + ts: meta.startedAt, + name, + chain: name, + ...(meta.label ? { label: meta.label } : {}), + ...(chosen?.pr ? { pr: chosen.pr.number } : {}), + ...(outcome.branch ? { branch: outcome.branch } : {}), + base: chosen?.ref ?? input.base ?? null, + files: manifest ? manifest.files.length : null, + lines: manifest ? manifestChangedLines(manifest) : null, + effort, + imported: true, + ...(outcome.head ? { head: outcome.head } : {}), + }; + const spent = spentOf(rows); + const { verdict, reason } = impliedVerdict(parsed, manifest, report); + const records: ReviewLedgerRecord[] = [ + { ...base, kind: "start", cwd: meta.cwd, effortSource: "imported", allowShallow: false }, + ...rows.map( + (row, index): ReviewLedgerRecord => ({ + ...base, + ts: row.ts, + kind: "turn", + turn: row.turn ?? index + 1, + outcome: row.outcome ?? "completed", + model: row.model, + durationMs: row.durationMs, + toolCalls: row.toolCalls ?? 0, + reasoningTokens: row.tokens?.reasoning ?? null, + outputTokens: row.tokens?.output ?? null, + costUsd: row.cost.apiEquivalentUsd, + // Only the final message survives, so only the last turn carries a coverage line. + coverage: index === rows.length - 1 ? parsed.coverage : null, + }) + ), + { + ...base, + ts: rows.at(-1)?.ts ?? meta.lastEventAt, + kind: "gate", + step: 0, + verdict, + reason, + coverage: { + reviewed: parsed.coverage?.reviewed ?? null, + total: parsed.coverage?.total ?? manifest?.files.length ?? 0, + }, + reasoningPer100Lines: manifest ? reasoningPer100Lines(spent.reasoningTokens, manifest) : null, + floor: isReviewEffort(effort) ? REASONING_FLOOR_PER_100_LINES[effort] : 0, + findings: parsed.findings.length, + malformedFindings: parsed.findings.filter((finding) => !finding.ok).length, + unread: parsed.notReviewed.map((entry) => entry.path).slice(0, 40), + spent, + }, + ]; + // One write for the whole review: a failure leaves nothing behind that the already-imported check would trust. + const written = appendReviewRecords(records, ledgerPath); + + if (!written.ok) { + return { + name, + status: "failed", + manifest: null, + notes: [...notes, `the review ledger write failed (${written.error}); nothing was imported`], + } satisfies ReviewImportResult; + } + + log.debug({ name, verdict, files: base.files, base: base.base, head: base.head }, "review imported"); + + return { + name, + status: "imported", + verdict, + manifest: + manifest && chosen + ? { + base: chosen.ref, + head: outcome.head ?? "HEAD", + files: manifest.files.length, + lines: manifestChangedLines(manifest), + } + : null, + notes, + } satisfies ReviewImportResult; +} + +/** + * Backfills the review ledger from codex sessions that ran before it existed: one start line, one turn line per + * booked turn, and one gate line whose verdict the final message's coverage line implies, written together. A + * session already in the ledger is skipped, so a second import of the same names adds nothing; one whose earlier + * import never reached its gate line is imported again. + */ +export async function importReviewSessions(input: ReviewImportInput): Promise<ReviewImportResult[]> { + const ledgerPath = input.ledgerPath ?? reviewLedgerPath(); + const readers = input.readers ?? defaultReviewImportReaders(ledgerPath); + const results: ReviewImportResult[] = []; + + for (const name of [...new Set(input.names)]) { + results.push(await importOne(name, input, readers, ledgerPath)); + } + + return results; +} diff --git a/src/codex/lib/review/ledger.ts b/src/codex/lib/review/ledger.ts new file mode 100644 index 0000000000..190e094909 --- /dev/null +++ b/src/codex/lib/review/ledger.ts @@ -0,0 +1,190 @@ +import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { codexRoot } from "@genesiscz/utils/codex/worker-paths"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { readJsonlRows } from "@genesiscz/utils/jsonl"; +import { JsonlWriter } from "@genesiscz/utils/log-session/jsonl-writer"; +import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; +import { manifestChangedLines } from "@genesiscz/utils/worker/contract"; +import type { GateCheck } from "./gate"; +import type { CodexReviewConfig, CodexReviewSpent, ReviewVerdict } from "./types"; + +const log = logger.child({ component: "codex:review-ledger" }); + +/** `~/.genesis-tools/codex/reviews.jsonl`: one line per review start, review turn and gate decision. */ +export function reviewLedgerPath(): string { + return join(codexRoot(), "reviews.jsonl"); +} + +export interface ReviewLedgerBase { + v: 1; + ts: string; + name: string; + chain: string; + label?: string; + pr?: number; + branch?: string; + /** Null only on an imported review whose base or worktree could not be found. */ + base: string | null; + files: number | null; + lines: number | null; + /** A review tier; an imported session keeps whatever effort it ran at. */ + effort: string; + /** Backfilled by `tools codex reviews import` from a session that ran before the ledger existed. */ + imported?: true; + /** Imported: the commit the review is taken to have read (the last one before the session started). */ + head?: string; +} + +export interface ReviewStartRecord extends ReviewLedgerBase { + kind: "start"; + cwd: string; + effortSource: CodexReviewConfig["effortSource"] | "imported"; + allowShallow: boolean; + parent?: string; +} + +export interface ReviewTurnRecord extends ReviewLedgerBase { + kind: "turn"; + turn: number; + outcome: "completed" | "failed" | "interrupted"; + model: string | null; + durationMs: number | null; + toolCalls: number; + reasoningTokens: number | null; + outputTokens: number | null; + costUsd: number | null; + /** The `Files reviewed: n of N` line of the turn's final message; null when it had none. */ + coverage: { reviewed: number; total: number } | null; +} + +export interface ReviewGateRecord extends ReviewLedgerBase { + kind: "gate"; + step: number; + verdict: ReviewVerdict; + reason: string; + coverage: { reviewed: number | null; total: number }; + reasoningPer100Lines: number | null; + floor: number; + findings: number; + malformedFindings: number; + /** The unread files the gate named, first 40. */ + unread: string[]; + /** + * Each check's own result and sentence, as the gate decided it. Absent on a gate line written before this field, + * on an imported one and on a `markReviewShallow` one; `review-depth` then rebuilds an approximation. + */ + checks?: { coverage: GateCheck; reasoning: GateCheck; shape: GateCheck }; + escalatedTo?: string; + spent: CodexReviewSpent; +} + +export type ReviewLedgerRecord = ReviewStartRecord | ReviewTurnRecord | ReviewGateRecord; + +type Distribute<T> = T extends ReviewLedgerRecord ? Omit<T, keyof ReviewLedgerBase> : never; +export type ReviewLedgerEntry = Distribute<ReviewLedgerRecord>; + +/** The fields every record of one review session repeats, so a single line answers the reviews filters. */ +export function reviewLedgerBase(input: { name: string; review: CodexReviewConfig; label?: string; at?: Date }) { + const { review } = input; + return { + v: 1, + ts: (input.at ?? new Date()).toISOString(), + name: input.name, + chain: review.chain, + ...(input.label ? { label: input.label } : {}), + ...(review.pr === undefined ? {} : { pr: review.pr }), + ...(review.branch ? { branch: review.branch } : {}), + base: review.base, + files: review.manifest.files.length, + lines: manifestChangedLines(review.manifest), + effort: review.effort, + } satisfies ReviewLedgerBase; +} + +/** + * Appends one record. Never throws: the ledger is a record of the review, and a failed write must not fail the + * review turn it describes. The failure is logged with the record's kind and session. + */ +export function appendReviewRecord( + base: ReviewLedgerBase, + entry: ReviewLedgerEntry, + path = reviewLedgerPath() +): ReviewLedgerRecord | null { + const record: ReviewLedgerRecord = { ...base, ...entry }; + + try { + new JsonlWriter(path).append({ ...record }); + log.debug({ path, kind: record.kind, name: record.name }, "review ledger record written"); + return record; + } catch (err) { + log.warn({ err, path, kind: record.kind, name: record.name }, "review ledger write failed"); + return null; + } +} + +/** + * Appends several records in one write, so they land together or not at all (a torn last line is skipped by the + * reader). Unlike {@link appendReviewRecord} it reports the failure: `reviews import` must not call a review + * imported when its lines never reached the ledger. + */ +export function appendReviewRecords( + records: readonly ReviewLedgerRecord[], + path = reviewLedgerPath() +): { ok: true } | { ok: false; error: string } { + try { + mkdirSync(dirname(path), { recursive: true }); + // A torn last line (a full disk or a killed writer) must stay its own line: appended onto, it would swallow + // this batch's first record, and a redone import's turns would fold into the old start's row. + const lead = endsMidLine(path) ? "\n" : ""; + appendFileSync( + path, + lead + records.map((record) => `${SafeJSON.stringify(record, { jsonl: true })}\n`).join("") + ); + log.debug({ path, records: records.length }, "review ledger records written"); + return { ok: true }; + } catch (err) { + log.warn({ err, path, records: records.length }, "review ledger write failed"); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** True when the file exists, is not empty and its last byte is not a newline. */ +function endsMidLine(path: string): boolean { + if (!existsSync(path)) { + return false; + } + + const fd = openSync(path, "r"); + try { + const size = fstatSync(fd).size; + if (size === 0) { + return false; + } + + const last = Buffer.alloc(1); + readSync(fd, last, 0, 1, size - 1); + return last[0] !== 0x0a; + } finally { + closeSync(fd); + } +} + +function isLedgerRecord(value: unknown): value is ReviewLedgerRecord { + return ( + isObject(value) && + value.v === 1 && + (value.kind === "start" || value.kind === "turn" || value.kind === "gate") && + typeof value.name === "string" && + typeof value.ts === "string" && + (typeof value.base === "string" || value.base === null) + ); +} + +/** Every well-formed record, oldest first; torn or foreign lines are counted in `skipped`. */ +export function readReviewRecords(path = reviewLedgerPath()): { records: ReviewLedgerRecord[]; skipped: number } { + const { rows, skipped } = readJsonlRows<unknown>(path); + const records = rows.filter(isLedgerRecord); + return { records, skipped: skipped + rows.length - records.length }; +} diff --git a/src/codex/lib/review/prepare.ts b/src/codex/lib/review/prepare.ts new file mode 100644 index 0000000000..f161f9eb43 --- /dev/null +++ b/src/codex/lib/review/prepare.ts @@ -0,0 +1,324 @@ +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { createGit, getCurrentBranch, type NumstatEntry, originDriver, type PrInfo } from "@genesiscz/utils/git"; +import { logger } from "@genesiscz/utils/logger"; +import { + isReviewEffort, + manifestChangedLines, + REVIEW_EFFORTS, + type ReviewEffort, + type ReviewManifest, +} from "@genesiscz/utils/worker/contract"; +import type { CodexReviewConfig, ReviewBaseSource } from "./types"; + +const log = logger.child({ component: "codex:review" }); + +/** A diff up to this size gets `high` by default; anything larger gets `xhigh`. */ +export const HIGH_TIER_MAX = { files: 15, lines: 800 } as const; +/** Above either bound one reviewer is too thin, and the spawn proposes a split by folder. */ +export const SPLIT_THRESHOLD = { files: 60, lines: 4000 } as const; + +/** Everything the review preparation reads from git and the hosting service; injected so tests never spawn. */ +export interface ReviewGitReaders { + currentBranch(cwd: string): Promise<string | null>; + /** `origin/<name>` when it exists, else the local branch, else null. */ + resolveBranch(cwd: string, name: string): Promise<string | null>; + refExists(cwd: string, ref: string): Promise<boolean>; + /** The newest PR of the branch; null when there is none or the host cannot answer. */ + pullRequest(cwd: string, branch: string): Promise<PrInfo | null>; + upstream(cwd: string, branch: string): Promise<string | null>; + /** `git diff --numstat <base>...<head>` (head defaults to HEAD), limited to `paths` when given. */ + numstat(cwd: string, base: string, paths?: string[], head?: string): Promise<NumstatEntry[]>; + /** The newest commit of HEAD's history committed at or before `at` (ISO time); null when there is none. */ + commitBefore(cwd: string, at: string): Promise<string | null>; +} + +export function defaultReviewGitReaders(): ReviewGitReaders { + return { + currentBranch: (cwd) => getCurrentBranch(cwd), + async resolveBranch(cwd, name) { + const git = createGit({ cwd }); + const short = name.replace(/^origin\//, ""); + + if (await git.refExists(`refs/remotes/origin/${short}`)) { + return `origin/${short}`; + } + + return (await git.refExists(`refs/heads/${short}`)) ? short : null; + }, + refExists: (cwd, ref) => createGit({ cwd }).refExists(`${ref}^{commit}`), + async pullRequest(cwd, branch) { + const driver = await originDriver(cwd); + + if (!driver) { + return null; + } + + const lookup = await driver.prForHead(branch); + + if (lookup.error) { + log.debug({ branch, error: lookup.error }, "review: PR lookup failed"); + } + + return lookup.pr; + }, + upstream: (cwd, branch) => createGit({ cwd }).getTrackingBranch(branch), + async numstat(cwd, base, paths, head = "HEAD") { + const git = createGit({ cwd }); + const mergeBase = await git.mergeBase(base, head); + return git.numstat({ from: mergeBase, to: head, ...(paths?.length ? { paths } : {}) }); + }, + async commitBefore(cwd, at) { + const result = await createGit({ cwd }).executor.exec([ + "log", + "-1", + "--format=%H", + `--before=${at}`, + "HEAD", + ]); + return result.success && result.stdout ? result.stdout : null; + }, + }; +} + +export interface PrepareReviewInput { + cwd: string; + base?: string; + effort?: string; + allowShallow?: boolean; + /** Limit the manifest to these paths: one slice of a split review. */ + paths?: string[]; + readers?: ReviewGitReaders; +} + +export interface PreparedReview { + /** The session's review record, without the chain fields the spawn adds. */ + config: Omit<CodexReviewConfig, "chain" | "step">; + /** Lines the spawn prints as information: which base and why, which tier and why. */ + notes: string[]; + /** Lines the spawn prints as warnings: the split suggestion. */ + warnings: string[]; +} + +export interface ChosenBase { + ref: string; + source: ReviewBaseSource; + detail: string; + pr?: PrInfo; +} + +/** The base ladder of `--mode review`: the flag (verified), else the branch's PR target, else its upstream. */ +export async function chooseBase( + input: Pick<PrepareReviewInput, "cwd" | "base"> & { readers: ReviewGitReaders; branch: string | null } +): Promise<ChosenBase> { + const { readers, cwd, branch } = input; + + if (input.base) { + if (!(await readers.refExists(cwd, input.base))) { + throw new Error( + `--base ${input.base} does not resolve in ${cwd}; run git fetch first or pick another ref.` + ); + } + + const pr = branch ? await readers.pullRequest(cwd, branch) : null; + return { ref: input.base, source: "flag", detail: "--base", ...(pr ? { pr } : {}) } satisfies ChosenBase; + } + + if (!branch) { + throw new Error("--mode review needs --base <ref>: HEAD is detached, so there is no PR or upstream to read."); + } + + const pr = await readers.pullRequest(cwd, branch); + + if (pr) { + const ref = await readers.resolveBranch(cwd, pr.target); + + if (ref) { + return { + ref, + source: "pr", + detail: `${pr.state} PR #${pr.number} targets ${pr.target}`, + pr, + } satisfies ChosenBase; + } + + log.debug({ branch, pr }, "review: PR target does not resolve locally"); + } + + const upstream = await readers.upstream(cwd, branch); + + // A branch tracking its own remote copy has no base there: the diff against it is the unpushed commits only. + if (upstream && upstream !== `origin/${branch}` && upstream !== branch) { + return { ref: upstream, source: "upstream", detail: `upstream of ${branch}` } satisfies ChosenBase; + } + + throw new Error( + `--mode review needs --base <ref>: ${branch} has no PR whose target resolves and no upstream other than itself. \`${toolCommand("git base")}\` names a likely base.` + ); +} + +export function buildManifest(base: string, entries: readonly NumstatEntry[]): ReviewManifest { + const files = entries.map((entry) => ({ + path: entry.path, + added: entry.insertions, + deleted: entry.deletions, + ...(entry.binary ? { binary: true } : {}), + })); + + return { + base, + files, + added: files.reduce((sum, file) => sum + file.added, 0), + deleted: files.reduce((sum, file) => sum + file.deleted, 0), + }; +} + +/** `high` for a diff of up to 15 files and 800 changed lines, `xhigh` above that. */ +export function defaultReviewEffort(manifest: ReviewManifest): ReviewEffort { + const small = manifest.files.length <= HIGH_TIER_MAX.files && manifestChangedLines(manifest) <= HIGH_TIER_MAX.lines; + return small ? "high" : "xhigh"; +} + +function chooseEffort( + flag: string | undefined, + allowShallow: boolean, + manifest: ReviewManifest +): { effort: ReviewEffort; source: "flag" | "diff-size" } { + if (flag === undefined) { + return { effort: defaultReviewEffort(manifest), source: "diff-size" }; + } + + if (!isReviewEffort(flag)) { + throw new Error(`--effort for a review must be one of ${REVIEW_EFFORTS.join(", ")}; got ${flag}.`); + } + + if ((flag === "low" || flag === "medium") && !allowShallow) { + throw new Error( + `--effort ${flag} is refused for a review: medium reviews spent 358 to 2,399 reasoning tokens whatever the diff size and missed defects the xhigh pass found. Omit --effort to pick high or xhigh from the diff size, or pass --allow-shallow to accept a shallow review.` + ); + } + + return { effort: flag, source: "flag" }; +} + +export interface ManifestSlice { + folder: string; + files: number; + added: number; + deleted: number; +} + +function folderOf(path: string, depth: number): string { + const parts = path.split("/"); + return parts.length > depth ? parts.slice(0, depth).join("/") : parts.slice(0, -1).join("/") || "."; +} + +/** + * One slice per folder, split deeper until each slice is at or under the file threshold or cannot split + * further. A repo with everything under `src/` would otherwise get one useless `src` slice. + */ +export function splitManifest(manifest: ReviewManifest, depth = 1): ManifestSlice[] { + const groups = new Map<string, ReviewManifest["files"]>(); + + for (const file of manifest.files) { + const folder = folderOf(file.path, depth); + groups.set(folder, [...(groups.get(folder) ?? []), file]); + } + + return [...groups.entries()] + .flatMap(([folder, files]) => { + const deeper = files.some((file) => file.path.split("/").length > depth + 1); + + if (files.length > SPLIT_THRESHOLD.files && deeper) { + const sub = splitManifest({ ...manifest, files }, depth + 1); + + if (sub.length > 1) { + return sub; + } + } + + return [ + { + folder, + files: files.length, + added: files.reduce((sum, file) => sum + file.added, 0), + deleted: files.reduce((sum, file) => sum + file.deleted, 0), + }, + ]; + }) + .sort((a, b) => b.files - a.files || a.folder.localeCompare(b.folder)); +} + +export function splitSuggestion(manifest: ReviewManifest): string | null { + const lines = manifestChangedLines(manifest); + + if (manifest.files.length <= SPLIT_THRESHOLD.files && lines <= SPLIT_THRESHOLD.lines) { + return null; + } + + // A diff under one folder (`src/`) only splits a level or two down. + let depth = 1; + let slices = splitManifest(manifest, depth); + + while (slices.length < 2 && depth < 4) { + depth += 1; + slices = splitManifest(manifest, depth); + } + + if (slices.length < 2) { + return null; + } + + const listed = slices + .slice(0, 12) + .map((slice) => `${slice.folder} (${slice.files} files, +${slice.added} -${slice.deleted})`) + .join(", "); + const more = slices.length > 12 ? `, +${slices.length - 12} more` : ""; + + return `${manifest.files.length} files, ${lines} changed lines: above about ${SPLIT_THRESHOLD.files} files or ${SPLIT_THRESHOLD.lines} lines one reviewer covers the diff thinly. Split suggestion, one reviewer per folder at xhigh, in parallel: ${listed}${more}. Each one: ${toolCommand("codex spawn")} --mode review --base ${manifest.base} --paths <folder> --name <name>-<folder>.`; +} + +/** + * Everything `spawn --mode review` decides before a worker starts: the base (the flag, else the branch's PR + * target, else its upstream, and the notes say which), the manifest from `git diff --numstat <base>...HEAD`, + * the tier (the flag, else the diff size) and the split suggestion for a large diff. + */ +export async function prepareReview(input: PrepareReviewInput): Promise<PreparedReview> { + const readers = input.readers ?? defaultReviewGitReaders(); + const allowShallow = input.allowShallow === true; + const branch = await readers.currentBranch(input.cwd); + const base = await chooseBase({ ...input, readers, branch }); + const manifest = buildManifest(base.ref, await readers.numstat(input.cwd, base.ref, input.paths)); + + if (manifest.files.length === 0) { + throw new Error( + `Nothing to review: git diff --numstat ${base.ref}...HEAD is empty in ${input.cwd}${input.paths?.length ? ` for ${input.paths.join(", ")}` : ""}.` + ); + } + + const { effort, source } = chooseEffort(input.effort, allowShallow, manifest); + const warnings = [splitSuggestion(manifest)].filter((line): line is string => line !== null); + const notes = [ + `review base ${base.ref} (${base.source}: ${base.detail})`, + `review manifest ${manifest.files.length} files, +${manifest.added} -${manifest.deleted}; effort ${effort} (${source === "flag" ? "--effort" : "from the diff size"})`, + ]; + log.debug( + { cwd: input.cwd, base: base.ref, source: base.source, files: manifest.files.length, effort }, + "review prepared" + ); + + return { + config: { + base: base.ref, + baseSource: base.source, + baseDetail: base.detail, + ...(branch ? { branch } : {}), + ...(base.pr ? { pr: base.pr.number } : {}), + manifest, + effort, + effortSource: source, + allowShallow, + }, + notes, + warnings, + }; +} diff --git a/src/codex/lib/review/report.ts b/src/codex/lib/review/report.ts new file mode 100644 index 0000000000..a9f13205df --- /dev/null +++ b/src/codex/lib/review/report.ts @@ -0,0 +1,266 @@ +import { parseSince } from "@genesiscz/utils/date"; +import { parseDuration } from "@genesiscz/utils/format"; +import type { ReviewGateRecord, ReviewLedgerRecord } from "./ledger"; +import type { ReviewVerdict } from "./types"; + +/** One review session, folded from its ledger lines: what `tools codex reviews` prints as a row. */ +export interface ReviewSummary { + name: string; + chain: string; + label?: string; + pr?: number; + branch?: string; + base: string | null; + files: number | null; + lines: number | null; + effort: string; + imported?: true; + startedAt: string; + lastAt: string; + turns: number; + durationMs: number; + toolCalls: number; + reasoningTokens: number; + outputTokens: number; + /** API-equivalent dollars; null when any turn was unpriced. */ + costUsd: number | null; + reasoningPer100Lines: number | null; + /** The last `Files reviewed` line: `12/14`. */ + coverage: string | null; + /** The last gate verdict; `open` while no gate line exists yet (running, or a turn that failed). */ + verdict: ReviewVerdict | "open"; + reason?: string; + escalatedTo?: string; + parent?: string; +} + +export interface ReviewFilters { + since?: Date; + pr?: number; + label?: string; + effort?: string; + verdict?: string; + name?: string; +} + +export interface ReviewTotals { + reviews: number; + turns: number; + durationMs: number; + toolCalls: number; + reasoningTokens: number; + costUsd: number; + unpricedReviews: number; + verdicts: Record<string, number>; +} + +function emptySummary(record: ReviewLedgerRecord): ReviewSummary { + return { + name: record.name, + chain: record.chain, + ...(record.label ? { label: record.label } : {}), + ...(record.pr === undefined ? {} : { pr: record.pr }), + ...(record.branch ? { branch: record.branch } : {}), + base: record.base, + files: record.files, + lines: record.lines, + effort: record.effort, + ...(record.imported ? { imported: true as const } : {}), + startedAt: record.ts, + lastAt: record.ts, + turns: 0, + durationMs: 0, + toolCalls: 0, + reasoningTokens: 0, + outputTokens: 0, + costUsd: 0, + reasoningPer100Lines: null, + coverage: null, + verdict: "open", + }; +} + +/** + * Folds the ledger into one summary per review session. A `start` line opens a new review, so a name that was + * reused later shows twice; turn and gate lines without a start (a ledger cut by hand) open one too. An imported + * review that never got its gate line is a partial import: the next import of that name replaces its row. + */ +export function summarizeReviews(records: readonly ReviewLedgerRecord[]): ReviewSummary[] { + const open = new Map<string, ReviewSummary>(); + const all: ReviewSummary[] = []; + + for (const record of records) { + let summary = open.get(record.name); + + if (!summary || record.kind === "start") { + if ( + summary && + record.kind === "start" && + record.imported && + summary.imported && + summary.verdict === "open" + ) { + all.splice(all.indexOf(summary), 1); + } + + summary = emptySummary(record); + open.set(record.name, summary); + all.push(summary); + } + + summary.lastAt = record.ts; + + if (record.kind === "start") { + if (record.parent) { + summary.parent = record.parent; + } + + continue; + } + + if (record.kind === "turn") { + summary.turns += 1; + summary.durationMs += record.durationMs ?? 0; + summary.toolCalls += record.toolCalls; + summary.reasoningTokens += record.reasoningTokens ?? 0; + summary.outputTokens += record.outputTokens ?? 0; + summary.costUsd = + summary.costUsd === null || record.costUsd === null ? null : summary.costUsd + record.costUsd; + summary.effort = record.effort; + + if (record.coverage) { + summary.coverage = `${record.coverage.reviewed}/${record.coverage.total}`; + } + + summary.reasoningPer100Lines = + summary.lines === null + ? null + : Math.round((summary.reasoningTokens * 100) / Math.max(1, summary.lines)); + continue; + } + + summary.verdict = record.verdict; + summary.reason = record.reason; + + if (record.escalatedTo) { + summary.escalatedTo = record.escalatedTo; + } else { + delete summary.escalatedTo; + } + } + + return all; +} + +/** + * The ledger lines of one review session's current run. A reused name (and a fresh spawn, whose chain is its name) + * matches every earlier run too, so only the lines from its last `start` on count; with no start line, all of them. + */ +export function currentReviewRecords( + records: readonly ReviewLedgerRecord[], + name: string, + chain: string +): ReviewLedgerRecord[] { + const mine = records.filter((record) => record.name === name && record.chain === chain); + const start = mine.findLastIndex((record) => record.kind === "start"); + return start < 0 ? mine : mine.slice(start); +} + +export interface GateCheckRow { + label: "coverage" | "reasoning" | "shape"; + ok: boolean; + detail: string; +} + +/** + * The three checks of one gate line as `review-depth` prints them: the result and sentence the gate recorded, or + * for an older line without them an approximation from its counts. + */ +export function gateCheckRows(gate: ReviewGateRecord): GateCheckRow[] { + const unread = + gate.unread.length > 0 + ? `; unread: ${gate.unread.slice(0, 8).join(", ")}${gate.unread.length > 8 ? ", …" : ""}` + : ""; + + if (gate.checks) { + return [ + { label: "coverage", ...gate.checks.coverage, detail: `${gate.checks.coverage.detail}${unread}` }, + { label: "reasoning", ...gate.checks.reasoning }, + { label: "shape", ...gate.checks.shape }, + ]; + } + + return [ + { + label: "coverage", + ok: gate.coverage.reviewed !== null && gate.coverage.reviewed >= gate.coverage.total, + detail: `${gate.coverage.reviewed ?? "no line"} of ${gate.coverage.total} files${unread}`, + }, + { + label: "reasoning", + ok: gate.reasoningPer100Lines === null || gate.reasoningPer100Lines >= gate.floor, + detail: `${gate.reasoningPer100Lines ?? "unknown"} tokens per 100 changed lines (floor ${gate.floor})`, + }, + { + label: "shape", + ok: gate.malformedFindings === 0, + detail: `${gate.findings} findings, ${gate.malformedFindings} malformed`, + }, + ]; +} + +/** `24h`, `90m`, `7d` back from now; otherwise the `--since` grammar of the other reports (`HH:MM`, a date). */ +export function parseReviewSince(value: string, now = new Date()): Date | null { + const text = value.trim(); + + if (/^\d+\s*[a-z]/i.test(text) && !/^\d{4}-/.test(text)) { + const ms = parseDuration(text); + return ms > 0 ? new Date(now.getTime() - ms) : null; + } + + return parseSince(text, now); +} + +export function filterReviews(summaries: readonly ReviewSummary[], filters: ReviewFilters): ReviewSummary[] { + const label = filters.label?.toLowerCase(); + const name = filters.name?.toLowerCase(); + + return summaries.filter( + (summary) => + (!filters.since || Date.parse(summary.lastAt) >= filters.since.getTime()) && + (filters.pr === undefined || summary.pr === filters.pr) && + (!label || (summary.label ?? "").toLowerCase().includes(label)) && + (!filters.effort || summary.effort === filters.effort) && + (!filters.verdict || summary.verdict === filters.verdict) && + (!name || summary.name.toLowerCase().includes(name) || summary.chain.toLowerCase().includes(name)) + ); +} + +export function reviewTotals(summaries: readonly ReviewSummary[]): ReviewTotals { + const totals: ReviewTotals = { + reviews: summaries.length, + turns: 0, + durationMs: 0, + toolCalls: 0, + reasoningTokens: 0, + costUsd: 0, + unpricedReviews: 0, + verdicts: {}, + }; + + for (const summary of summaries) { + totals.turns += summary.turns; + totals.durationMs += summary.durationMs; + totals.toolCalls += summary.toolCalls; + totals.reasoningTokens += summary.reasoningTokens; + totals.verdicts[summary.verdict] = (totals.verdicts[summary.verdict] ?? 0) + 1; + + if (summary.costUsd === null) { + totals.unpricedReviews += 1; + } else { + totals.costUsd += summary.costUsd; + } + } + + return totals; +} diff --git a/src/codex/lib/review/review.test.ts b/src/codex/lib/review/review.test.ts new file mode 100644 index 0000000000..3d078616bb --- /dev/null +++ b/src/codex/lib/review/review.test.ts @@ -0,0 +1,987 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { appendFileSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { WorkerCostRow } from "@app/ai-spend/lib/workers/ledger"; +import type { CodexFinishedTurn } from "@genesiscz/utils/codex/turn-cost"; +import { env } from "@genesiscz/utils/env"; +import type { NumstatEntry, PrInfo } from "@genesiscz/utils/git"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { out } from "@genesiscz/utils/logger"; +import type { ReviewManifest } from "@genesiscz/utils/worker/contract"; +import { runImport } from "../../commands/reviews"; +import { codexDriver } from "../driver"; +import { spawnCodexSession } from "../spawn"; +import type { CodexSessionMeta } from "../store"; +import { escalatedReviewName, markReviewShallow, recordReviewTurn } from "./controller"; +import { checkReview, decideGate, parseReviewReport } from "./gate"; +import { importReviewSessions, type ReviewImportReaders } from "./import"; +import { type ReviewGateRecord, type ReviewLedgerRecord, readReviewRecords } from "./ledger"; +import { prepareReview, type ReviewGitReaders } from "./prepare"; +import { + currentReviewRecords, + filterReviews, + gateCheckRows, + parseReviewSince, + reviewTotals, + summarizeReviews, +} from "./report"; +import type { CodexReviewConfig } from "./types"; + +function entry(path: string, insertions: number, deletions = 0, binary = false): NumstatEntry { + return { path, insertions, deletions, binary }; +} + +function readers(options: { + branch?: string | null; + pr?: PrInfo | null; + upstream?: string | null; + numstat?: NumstatEntry[]; + refs?: string[]; + commitBefore?: (at: string) => string | null; +}): ReviewGitReaders & { calls: string[] } { + const calls: string[] = []; + return { + calls, + currentBranch: async () => (options.branch === undefined ? "feat/x" : options.branch), + resolveBranch: async (_cwd, name) => + (options.refs ?? ["origin/master"]).includes(`origin/${name}`) ? `origin/${name}` : null, + refExists: async (_cwd, ref) => (options.refs ?? ["origin/master"]).includes(ref), + pullRequest: async () => options.pr ?? null, + upstream: async () => options.upstream ?? null, + numstat: async (_cwd, base, paths, head) => { + calls.push(`numstat ${base}${head ? `...${head}` : ""}${paths ? ` ${paths.join(",")}` : ""}`); + return options.numstat ?? [entry("src/a.ts", 40, 2)]; + }, + commitBefore: async (_cwd, at) => (options.commitBefore ? options.commitBefore(at) : null), + }; +} + +const PR: PrInfo = { number: 12, state: "OPEN", target: "master", url: "https://example.com/pr/12" }; + +describe("prepareReview", () => { + test("--base is verified and used; the PR number is still recorded", async () => { + const fake = readers({ pr: PR }); + const prepared = await prepareReview({ cwd: "/repo", base: "origin/master", readers: fake }); + + expect(prepared.config).toMatchObject({ base: "origin/master", baseSource: "flag", pr: 12, branch: "feat/x" }); + expect(prepared.notes[0]).toBe("review base origin/master (flag: --base)"); + expect(fake.calls).toEqual(["numstat origin/master"]); + await expect(prepareReview({ cwd: "/repo", base: "origin/nope", readers: fake })).rejects.toThrow( + "--base origin/nope does not resolve" + ); + }); + + test("without --base: the PR target first, then an upstream that is not the branch itself", async () => { + const fromPr = await prepareReview({ cwd: "/repo", readers: readers({ pr: PR }) }); + expect(fromPr.config).toMatchObject({ base: "origin/master", baseSource: "pr", pr: 12 }); + expect(fromPr.notes[0]).toBe("review base origin/master (pr: OPEN PR #12 targets master)"); + + const fromUpstream = await prepareReview({ cwd: "/repo", readers: readers({ upstream: "origin/develop" }) }); + expect(fromUpstream.config).toMatchObject({ base: "origin/develop", baseSource: "upstream" }); + + await expect(prepareReview({ cwd: "/repo", readers: readers({ upstream: "origin/feat/x" }) })).rejects.toThrow( + "--mode review needs --base <ref>: feat/x has no PR" + ); + await expect(prepareReview({ cwd: "/repo", readers: readers({ branch: null }) })).rejects.toThrow( + "HEAD is detached" + ); + }); + + test("an empty diff is refused; --paths reaches the numstat", async () => { + await expect( + prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({ numstat: [] }) }) + ).rejects.toThrow("Nothing to review"); + + const fake = readers({}); + await prepareReview({ cwd: "/repo", base: "origin/master", paths: ["src/codex"], readers: fake }); + expect(fake.calls).toEqual(["numstat origin/master src/codex"]); + }); + + test("the tier comes from the diff size; low and medium need --allow-shallow", async () => { + const small = await prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({}) }); + expect(small.config).toMatchObject({ effort: "high", effortSource: "diff-size" }); + + const many = Array.from({ length: 16 }, (_, i) => entry(`src/f${i}.ts`, 10)); + const large = await prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({ numstat: many }) }); + expect(large.config.effort).toBe("xhigh"); + + const long = await prepareReview({ + cwd: "/repo", + base: "origin/master", + readers: readers({ numstat: [entry("src/a.ts", 801)] }), + }); + expect(long.config.effort).toBe("xhigh"); + + const base = { cwd: "/repo", base: "origin/master", readers: readers({}) }; + await expect(prepareReview({ ...base, effort: "medium" })).rejects.toThrow("--effort medium is refused"); + await expect(prepareReview({ ...base, effort: "low" })).rejects.toThrow("--allow-shallow"); + await expect(prepareReview({ ...base, effort: "huge" })).rejects.toThrow("must be one of low, medium"); + expect((await prepareReview({ ...base, effort: "medium", allowShallow: true })).config).toMatchObject({ + effort: "medium", + effortSource: "flag", + allowShallow: true, + }); + expect((await prepareReview({ ...base, effort: "max" })).config.effort).toBe("max"); + }); + + test("above 60 files the spawn proposes one reviewer per folder, split below a shared src/", async () => { + const files = [ + ...Array.from({ length: 40 }, (_, i) => entry(`src/codex/f${i}.ts`, 5)), + ...Array.from({ length: 30 }, (_, i) => entry(`src/utils/g${i}.ts`, 5)), + ]; + const prepared = await prepareReview({ + cwd: "/repo", + base: "origin/master", + readers: readers({ numstat: files }), + }); + + expect(prepared.warnings).toHaveLength(1); + expect(prepared.warnings[0]).toContain("src/codex (40 files, +200 -0), src/utils (30 files, +150 -0)"); + expect(prepared.warnings[0]).toContain("--paths <folder>"); + + const smallDiff = await prepareReview({ cwd: "/repo", base: "origin/master", readers: readers({}) }); + expect(smallDiff.warnings).toEqual([]); + }); +}); + +const MANIFEST: ReviewManifest = { + base: "origin/master", + files: [ + { path: "src/a.ts", added: 60, deleted: 10 }, + { path: "src/b.ts", added: 20, deleted: 0 }, + { path: "src/c.ts", added: 10, deleted: 0 }, + { path: "assets/logo.png", added: 0, deleted: 0, binary: true }, + ], + added: 90, + deleted: 10, +}; + +const GOOD_REPORT = [ + "1. **[High]** src/a.ts:12 - the lock is never released. Scenario: a throw inside the callback. Fix: release in finally.", + "2. [Low] src/b.ts:3 - stale comment.", + " Scenario: a reader trusts it. Fix: delete it.", + "Ran: bun test src/a.test.ts", + "Files reviewed: 3 of 4", + "Not reviewed: assets/logo.png (binary image)", + "RESULT: done", +].join("\n"); + +describe("the depth gate", () => { + test("parses the review shape, markdown and wrapped findings included", () => { + const parsed = parseReviewReport(GOOD_REPORT); + + expect(parsed.coverage).toEqual({ reviewed: 3, total: 4 }); + expect(parsed.notReviewed).toEqual([{ path: "assets/logo.png", reason: "binary image" }]); + expect(parsed.findings.map((finding) => [finding.index, finding.ok])).toEqual([ + [1, true], + [2, true], + ]); + expect(parsed.ran).toBe(true); + }); + + test("passes a full review: a named binary file with a reason is excused", () => { + const checks = checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "high", reasoningTokens: 400 }); + + expect(checks.coverage).toMatchObject({ ok: true, reviewed: 3, total: 4, unread: [] }); + expect(checks.reasoning).toMatchObject({ ok: true, per100Lines: 400, floor: 100 }); + expect(checks.shape.ok).toBe(true); + expect(decideGate(checks, { effort: "high", step: 0, allowShallow: false }).verdict).toBe("pass"); + }); + + test("names the unread files: the Not reviewed line, else the manifest files the report never mentions", () => { + const named = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 2 of 4").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: src/c.ts (ran out of time), assets/logo.png (binary image)" + ), + manifest: MANIFEST, + effort: "high", + reasoningTokens: 400, + }); + expect(named.coverage).toMatchObject({ ok: false, unread: ["src/c.ts"] }); + + const silent = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 2 of 4").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: none" + ), + manifest: MANIFEST, + effort: "high", + reasoningTokens: 400, + }); + expect(silent.coverage.unread).toEqual(["src/c.ts", "assets/logo.png"]); + + const missing = checkReview({ report: "1. nothing", manifest: MANIFEST, effort: "high", reasoningTokens: 400 }); + expect(missing.coverage).toMatchObject({ + ok: false, + reviewed: null, + detail: "no `Files reviewed: n of N` line", + }); + expect(missing.shape).toMatchObject({ ok: false, malformed: [1] }); + }); + + test("holds each tier to its reasoning floor per 100 changed lines", () => { + const thin = checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "xhigh", reasoningTokens: 200 }); + expect(thin.reasoning).toMatchObject({ ok: false, per100Lines: 200, floor: 250 }); + expect( + checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "high", reasoningTokens: 99 }).reasoning.ok + ).toBe(false); + expect( + checkReview({ report: GOOD_REPORT, manifest: MANIFEST, effort: "high", reasoningTokens: null }).reasoning + ).toMatchObject({ ok: true, detail: "reasoning tokens unknown" }); + }); + + test("steers once, escalates once, then ends shallow; --allow-shallow tiers are never tuned up", () => { + const miss = checkReview({ + report: "Ran: nothing\nFindings: none", + manifest: MANIFEST, + effort: "high", + reasoningTokens: 10, + }); + + const first = decideGate(miss, { effort: "high", step: 0, allowShallow: false }); + expect(first.verdict).toBe("steered"); + expect(first.steer).toContain("You did not read: src/a.ts, src/b.ts, src/c.ts, assets/logo.png."); + expect(first.steer).toContain("Depth: 10 reasoning tokens per 100 changed lines, high floor 100."); + expect(first.steer).toContain("`Files reviewed: <n> of 4`"); + + expect(decideGate(miss, { effort: "high", step: 1, allowShallow: false })).toMatchObject({ + verdict: "escalated", + nextEffort: "xhigh", + }); + expect(decideGate(miss, { effort: "max", step: 1, allowShallow: false }).reason).toContain("no tier above max"); + expect(decideGate(miss, { effort: "xhigh", step: 2, allowShallow: false })).toMatchObject({ + verdict: "shallow", + }); + expect(decideGate(miss, { effort: "medium", step: 0, allowShallow: true }).verdict).toBe("shallow"); + expect(decideGate(miss, { effort: "low", step: 0, allowShallow: true }).verdict).toBe("shallow"); + }); + + test("a text file on the Not reviewed line fails coverage even when the count is full; a repeated binary counts once", () => { + const admitted = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 4 of 4").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: src/c.ts (ran out of time)" + ), + manifest: MANIFEST, + effort: "high", + reasoningTokens: 400, + }); + expect(admitted.coverage).toMatchObject({ ok: false, unread: ["src/c.ts"] }); + expect(admitted.coverage.detail).toContain("Not reviewed names 1 text file(s)"); + + const twoBinaries: ReviewManifest = { + ...MANIFEST, + files: [...MANIFEST.files, { path: "assets/icon.png", added: 0, deleted: 0, binary: true }], + }; + const repeated = checkReview({ + report: GOOD_REPORT.replace("Files reviewed: 3 of 4", "Files reviewed: 3 of 5").replace( + "Not reviewed: assets/logo.png (binary image)", + "Not reviewed: assets/logo.png (binary image), assets/logo.png (binary image)" + ), + manifest: twoBinaries, + effort: "high", + reasoningTokens: 400, + }); + expect(repeated.coverage.ok).toBe(false); + }); + + test("markdown is stripped around words, never inside a path: underscores and spaces in paths survive", () => { + const manifest: ReviewManifest = { + base: "origin/master", + files: [ + { path: "src/my_mod.ts", added: 60, deleted: 10 }, + { path: "src/my file.ts", added: 30, deleted: 0 }, + { path: "assets/app_icon.png", added: 0, deleted: 0, binary: true }, + ], + added: 90, + deleted: 10, + }; + const report = [ + "1. **[High]** src/my_mod.ts:12 - the lock leaks. Scenario: a throw. Fix: release in finally.", + "2. [Medium] src/my file.ts:7 - stale read. Scenario: a retry. Fix: reload.", + "__Ran:__ `bun test src/my_mod.test.ts`", + "**Files reviewed:** 2 of 3", + "_Not reviewed:_ `assets/app_icon.png` (binary image)", + ].join("\n"); + const parsed = parseReviewReport(report); + + expect(parsed.notReviewed).toEqual([{ path: "assets/app_icon.png", reason: "binary image" }]); + expect(parsed.findings.map((finding) => [finding.ok, finding.text.split(" - ")[0]])).toEqual([ + [true, "[High] src/my_mod.ts:12"], + [true, "[Medium] src/my file.ts:7"], + ]); + expect(parsed.ran).toBe(true); + + const checks = checkReview({ report, manifest, effort: "high", reasoningTokens: 400 }); + expect(checks.coverage).toMatchObject({ ok: true, unread: [] }); + expect(checks.shape.ok).toBe(true); + }); +}); + +function finishedTurn(over: Partial<CodexFinishedTurn> = {}): CodexFinishedTurn { + return { + turn: 1, + outcome: "completed", + startedAt: new Date("2026-10-22T10:00:00Z"), + endedAt: new Date("2026-10-22T10:05:00Z"), + durationMs: 300_000, + parts: [{ model: "gpt-x", tokens: { input: 900, cacheRead: 0, cacheWrite: 0, output: 600, reasoning: 50 } }], + toolCalls: 12, + model: "gpt-x", + auth: "subscription", + ...over, + }; +} + +function reviewConfig(over: Partial<CodexReviewConfig> = {}): CodexReviewConfig { + return { + base: "origin/master", + baseSource: "pr", + baseDetail: "OPEN PR #12 targets master", + branch: "feat/x", + pr: 12, + manifest: MANIFEST, + effort: "high", + effortSource: "diff-size", + allowShallow: false, + step: 0, + chain: "rv", + ...over, + }; +} + +describe("recordReviewTurn", () => { + test("a miss steers the same session and logs a turn line and a gate line", () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-ledger-")), "reviews.jsonl"); + const outcome = recordReviewTurn({ + name: "rv", + label: "babysit pr12", + review: reviewConfig(), + finished: finishedTurn(), + report: "Ran: nothing\nFindings: none\nFiles reviewed: 1 of 4\nNot reviewed: none", + costUsd: 0.4, + ledgerPath, + }); + + expect(outcome.action.kind).toBe("steer"); + expect(outcome.review).toMatchObject({ verdict: "steered", step: 1 }); + expect(outcome.review.spent).toEqual({ + turns: 1, + durationMs: 300_000, + toolCalls: 12, + reasoningTokens: 50, + outputTokens: 600, + costUsd: 0.4, + }); + + const { records } = readReviewRecords(ledgerPath); + expect(records.map((record) => record.kind)).toEqual(["turn", "gate"]); + expect(records[0]).toMatchObject({ + name: "rv", + label: "babysit pr12", + pr: 12, + base: "origin/master", + files: 4, + lines: 100, + effort: "high", + coverage: { reviewed: 1, total: 4 }, + reasoningTokens: 50, + }); + expect(records[1]).toMatchObject({ verdict: "steered", step: 1, reasoningPer100Lines: 50, floor: 100 }); + expect((records[1] as ReviewGateRecord).checks).toEqual({ + coverage: { ok: false, detail: "1 of 4 files reviewed" }, + reasoning: { ok: false, detail: "50 reasoning tokens per 100 changed lines, high floor 100" }, + shape: { ok: true, detail: "0 findings parse" }, + }); + + const second = recordReviewTurn({ + name: "rv", + review: outcome.review, + finished: finishedTurn({ turn: 2 }), + report: "Ran: nothing\nFindings: none\nFiles reviewed: 2 of 4\nNot reviewed: none", + costUsd: 0.2, + ledgerPath, + }); + expect(second.action).toEqual({ + kind: "escalate", + effort: "xhigh", + name: escalatedReviewName({ chain: "rv" }, "xhigh"), + }); + expect(second.review).toMatchObject({ verdict: "escalated", step: 2, escalatedTo: "rv-xhigh" }); + expect(second.review.spent?.reasoningTokens).toBe(100); + }); + + test("a failed turn and a follow-up after a final verdict are booked without the gate", () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-ledger-")), "reviews.jsonl"); + const failed = recordReviewTurn({ + name: "rv", + review: reviewConfig(), + finished: finishedTurn({ outcome: "failed" }), + report: "", + costUsd: null, + ledgerPath, + }); + expect(failed).toMatchObject({ action: { kind: "none" } }); + expect(failed.decision).toBeUndefined(); + expect(failed.review.spent?.costUsd).toBeNull(); + + const followUp = recordReviewTurn({ + name: "rv", + review: reviewConfig({ verdict: "pass" }), + finished: finishedTurn(), + report: "", + costUsd: 0.1, + ledgerPath, + }); + expect(followUp.decision).toBeUndefined(); + expect(readReviewRecords(ledgerPath).records.map((record) => record.kind)).toEqual(["turn", "turn"]); + + const shallow = markReviewShallow({ + name: "rv", + review: reviewConfig({ verdict: "steered" }), + reason: "steer failed", + ledgerPath, + }); + expect(shallow.verdict).toBe("shallow"); + expect(readReviewRecords(ledgerPath).records.at(-1)).toMatchObject({ + kind: "gate", + verdict: "shallow", + reason: "steer failed", + }); + }); +}); + +function ledgerFixture(): ReviewLedgerRecord[] { + const base = { + v: 1 as const, + chain: "rv", + base: "origin/master", + files: 4, + lines: 100, + pr: 12, + label: "babysit pr12", + }; + const spent = { + turns: 2, + durationMs: 600_000, + toolCalls: 30, + reasoningTokens: 600, + outputTokens: 900, + costUsd: 0.9, + }; + return [ + { + ...base, + kind: "start", + ts: "2026-10-22T10:00:00Z", + name: "rv", + effort: "high", + cwd: "/repo", + effortSource: "diff-size", + allowShallow: false, + }, + { + ...base, + kind: "turn", + ts: "2026-10-22T10:05:00Z", + name: "rv", + effort: "high", + turn: 1, + outcome: "completed", + model: "gpt-x", + durationMs: 300_000, + toolCalls: 10, + reasoningTokens: 50, + outputTokens: 300, + costUsd: 0.4, + coverage: { reviewed: 1, total: 4 }, + }, + { + ...base, + kind: "gate", + ts: "2026-10-22T10:05:01Z", + name: "rv", + effort: "high", + step: 1, + verdict: "escalated", + reason: "coverage: 1 of 4", + coverage: { reviewed: 1, total: 4 }, + reasoningPer100Lines: 50, + floor: 100, + findings: 0, + malformedFindings: 0, + unread: ["src/b.ts"], + escalatedTo: "rv-xhigh", + spent, + }, + { + ...base, + kind: "start", + ts: "2026-10-22T10:06:00Z", + name: "rv-xhigh", + effort: "xhigh", + cwd: "/repo", + effortSource: "flag", + allowShallow: false, + parent: "rv", + }, + { + ...base, + kind: "turn", + ts: "2026-10-22T10:20:00Z", + name: "rv-xhigh", + effort: "xhigh", + turn: 1, + outcome: "completed", + model: "gpt-x", + durationMs: 840_000, + toolCalls: 60, + reasoningTokens: 3000, + outputTokens: 900, + costUsd: 1.5, + coverage: { reviewed: 4, total: 4 }, + }, + { + ...base, + kind: "gate", + ts: "2026-10-22T10:20:01Z", + name: "rv-xhigh", + effort: "xhigh", + step: 2, + verdict: "pass", + reason: "4 of 4 files reviewed", + coverage: { reviewed: 4, total: 4 }, + reasoningPer100Lines: 3000, + floor: 250, + findings: 3, + malformedFindings: 0, + unread: [], + spent, + }, + ]; +} + +describe("the review ledger report", () => { + test("folds one row per review session, with the escalation linked both ways", () => { + const reviews = summarizeReviews(ledgerFixture()); + + expect(reviews.map((review) => [review.name, review.verdict, review.coverage, review.turns])).toEqual([ + ["rv", "escalated", "1/4", 1], + ["rv-xhigh", "pass", "4/4", 1], + ]); + expect(reviews[0]).toMatchObject({ escalatedTo: "rv-xhigh", reasoningPer100Lines: 50, costUsd: 0.4, pr: 12 }); + expect(reviews[1]).toMatchObject({ parent: "rv", effort: "xhigh", reasoningPer100Lines: 3000, toolCalls: 60 }); + expect(reviewTotals(reviews)).toMatchObject({ + reviews: 2, + turns: 2, + toolCalls: 70, + costUsd: 1.9, + verdicts: { escalated: 1, pass: 1 }, + }); + }); + + test("a reused session name starts a new review row at its start line", () => { + const [start, turn] = ledgerFixture(); + const again = { ...start, ts: "2026-10-23T09:00:00Z" }; + const reviews = summarizeReviews([start, turn, again]); + + expect(reviews.map((review) => [review.name, review.turns, review.startedAt])).toEqual([ + ["rv", 1, "2026-10-22T10:00:00Z"], + ["rv", 0, "2026-10-23T09:00:00Z"], + ]); + }); + + test("review-depth reads only the current run of a reused name", () => { + const [start, turn, gate] = ledgerFixture(); + const again = { ...start, ts: "2026-10-23T09:00:00Z" }; + + expect(currentReviewRecords([start, turn, gate, again], "rv", "rv")).toEqual([again]); + expect(currentReviewRecords([turn, gate], "rv", "rv")).toEqual([turn, gate]); + }); + + test("review-depth prints the checks the gate recorded, and approximates only for a line without them", () => { + const gate = ledgerFixture()[2] as ReviewGateRecord; + // 3 of 4 with an excused binary passed the gate; a missing Ran line failed shape with no malformed finding. + const recorded: ReviewGateRecord = { + ...gate, + coverage: { reviewed: 3, total: 4 }, + unread: [], + checks: { + coverage: { ok: true, detail: "3 of 4 files reviewed" }, + reasoning: { ok: true, detail: "400 reasoning tokens per 100 changed lines, high floor 100" }, + shape: { ok: false, detail: "no `Ran:` line" }, + }, + }; + expect(gateCheckRows(recorded).map((row) => [row.label, row.ok, row.detail])).toEqual([ + ["coverage", true, "3 of 4 files reviewed"], + ["reasoning", true, "400 reasoning tokens per 100 changed lines, high floor 100"], + ["shape", false, "no `Ran:` line"], + ]); + + // Without the recorded checks the counts are all there is: coverage reads as a miss, shape as clean. + const { checks: _dropped, ...older } = recorded; + expect(gateCheckRows(older).map((row) => [row.label, row.ok])).toEqual([ + ["coverage", false], + ["reasoning", false], + ["shape", true], + ]); + }); + + test("filters by PR, label, tier, verdict, name and time", () => { + const reviews = summarizeReviews(ledgerFixture()); + const names = (filters: Parameters<typeof filterReviews>[1]) => + filterReviews(reviews, filters).map((r) => r.name); + + expect(names({ pr: 12 })).toEqual(["rv", "rv-xhigh"]); + expect(names({ pr: 13 })).toEqual([]); + expect(names({ label: "PR12" })).toEqual(["rv", "rv-xhigh"]); + expect(names({ effort: "xhigh" })).toEqual(["rv-xhigh"]); + expect(names({ verdict: "escalated" })).toEqual(["rv"]); + expect(names({ name: "xhigh" })).toEqual(["rv-xhigh"]); + expect(names({ since: new Date("2026-10-22T10:10:00Z") })).toEqual(["rv-xhigh"]); + }); + + test("--since takes a duration back, a time today or a date", () => { + const now = new Date("2026-10-22T12:00:00Z"); + + expect(parseReviewSince("24h", now)?.toISOString()).toBe("2026-10-21T12:00:00.000Z"); + expect(parseReviewSince("90m", now)?.toISOString()).toBe("2026-10-22T10:30:00.000Z"); + expect(parseReviewSince("2026-10-01", now)?.getDate()).toBe(1); + expect(parseReviewSince("bogus", now)).toBeNull(); + }); + + test("a torn ledger line is skipped and counted, never fatal", () => { + const path = join(mkdtempSync(join(tmpdir(), "gt-review-ledger-")), "reviews.jsonl"); + const [start] = ledgerFixture(); + appendFileSync(path, `${SafeJSON.stringify(start, { strict: true })}\n{"v":1,"kind":"tu\n{"other":true}\n`); + + expect(readReviewRecords(path)).toMatchObject({ records: [{ name: "rv", kind: "start" }], skipped: 2 }); + expect(readFileSync(path, "utf8").split("\n")).toHaveLength(4); + }); +}); + +describe("spawn guards", () => { + test("--base, --allow-shallow and --paths are refused outside --mode review", async () => { + const spawn = (extras: Record<string, unknown>) => + codexDriver.spawn({ + name: "x", + cwd: "/repo", + surfaces: { skills: true, rules: true }, + extras: { mode: "task", ...extras }, + }); + + await expect(spawn({ base: "origin/master" })).rejects.toThrow("apply to --mode review only"); + await expect(spawn({ allowShallow: true })).rejects.toThrow("apply to --mode review only"); + }); + + test("a review session cannot start without its review record, and a task session cannot carry one", async () => { + await expect(spawnCodexSession({ name: "x", mode: "review" })).rejects.toThrow("needs its review record"); + await expect(spawnCodexSession({ name: "x", mode: "task", review: reviewConfig() })).rejects.toThrow( + "needs its review record" + ); + }); +}); + +function importedMeta(name: string, over: Partial<CodexSessionMeta> = {}): CodexSessionMeta { + return { + name, + daemonPid: 0, + cwd: `/wt/${name}`, + sandbox: "read-only", + approvalPolicy: "never", + writePolicy: "deny", + status: "closed", + agentName: `codex_${name}`, + rendezvousSession: "s", + agentsEnabled: true, + startedAt: "2026-10-22T19:08:07.954Z", + lastEventAt: "2026-10-22T19:30:24.784Z", + codexVersion: "0.162.1", + pendingApprovals: {}, + effort: "medium", + label: `pr-split-review ${name}`, + threadId: `thread-${name}`, + ...over, + }; +} + +function costRow(name: string, turn: number, over: Partial<WorkerCostRow> = {}): WorkerCostRow { + return { + v: 1, + ts: `2026-10-22T19:1${turn}:00.000Z`, + backend: "codex", + name, + sessionId: `thread-${name}`, + turn, + model: "gpt-6.1-sol", + effort: "medium", + auth: "subscription", + account: null, + durationMs: 100_000, + toolCalls: 10, + tokens: { input: 1000, cacheRead: 0, cacheWrite: 0, output: 400, reasoning: 300 }, + cost: { + apiEquivalentUsd: 0.5, + basis: "catalog", + pricedAs: ["gpt-6.1-sol"], + longContextRequests: 0, + auth: "subscription", + billedUsd: 0, + claudeEquivalent: { estimate: true, opusModel: "o", sonnetModel: "s", opusUsd: 1, sonnetUsd: 0.5 }, + }, + cwd: `/wt/${name}`, + outcome: "completed", + ...over, + }; +} + +function importReaders(ledgerPath: string, options: { gone?: string[]; noBase?: boolean } = {}) { + const git = readers({ + pr: options.noBase ? null : PR, + numstat: [entry("src/a.ts", 30, 5), entry("src/b.ts", 10)], + commitBefore: (at) => (at.startsWith("2026-10-22") ? "abc1234567890" : null), + }); + const messages: Record<string, string> = { + "rv-full": + "1. [High] src/a.ts:3 - bug. Scenario: x. Fix: y.\nRan: bun test\nFiles reviewed: 2 of 2\nNot reviewed: none", + "rv-part": "Findings: none\nRan: nothing\nFiles reviewed: 1 of 2\nNot reviewed: src/b.ts (time)", + "rv-gone": "a summary without the coverage line", + }; + const fake: ReviewImportReaders = { + readMeta: async (name) => (name in messages ? importedMeta(name) : null), + costRows: (meta) => + meta.name === "rv-part" ? [costRow(meta.name, 1), costRow(meta.name, 2)] : [costRow(meta.name, 1)], + lastAgentMessage: async (meta) => messages[meta.name] ?? "", + cwdExists: (path) => !(options.gone ?? []).some((name) => path.endsWith(name)), + git, + ledgerRecords: () => readReviewRecords(ledgerPath).records, + }; + return { fake, git }; +} + +describe("reviews import", () => { + test("backfills start, turns and a gate whose verdict the coverage line implies, diffing up to the commit the review saw", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-import-")), "reviews.jsonl"); + const { fake, git } = importReaders(ledgerPath, { gone: ["rv-gone"] }); + const results = await importReviewSessions({ + names: ["rv-full", "rv-part", "rv-gone", "rv-none", "rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + + expect(results.map((result) => [result.name, result.status, result.verdict ?? null])).toEqual([ + ["rv-full", "imported", "pass"], + ["rv-part", "imported", "shallow"], + ["rv-gone", "imported", "imported"], + ["rv-none", "missing", null], + ]); + expect(results[0].manifest).toEqual({ base: "origin/master", head: "abc1234567890", files: 2, lines: 45 }); + expect(results[2].manifest).toBeNull(); + expect(results[2].notes[0]).toContain("/wt/rv-gone is gone"); + expect(git.calls).toEqual(["numstat origin/master...abc1234567890", "numstat origin/master...abc1234567890"]); + + const records = readReviewRecords(ledgerPath).records; + expect(records.map((record) => `${record.name}:${record.kind}`)).toEqual([ + "rv-full:start", + "rv-full:turn", + "rv-full:gate", + "rv-part:start", + "rv-part:turn", + "rv-part:turn", + "rv-part:gate", + "rv-gone:start", + "rv-gone:turn", + "rv-gone:gate", + ]); + expect(records.every((record) => record.imported === true)).toBe(true); + expect(records[2]).toMatchObject({ + kind: "gate", + pr: 12, + files: 2, + lines: 45, + effort: "medium", + head: "abc1234567890", + }); + expect(records[6]).toMatchObject({ + kind: "gate", + reason: "imported: Files reviewed 1 of 2, 1 unread", + unread: ["src/b.ts"], + }); + expect(records[9]).toMatchObject({ + base: "origin/master", + files: null, + lines: null, + reasoningPer100Lines: null, + }); + + const summaries = summarizeReviews(records); + expect(summaries.find((review) => review.name === "rv-part")).toMatchObject({ + turns: 2, + toolCalls: 20, + costUsd: 1, + coverage: "1/2", + reasoningPer100Lines: 1333, + verdict: "shallow", + }); + expect(summaries.find((review) => review.name === "rv-gone")?.reasoningPer100Lines).toBeNull(); + + // A second import of the same names adds nothing. + const again = await importReviewSessions({ names: ["rv-full", "rv-part"], readers: fake, ledgerPath }); + expect(again.map((result) => result.status)).toEqual(["skipped", "skipped"]); + expect(readReviewRecords(ledgerPath).records).toHaveLength(10); + }); + + test("without a base or with --no-cwd-from-session the manifest is null and the note says why", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-import-")), "reviews.jsonl"); + const { fake } = importReaders(ledgerPath, { noBase: true }); + const [derived] = await importReviewSessions({ names: ["rv-full"], readers: fake, ledgerPath }); + expect(derived.manifest).toBeNull(); + expect(derived.notes[0]).toContain("no base: --mode review needs --base <ref>"); + + const [skipped] = await importReviewSessions({ + names: ["rv-part"], + cwdFromSession: false, + readers: fake, + ledgerPath, + }); + expect(skipped.notes[0]).toContain("manifest skipped"); + expect(readReviewRecords(ledgerPath).records.filter((record) => record.kind === "gate")).toHaveLength(2); + }); +}); + +describe("reviews import: verdicts and failed writes", () => { + test("a full count whose total differs from the rebuilt manifest stays imported, never pass", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-import-")), "reviews.jsonl"); + const { fake } = importReaders(ledgerPath); + const mismatched: ReviewImportReaders = { + ...fake, + lastAgentMessage: async () => + "1. [High] src/a.ts:3 - bug. Scenario: x. Fix: y.\nRan: bun test\nFiles reviewed: 1 of 1\nNot reviewed: none", + }; + const [result] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: mismatched, + ledgerPath, + }); + + expect(result.verdict).toBe("imported"); + expect(summarizeReviews(readReviewRecords(ledgerPath).records)[0].verdict).toBe("imported"); + expect(readReviewRecords(ledgerPath).records.at(-1)).toMatchObject({ + kind: "gate", + reason: "imported: reported 1 of 1, but the manifest has 2 files; the scope is not confirmed", + }); + }); + + test("a failed ledger write is reported, and a partial earlier import is redone instead of skipped", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-review-import-")); + const blocker = join(dir, "not-a-folder"); + writeFileSync(blocker, ""); + const unwritable = join(blocker, "reviews.jsonl"); + const { fake: failing } = importReaders(unwritable); + const [failed] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: { ...failing, ledgerRecords: () => [] }, + ledgerPath: unwritable, + }); + expect(failed.status).toBe("failed"); + expect(failed.notes.at(-1)).toContain("the review ledger write failed"); + + // An older import wrote its start line and then failed: the rerun completes it and its row replaces the stub. + const ledgerPath = join(dir, "reviews.jsonl"); + const { fake } = importReaders(ledgerPath); + const stub = { + v: 1, + ts: "2026-10-22T19:00:00.000Z", + name: "rv-full", + chain: "rv-full", + base: "origin/master", + files: 2, + lines: 45, + effort: "medium", + imported: true, + kind: "start", + cwd: "/wt/rv-full", + effortSource: "imported", + allowShallow: false, + }; + appendFileSync(ledgerPath, `${SafeJSON.stringify(stub)}\n`); + const [redone] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + expect(redone).toMatchObject({ status: "imported", verdict: "pass" }); + expect(redone.notes).toContain("an earlier import of this name has no gate line; this import replaces it"); + expect( + summarizeReviews(readReviewRecords(ledgerPath).records).map((review) => [review.name, review.verdict]) + ).toEqual([["rv-full", "pass"]]); + + const [again] = await importReviewSessions({ names: ["rv-full"], readers: fake, ledgerPath }); + expect(again.status).toBe("skipped"); + }); + + test("a redo after a torn last line keeps the line boundary, so the old batch's turns never fold into the new row", async () => { + const ledgerPath = join(mkdtempSync(join(tmpdir(), "gt-review-torn-")), "reviews.jsonl"); + const { fake } = importReaders(ledgerPath); + const [first] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + expect(first.status).toBe("imported"); + const clean = summarizeReviews(readReviewRecords(ledgerPath).records); + + // The same batch again, torn inside its gate line: complete start and turn lines, then an unterminated fragment. + const text = readFileSync(ledgerPath, "utf8"); + const gateAt = text.lastIndexOf('{"v":1'); + writeFileSync(ledgerPath, text.slice(0, gateAt + 20)); + const [redone] = await importReviewSessions({ + names: ["rv-full"], + base: "origin/master", + readers: fake, + ledgerPath, + }); + expect(redone.status).toBe("imported"); + + const { records, skipped } = readReviewRecords(ledgerPath); + expect(skipped).toBe(1); + expect(summarizeReviews(records).map((row) => [row.name, row.verdict, row.turns, row.costUsd])).toEqual( + clean.map((row) => [row.name, row.verdict, row.turns, row.costUsd]) + ); + }); + + test("tools codex reviews import exits 1 for a missing session in table and --json output alike", async () => { + const home = mkdtempSync(join(tmpdir(), "gt-review-import-cli-")); + const previousExitCode = process.exitCode; + const printed: unknown[] = []; + const result = spyOn(out, "result").mockImplementation((value) => { + printed.push(value); + }); + const println = spyOn(out, "println").mockImplementation(() => {}); + // Read through a call: after the reset below, TypeScript would narrow a direct read to undefined. + const exitCode = (): typeof process.exitCode => process.exitCode; + + try { + await env.testing.withOverrides({ GENESIS_TOOLS_HOME: home }, async () => { + for (const json of [true, false]) { + process.exitCode = undefined; + await runImport(["absent-review"], { cwdFromSession: true, json }); + expect(exitCode()).toBe(1); + } + }); + expect(printed).toHaveLength(1); + expect(printed[0]).toMatchObject({ results: [{ name: "absent-review", status: "missing" }] }); + } finally { + result.mockRestore(); + println.mockRestore(); + process.exitCode = previousExitCode; + } + }); +}); diff --git a/src/codex/lib/review/types.ts b/src/codex/lib/review/types.ts new file mode 100644 index 0000000000..582038caba --- /dev/null +++ b/src/codex/lib/review/types.ts @@ -0,0 +1,47 @@ +import type { ReviewEffort, ReviewManifest } from "@genesiscz/utils/worker/contract"; + +/** + * What the depth gate decided about one review turn. `imported` is never decided by the gate: it marks a review + * backfilled from a session that ran before the ledger and whose final message has no coverage line. + */ +export const REVIEW_VERDICTS = ["pass", "steered", "escalated", "shallow", "imported"] as const; +export type ReviewVerdict = (typeof REVIEW_VERDICTS)[number]; + +/** Where the base ref came from; printed at spawn, because a derived base is a guess the caller must see. */ +export type ReviewBaseSource = "flag" | "pr" | "upstream"; + +/** What the review turns of one session spent, summed; the gate's reasoning floor reads it. */ +export interface CodexReviewSpent { + turns: number; + durationMs: number; + toolCalls: number; + reasoningTokens: number; + outputTokens: number; + /** API-equivalent dollars; null while any turn was unpriced. */ + costUsd: number | null; +} + +/** The `--mode review` part of a codex session record. */ +export interface CodexReviewConfig { + base: string; + baseSource: ReviewBaseSource; + /** One human sentence on how the base was chosen. */ + baseDetail: string; + branch?: string; + pr?: number; + manifest: ReviewManifest; + effort: ReviewEffort; + effortSource: "flag" | "diff-size"; + allowShallow: boolean; + /** Automatic gate steps already taken in this review chain: 0 fresh, 1 after the steer, 2 in the escalated session. */ + step: number; + /** The first session of the chain; an escalated session keeps its parent's chain. */ + chain: string; + parent?: string; + /** The first prompt of the review, kept so an escalation can rerun it. */ + prompt?: string; + verdict?: ReviewVerdict; + reason?: string; + escalatedTo?: string; + spent?: CodexReviewSpent; +} diff --git a/src/codex/lib/seed-instructions.ts b/src/codex/lib/seed-instructions.ts index e6873f597f..73de67f703 100644 --- a/src/codex/lib/seed-instructions.ts +++ b/src/codex/lib/seed-instructions.ts @@ -1,4 +1,4 @@ -import { buildWorkerContract, type WorkerSandbox } from "@genesiscz/utils/worker/contract"; +import { buildWorkerContract, type WorkerReviewInput, type WorkerSandbox } from "@genesiscz/utils/worker/contract"; /** The contract's name for a codex sandbox mode: `danger-full-access` (`--write full`) has no sandbox at all. */ export function contractSandbox(sandbox: string | undefined): WorkerSandbox { @@ -19,10 +19,12 @@ export function buildAgentInstructions(options: { rendezvousSession: string; leadName: string; sandbox?: string; + review?: WorkerReviewInput | null; }): string { return buildWorkerContract({ backend: "codex", sandbox: contractSandbox(options.sandbox), bus: { agentName: options.agentName, leadName: options.leadName, rendezvousSession: options.rendezvousSession }, + ...(options.review ? { review: options.review } : {}), }); } diff --git a/src/codex/lib/session.test.ts b/src/codex/lib/session.test.ts index b28d66cb78..a0b15c9aac 100644 --- a/src/codex/lib/session.test.ts +++ b/src/codex/lib/session.test.ts @@ -10,6 +10,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import type { ThreadTurnsListParams, Turn } from "./_generated/protocol"; import { CodexAccountBinding } from "./account"; import { isOwnBusCommand, isReadOnlyCommand } from "./bus-approval"; +import { readReviewRecords } from "./review/ledger"; import type { RpcClient } from "./session"; import { CodexSessionRuntime } from "./session"; import { type CodexSessionMeta, CodexSessionStore } from "./store"; @@ -1168,3 +1169,125 @@ describe("an ask worker's read-only commands", () => { }); }); }); + +describe("a --mode review session", () => { + const report = (reviewed: number) => + `Findings: none\nRan: nothing\nFiles reviewed: ${reviewed} of 2\nNot reviewed: none`; + + async function finishTurn(runtime: CodexSessionRuntime, turn: string, text: string, reasoning = 5): Promise<void> { + const params = { threadId: "thread-1" }; + await runtime.handleNotification({ method: "turn/started", params: { ...params, turn: { id: turn } } }); + await runtime.handleNotification({ + method: "thread/tokenUsage/updated", + params: { + ...params, + tokenUsage: { last: { inputTokens: 100, outputTokens: 50, reasoningOutputTokens: reasoning } }, + }, + }); + await runtime.handleNotification({ + method: "item/completed", + params: { ...params, item: { type: "agentMessage", id: `${turn}-msg`, text } }, + }); + await runtime.handleNotification({ + method: "turn/completed", + params: { ...params, turn: { id: turn, status: "completed" } }, + }); + } + + test("gets its tier and manifest, steers once on a miss, escalates on the second, and ends shallow when that fails", async () => { + const home = mkdtempSync(join(tmpdir(), "gt-codex-runtime-review-")); + + await env.testing.withOverrides({ GENESIS_TOOLS_HOME: home }, async () => { + const store = new CodexSessionStore(); + const meta: CodexSessionMeta = { + ...makeMeta(home), + name: "rv", + agentsEnabled: false, + writePolicy: "deny", + sandbox: "read-only", + effort: "high", + review: { + base: "origin/master", + baseSource: "flag", + baseDetail: "--base", + pr: 12, + manifest: { + base: "origin/master", + files: [ + { path: "src/a.ts", added: 40, deleted: 2 }, + { path: "src/b.ts", added: 8, deleted: 0 }, + ], + added: 48, + deleted: 2, + }, + effort: "high", + effortSource: "diff-size", + allowShallow: false, + step: 0, + chain: "rv", + }, + }; + store.writeMeta(meta); + const client = new FakeRpcClient(); + const escalations: string[] = []; + const gateEvents: unknown[] = []; + const runtime = new CodexSessionRuntime({ + client, + store, + meta, + escalateReview: async (request) => { + escalations.push(`${request.name} at ${request.effort}`); + throw new Error('Codex session "rv-xhigh" is already active'); + }, + onReviewGate: (event) => { + gateEvents.push(event.verdict); + }, + }); + + await runtime.start({ prompt: "Review PR 12." }); + const threadStart = client.requests.find((request) => request.method === "thread/start"); + const startParams = threadStart?.params as { developerInstructions?: string } | undefined; + const instructions = String(startParams?.developerInstructions); + expect(instructions).toContain("You are a code REVIEWER at the high tier."); + expect(instructions).toContain("Files to review (2, +48 -2):\n- src/a.ts +40 -2\n- src/b.ts +8 -0"); + expect((await store.readMeta("rv"))?.review?.prompt).toBe("Review PR 12."); + + await finishTurn(runtime, "turn-1", report(1)); + const steers = client.requests.filter((request) => request.method === "turn/start"); + expect(steers).toHaveLength(2); + const steerText = (steers[1].params as { input: Array<{ text: string }> }).input[0].text; + expect(steerText).toContain("You did not read: src/a.ts, src/b.ts."); + // The session never reads as idle between the missed turn and its steer. + expect((await store.readMeta("rv"))?.status).toBe("running"); + expect((await store.readMeta("rv"))?.review).toMatchObject({ verdict: "steered", step: 1 }); + + await finishTurn(runtime, "turn-2", report(1)); + expect(client.requests.filter((request) => request.method === "turn/start")).toHaveLength(2); + expect(escalations).toEqual(["rv-xhigh at xhigh"]); + + const after = await store.readMeta("rv"); + expect(after?.status).toBe("ready"); + expect(after?.review?.verdict).toBe("shallow"); + expect(after?.review?.reason).toContain('the escalate failed: Codex session "rv-xhigh" is already active'); + expect(after?.review?.spent).toMatchObject({ turns: 2, reasoningTokens: 10 }); + expect(gateEvents).toEqual(["steered", "escalated", "shallow"]); + expect( + readReviewRecords().records.map( + (record) => `${record.kind}:${"verdict" in record ? record.verdict : ""}` + ) + ).toEqual(["turn:", "gate:steered", "turn:", "gate:escalated", "gate:shallow"]); + + // A passing review needs no step: a full report with enough reasoning ends the gate at once. + const passing = new CodexSessionRuntime({ + client: new FakeRpcClient(), + store, + meta: { ...meta, name: "rv-ok", review: { ...meta.review!, chain: "rv-ok", prompt: "x" } }, + }); + store.writeMeta({ ...meta, name: "rv-ok" }); + await passing.start({}); + await finishTurn(passing, "turn-1", report(2), 500); + expect((await store.readMeta("rv-ok"))?.review).toMatchObject({ verdict: "pass", step: 0 }); + expect((await store.readMeta("rv-ok"))?.status).toBe("ready"); + }); + }); +}); diff --git a/src/codex/lib/session.ts b/src/codex/lib/session.ts index fe3755f849..9b923aecbb 100644 --- a/src/codex/lib/session.ts +++ b/src/codex/lib/session.ts @@ -1,11 +1,11 @@ import { relative } from "node:path"; import { formatCostLine } from "@app/ai-spend/lib/workers/cost"; -import { recordWorkerTurnCost } from "@app/ai-spend/lib/workers/ledger"; +import { recordWorkerTurnCost, type WorkerCostRow } from "@app/ai-spend/lib/workers/ledger"; import { withTimeout } from "@genesiscz/utils/async"; import { type CodexFinishedTurn, CodexTurnCostTracker } from "@genesiscz/utils/codex/turn-cost"; import { codexTurnOutcome } from "@genesiscz/utils/codex/turn-outcome"; import { logger } from "@genesiscz/utils/logger"; -import { buildWorkerContract } from "@genesiscz/utils/worker/contract"; +import { buildWorkerContract, type ReviewEffort } from "@genesiscz/utils/worker/contract"; import { WorkerDeliveryRejectedError, workerSourceHome } from "@genesiscz/utils/worker/delivery"; import type { InitializeParams, @@ -26,6 +26,7 @@ import type { RpcNotification, RpcServerRequest } from "./app-server-client"; import { isOwnBusCommand, isReadOnlyCommand } from "./bus-approval"; import type { CodexControl } from "./control"; import { assignThreadToProject, type CodexProject, resolveProjectSelector } from "./desktop-project"; +import { markReviewShallow, recordReviewTurn } from "./review/controller"; import { buildAgentInstructions, contractSandbox } from "./seed-instructions"; import type { CodexSessionMeta, CodexSessionStore } from "./store"; @@ -46,6 +47,16 @@ interface RuntimeOptions { unsubscribeTimeoutMs?: number; /** `tools codex spawn --project`: a Desktop project id, name or root. Without it the cwd decides. */ project?: string; + /** Starts the escalated review session the depth gate asked for; the daemon wires it to `spawnCodexSession`. */ + escalateReview?: (request: EscalateReviewRequest) => Promise<void>; + /** Told about every gate decision, so the daemon can put it on the agents bus. */ + onReviewGate?: (event: Record<string, unknown>) => void | Promise<void>; +} + +export interface EscalateReviewRequest { + meta: CodexSessionMeta; + effort: ReviewEffort; + name: string; } interface ThreadStartResult { @@ -119,6 +130,10 @@ export class CodexSessionRuntime { private readonly costTracker: CodexTurnCostTracker; /** The cost line of the last booked turn, for the lead's `turn_completed` bus event. */ private lastTurnCost: { turnId?: string; line: string } | null = null; + /** The current turn's last agent message: a review's report, which the depth gate reads. */ + private turnMessage = ""; + private readonly escalateReview: RuntimeOptions["escalateReview"]; + private readonly onReviewGate: RuntimeOptions["onReviewGate"]; constructor(options: RuntimeOptions) { this.account = options.account; @@ -128,6 +143,8 @@ export class CodexSessionRuntime { this.onApprovalRequest = options.onApprovalRequest; this.unsubscribeTimeoutMs = options.unsubscribeTimeoutMs ?? 1000; this.projectSelector = options.project; + this.escalateReview = options.escalateReview; + this.onReviewGate = options.onReviewGate; // A pinned account is a ChatGPT login; `account/updated` confirms or corrects it. this.costTracker = new CodexTurnCostTracker({ model: options.meta.model ?? null, @@ -159,23 +176,28 @@ export class CodexSessionRuntime { this.chosenProject = resolveProjectSelector(projects.data ?? [], this.projectSelector); } + const review = this.meta.review + ? { effort: this.meta.review.effort, manifest: this.meta.review.manifest } + : null; const threadParams: ThreadStartParams = { cwd: this.meta.cwd, sandbox: this.meta.sandbox, approvalPolicy: this.meta.approvalPolicy, ...(this.meta.model ? { model: this.meta.model } : {}), - // Every worker gets the shared contract (checkpoints, report shape); - // the bus identity and commands only when the swarm is enabled. + // Every worker gets the shared contract (checkpoints, report shape); the bus identity and + // commands only when the swarm is enabled; a reviewer its tier rules and the diff manifest. developerInstructions: this.meta.agentsEnabled ? buildAgentInstructions({ agentName: this.meta.agentName, rendezvousSession: this.meta.rendezvousSession, leadName: "lead", sandbox: this.meta.sandbox, + review, }) : buildWorkerContract({ backend: "codex", sandbox: contractSandbox(this.meta.sandbox), + review, }), }; const thread = await this.client.request<ThreadStartResult>("thread/start", threadParams); @@ -198,7 +220,9 @@ export class CodexSessionRuntime { ...(this.meta.effort ? { effort: this.meta.effort } : {}), }; const result = await this.client.request<TurnStartResult>("turn/start", params); - await this.updateMeta({ activeTurnId: result.turn.id, status: "running" }); + // A review keeps its first prompt, so an escalated session can rerun the same review one tier higher. + const review = this.meta.review && !this.meta.review.prompt ? { ...this.meta.review, prompt: body } : undefined; + await this.updateMeta({ activeTurnId: result.turn.id, status: "running", ...(review ? { review } : {}) }); return result.turn.id; } @@ -222,10 +246,9 @@ export class CodexSessionRuntime { await this.fileUnderProject(notification); this.trackFileChange(notification); + this.trackAgentMessage(notification); const finished = this.costTracker.observe(notification, new Date()); - if (finished) { - await this.bookTurnCost(finished); - } + const booked = finished ? await this.bookTurnCost(finished) : null; if (notification.method === "turn/started") { await this.updateMeta({ @@ -238,6 +261,12 @@ export class CodexSessionRuntime { const terminal = codexTurnOutcome(notification); if (terminal) { + // A steered review keeps running: the session never reads as ready between the turn and its steer. + if (finished && this.meta.review && (await this.gateReviewTurn(finished, booked))) { + await this.updateMeta({ lastEventAt }); + return; + } + await this.updateMeta({ activeTurnId: undefined, status: "ready", lastEventAt }); if (terminal.outcome !== "interrupted") { await this.deliverQueuedSteer(); @@ -505,8 +534,99 @@ export class CodexSessionRuntime { } } + /** The last agent message of the current turn, which a review's gate parses. */ + private trackAgentMessage(notification: RpcNotification): void { + if (notification.method === "turn/started") { + this.turnMessage = ""; + return; + } + + const item = nestedRecord(notification.params, "item"); + + if ( + notification.method === "item/completed" && + item?.type === "agentMessage" && + typeof item.text === "string" + ) { + this.turnMessage = item.text; + } + } + + /** + * Runs the depth gate on a finished review turn and takes its step. True when the review continues in this + * session (the steer started a turn). An escalation starts a new session and this one ends; a step the runtime + * cannot take ends the review `shallow` with the reason. + */ + private async gateReviewTurn(finished: CodexFinishedTurn, booked: WorkerCostRow | null): Promise<boolean> { + const current = this.meta.review; + + if (!current) { + return false; + } + + const outcome = recordReviewTurn({ + name: this.meta.name, + ...(this.meta.label ? { label: this.meta.label } : {}), + review: current, + finished, + report: this.turnMessage, + costUsd: booked?.cost.apiEquivalentUsd ?? null, + }); + this.turnMessage = ""; + await this.updateMeta({ review: outcome.review }); + + if (outcome.decision) { + await this.publishReviewGate(outcome.review); + } + + const action = outcome.action; + + try { + if (action.kind === "steer") { + await this.startTurn(action.text); + return true; + } + + if (action.kind === "escalate") { + if (!this.escalateReview) { + throw new Error("this runtime has no way to start an escalated session"); + } + + await this.escalateReview({ meta: this.meta, effort: action.effort, name: action.name }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn({ err, name: this.meta.name, action: action.kind }, "review gate step failed"); + const review = markReviewShallow({ + name: this.meta.name, + ...(this.meta.label ? { label: this.meta.label } : {}), + review: outcome.review, + reason: `${outcome.review.reason}; the ${action.kind} failed: ${message}`, + }); + await this.updateMeta({ review }); + await this.publishReviewGate(review); + } + + return false; + } + + private async publishReviewGate(review: NonNullable<CodexSessionMeta["review"]>): Promise<void> { + try { + await this.onReviewGate?.({ + event: "review_gate", + name: this.meta.name, + verdict: review.verdict, + reason: review.reason, + step: review.step, + ...(review.escalatedTo ? { escalatedTo: review.escalatedTo } : {}), + }); + } catch (err) { + log.warn({ err, name: this.meta.name }, "publishing the review gate decision failed"); + } + } + /** Never throws: `recordWorkerTurnCost` logs its own failure and the turn goes on without a cost line. */ - private async bookTurnCost(finished: CodexFinishedTurn): Promise<void> { + private async bookTurnCost(finished: CodexFinishedTurn): Promise<WorkerCostRow | null> { const row = await recordWorkerTurnCost({ backend: "codex", name: this.meta.name, @@ -529,6 +649,7 @@ export class CodexSessionRuntime { this.lastTurnCost = row ? { ...(finished.turnId ? { turnId: finished.turnId } : {}), line: formatCostLine(row.cost, "codex") } : null; + return row; } /** `{ cost }` for the bus event of the turn just booked; undefined for any other notification. */ diff --git a/src/codex/lib/spawn.ts b/src/codex/lib/spawn.ts index 162c8e0f23..77383f802e 100644 --- a/src/codex/lib/spawn.ts +++ b/src/codex/lib/spawn.ts @@ -16,6 +16,8 @@ import { CODEX_SCHEMA_VERSION } from "./_generated/protocol"; import { CodexAccountBinding } from "./account"; import { computerUseOverrides } from "./computer-use"; import { sessionDaemonLogPath, sessionLaunchPath } from "./paths"; +import { appendReviewRecord, reviewLedgerBase } from "./review/ledger"; +import type { CodexReviewConfig } from "./review/types"; import { type CodexSessionMeta, CodexSessionStore, type CodexWritePolicy } from "./store"; import { detectCodexVersion } from "./version"; @@ -37,6 +39,8 @@ export interface SpawnOptions { label?: string; /** A Codex Desktop project id, name or root to show the thread under; the cwd decides without it. */ project?: string; + /** Required with `mode: "review"`: what `prepareReview` decided, plus the chain fields. */ + review?: CodexReviewConfig; } export interface LaunchConfig { @@ -269,6 +273,10 @@ export interface CodexSpawnResult extends CodexSessionMeta { } export async function spawnCodexSession(options: SpawnOptions): Promise<CodexSpawnResult> { + if ((options.mode === "review") !== (options.review !== undefined)) { + throw new Error("A review session needs its review record, and only a review session takes one"); + } + const cwd = resolve(options.cwd ?? process.cwd()); const checkout = checkoutKindSync(cwd); assertWritePolicyAllowed({ write: options.write, checkout }); @@ -335,6 +343,8 @@ export async function spawnCodexSession(options: SpawnOptions): Promise<CodexSpa const daemonEntry = resolve(import.meta.dir, "../daemon.ts"); const logFd = openSync(sessionDaemonLogPath(options.name), "a"); + // A review runs at its tier; the gate's escalation changes the tier, never a stale --effort. + const effort = options.review?.effort ?? options.effort; const meta: CodexSessionMeta = { ...(account ? { accountId: account.accountId, accountName: account.name } : {}), name: options.name, @@ -343,8 +353,9 @@ export async function spawnCodexSession(options: SpawnOptions): Promise<CodexSpa cwd, home, ...(options.model ? { model: options.model } : {}), - ...(options.effort ? { effort: options.effort } : {}), + ...(effort ? { effort } : {}), ...(options.label ? { label: options.label } : {}), + ...(options.review ? { review: options.review } : {}), ...policy, status: "starting", agentName: `codex_${options.name}`, @@ -357,6 +368,16 @@ export async function spawnCodexSession(options: SpawnOptions): Promise<CodexSpa }; claimSessionName(store, meta); + if (options.review) { + appendReviewRecord(reviewLedgerBase({ name: options.name, review: options.review, label: options.label }), { + kind: "start", + cwd, + effortSource: options.review.effortSource, + allowShallow: options.review.allowShallow, + ...(options.review.parent ? { parent: options.review.parent } : {}), + }); + } + const proc = (() => { try { return Bun.spawn({ diff --git a/src/codex/lib/store.ts b/src/codex/lib/store.ts index c0f05cb0ff..4828ed578c 100644 --- a/src/codex/lib/store.ts +++ b/src/codex/lib/store.ts @@ -7,6 +7,7 @@ import { JsonlWriter } from "@genesiscz/utils/log-session/jsonl-writer"; import { logger } from "@genesiscz/utils/logger"; import { WorkerMetaStore } from "@genesiscz/utils/worker/meta-store"; import { sessionEventsPath, sessionMetaPath, sessionsDir } from "./paths"; +import type { CodexReviewConfig } from "./review/types"; const log = logger.child({ component: "codex:store" }); @@ -81,6 +82,8 @@ export interface CodexSessionMeta { desktopProject?: { id: string; name: string }; /** The `spawn --label` tag every cost-ledger row of the session carries. */ label?: string; + /** `spawn --mode review`: the base, the diff manifest, the tier and the depth gate's state. */ + review?: CodexReviewConfig; } export interface CodexEventRecord { diff --git a/src/control/commands/capture-session.ts b/src/control/commands/capture-session.ts new file mode 100644 index 0000000000..47bf6adb3d --- /dev/null +++ b/src/control/commands/capture-session.ts @@ -0,0 +1,273 @@ +import { randomUUID } from "node:crypto"; +import { withInterrupt } from "@genesiscz/utils/cli/interrupt"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger, out } from "@genesiscz/utils/logger"; +import { createBoxTable, renderCliHeader } from "@genesiscz/utils/table"; +import type { Command } from "commander"; +import { ancestorPids } from "../lib/decision/frontmost"; +import { ffmpegSegmentJoiner, nativeSegmentRecorder } from "../lib/recording-native"; +import { + parseRecordingCommand, + RECORDING_SESSION_MAX_SECONDS, + RecordingSession, + type RecordingSessionEvent, + recordingsDir, +} from "../lib/recording-session"; +import { + defaultRecordingTarget, + describeRecordingTarget, + listRecordingTargets, + recordingTargetSchema, +} from "../lib/recording-targets"; + +const { log } = logger.scoped("control-capture-session"); + +interface SessionFlags { + json?: boolean; + stopOnStdin?: boolean; + target?: string; + excludeWindowIds?: string; + fps?: string; + /** Commander's `--no-indicator`: true unless the flag is given. */ + indicator?: boolean; + maxSeconds?: string; + output?: string; +} + +function parseIds(raw: string | undefined): number[] | undefined { + if (!raw) { + return undefined; + } + + const ids = raw.split(",").map(Number); + if (ids.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new Error("--exclude-window-ids takes positive CG window ids separated by commas"); + } + + return ids; +} + +function boundedNumber(raw: string | undefined, name: string, min: number, max: number): number | undefined { + if (raw === undefined) { + return undefined; + } + + const value = Number(raw); + if (!Number.isFinite(value) || value < min || value > max) { + throw new Error(`${name} must be between ${min} and ${max}`); + } + + return value; +} + +function stamp(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, "0"); + const day = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`; + return `${day}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; +} + +function humanLine(event: RecordingSessionEvent): string { + switch (event.event) { + case "state": + return `${event.state} (${event.reason}) · ${describeRecordingTarget(event.target)} · ${Math.round(event.elapsedMs / 1000)} s`; + case "elapsed": + return `${Math.round(event.elapsedMs / 1000)} s`; + case "segment": + return `segment ${event.index}: ${Math.round(event.durationMs / 1000)} s`; + case "done": + return `saved ${event.path} (${Math.round(event.durationMs / 1000)} s, ${event.segments} segments)`; + case "discarded": + return "discarded"; + case "preparing": + return event.message; + case "error": + return `error: ${event.message}`; + } +} + +/** + * Reads one command per stdin line and calls `onEnd` at EOF. The returned function stops reading, so a finished + * session does not keep the process alive while its host still holds the pipe open. + */ +function readCommands({ onLine, onEnd }: { onLine: (line: string) => void; onEnd: () => void }): () => void { + const reader = Bun.stdin.stream().getReader(); + const decoder = new TextDecoder(); + let cancelled = false; + void (async () => { + let pending = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + + pending += decoder.decode(next.value, { stream: true }); + let newline = pending.indexOf("\n"); + while (newline >= 0) { + const line = pending.slice(0, newline).trim(); + pending = pending.slice(newline + 1); + if (line) { + onLine(line); + } + + newline = pending.indexOf("\n"); + } + } + } catch (error) { + log.debug({ error }, "recording command input ended with an error"); + } + + if (!cancelled) { + onEnd(); + } + })(); + return () => { + cancelled = true; + void reader.cancel().catch((error: unknown) => log.debug({ error }, "recording command input cancel")); + }; +} + +export function registerCaptureSessionCommands(capture: Command): void { + capture + .command("session") + .description( + "A recording you control while it runs: JSONL commands on stdin (pause, resume, switch, restart, stop, discard), JSONL events on stdout" + ) + .option("--json", "Events as JSON lines on stdout") + .option("--stop-on-stdin", "Read commands from stdin; its end stops and keeps the recording") + .option( + "--target <json>", + 'What to record: {"kind":"window","windowIds":[n]}, {"kind":"app","app":"Finder"} or {"kind":"display","screenIndex":0}; default the front window' + ) + .option("--exclude-window-ids <ids>", "CG window ids the movie never shows (the host's own controls)") + .option("--fps <n>", "Frame rate while something moves, 0.5–30", "15") + .option("--no-indicator", "Hide the red border around what is recorded (never in the movie either way)") + .option("--max-seconds <n>", `Longest recording, 1–${RECORDING_SESSION_MAX_SECONDS} s`) + .option("--output <path>", "Where the joined movie goes (default ~/.genesis-tools/control/recordings/)") + .action(async (flags: SessionFlags) => { + // A host that dies closes the pipe: the next write fails with EPIPE, which would end this process with + // the segments on disk and no movie. Stop instead, and keep what was recorded. + let hostGone = false; + let session: RecordingSession | undefined; + process.stdout.on("error", (error) => { + if (hostGone) { + return; + } + + hostGone = true; + log.warn({ error }, "stdout closed; the host is gone, so the recording stops and is kept"); + void session?.command({ cmd: "stop" }); + }); + const emit = (event: RecordingSessionEvent) => { + log.info({ event }, "recording event"); + if (hostGone) { + return; + } + + if (flags.json) { + out.print(`${SafeJSON.stringify(event)}\n`); + } else { + out.printlnErr(humanLine(event)); + } + }; + + try { + const target = flags.target + ? recordingTargetSchema.parse(SafeJSON.parse(flags.target, { strict: true })) + : defaultRecordingTarget(await listRecordingTargets(), new Set([...ancestorPids(), process.pid])); + if (!target) { + throw new Error("Nothing on screen to record."); + } + + session = new RecordingSession({ + target, + directory: recordingsDir(`session-${randomUUID()}`), + output: flags.output ?? recordingsDir(`recording-${stamp()}.mp4`), + recorder: nativeSegmentRecorder({ + excludeWindowIds: parseIds(flags.excludeWindowIds), + fps: boundedNumber(flags.fps, "--fps", 0.5, 30), + indicator: flags.indicator, + }), + join: ffmpegSegmentJoiner, + emit, + maxSeconds: boundedNumber(flags.maxSeconds, "--max-seconds", 1, RECORDING_SESSION_MAX_SECONDS), + }); + log.info({ target, stopOnStdin: flags.stopOnStdin === true }, "recording session starting"); + } catch (error) { + emit({ event: "error", message: error instanceof Error ? error.message : String(error), fatal: true }); + process.exitCode = 1; + return; + } + + const active = session; + const outcome = await withInterrupt( + async (signal) => { + // Ctrl-C or SIGTERM keeps what was recorded: a host that quits never loses the movie. + signal.addEventListener("abort", () => void active.command({ cmd: "stop" }), { once: true }); + await active.start(); + const stopReading = flags.stopOnStdin + ? readCommands({ + onLine: (line) => { + const command = parseRecordingCommand(line); + if ("error" in command) { + emit({ event: "error", message: command.error, fatal: false }); + return; + } + + void active.command(command); + }, + onEnd: () => void active.command({ cmd: "stop" }), + }) + : undefined; + const result = await active.finished; + stopReading?.(); + return result; + }, + { handleTermination: true } + ); + log.info({ outcome }, "recording session ended"); + if (outcome === "failed") { + process.exitCode = 1; + } + }); + + capture + .command("targets") + .description("What a recording session can record: on-screen windows, their apps, and the displays") + .option("--json", "JSON on stdout") + .action(async (flags: { json?: boolean }) => { + const targets = await listRecordingTargets(); + if (flags.json) { + out.print(`${SafeJSON.stringify(targets)}\n`); + return; + } + + renderCliHeader( + "Recording targets", + `${targets.windows.length} windows · ${targets.displays.length} displays` + ); + const table = createBoxTable(["KIND", "ID", "APP", "TITLE / NAME", "SIZE"]); + for (const window of targets.windows) { + table.push([ + "window", + String(window.id), + window.app, + window.title || "—", + `${Math.round(window.bounds.width)}x${Math.round(window.bounds.height)}`, + ]); + } + + for (const display of targets.displays) { + table.push([ + "display", + String(display.index), + "", + display.name + (display.isPrimary ? " (main)" : ""), + `${display.width}x${display.height}`, + ]); + } + + out.println(table.toString()); + }); +} diff --git a/src/control/commands/capture.ts b/src/control/commands/capture.ts index 00c7271564..40bfa59e7e 100644 --- a/src/control/commands/capture.ts +++ b/src/control/commands/capture.ts @@ -9,6 +9,7 @@ import { nativeCapturePreflight } from "../lib/capture-native"; import { CAPTURE_HELP, type Plan } from "../lib/capture-plan"; import { CaptureRunError, type RunResult, runCapturePlan, runClickmap, runRecrop } from "../lib/capture-runner"; import { addCaptureFlags, type CaptureFlags, captureFromFlags, resolveCaptureEnumFlags } from "../lib/native-record"; +import { registerCaptureSessionCommands } from "./capture-session"; function fail(msg: string, exitCode = 2): never { console.error(`capture-with-actions: ${msg}`); @@ -98,6 +99,8 @@ export function registerCaptureCommands(program: Command): void { } }); + registerCaptureSessionCommands(capture); + capture .command("recrop <result> <plan>") .description("Re-crop frames of a PRIOR run (no re-record) — the offline reframing flow") diff --git a/src/control/commands/workflow.ts b/src/control/commands/workflow.ts index b4dbf96959..9d8d1b727d 100644 --- a/src/control/commands/workflow.ts +++ b/src/control/commands/workflow.ts @@ -65,6 +65,7 @@ interface WorkflowOptions { frame?: string; background?: boolean; allowOutside?: boolean; + allowForeground?: boolean; prepare?: boolean; replace?: boolean; targetKey?: string; @@ -517,6 +518,10 @@ export function registerWorkflowCommands(program: Command): void { "--allow-outside", "drag: the path and --to may leave the snapshot window, and the window may move with the drag (a panel dragged by its body). Only the start is checked against the snapshot; each later step checks that the window is still on screen and that you have not taken the pointer. The result reports windowBoundsAfter." ) + .option( + "--allow-foreground", + "under an agent, take focus or the pointer even while the user is working (a visible demonstration the user asked for); without it such a call is refused while the user typed, clicked or moved the pointer in the last 5 s (GENESIS_CONTROL_FOREGROUND_IDLE)" + ) .option("--duration <seconds>", "drag: duration from 0.1 to 5 seconds") .option("--pages <n>", "scroll: 1–20 receiving AX scroll-area viewport pages; if unavailable use --pixels") .option("--pixels <n>", "scroll: exact synthetic wheel pixels from 1 to 10000; mutually exclusive with --pages") @@ -688,6 +693,10 @@ export function registerWorkflowCommands(program: Command): void { args.push("--allow-outside"); } + if (opts.allowForeground) { + args.push("--allow-foreground"); + } + if (opts.refresh) { args.push("--refresh"); } diff --git a/src/control/lib/capture-plan.ts b/src/control/lib/capture-plan.ts index 93a383f52d..ae01faae79 100644 --- a/src/control/lib/capture-plan.ts +++ b/src/control/lib/capture-plan.ts @@ -452,6 +452,10 @@ export interface CaptureSpec { idleFps?: number; threshold?: number; videoOut?: string; + /** Most PNG frames the recorder writes beside the movie (1–5000, recorder default 800). */ + maxFrames?: number; + /** CG window ids the movie never shows, such as a host's own recording controls (native backend). */ + excludeWindowIds?: number[]; countdownSec?: number; noRemote?: boolean; captureEngine?: "cg" | "sc"; diff --git a/src/control/lib/capture-runner.ts b/src/control/lib/capture-runner.ts index 567206b411..e30c0d11db 100644 --- a/src/control/lib/capture-runner.ts +++ b/src/control/lib/capture-runner.ts @@ -58,7 +58,7 @@ import { type WindowBounds, windowShotArgv, } from "./peekaboo"; -import { ensureBinary } from "./runner"; +import { ensureBinaryAsync } from "./runner"; import { publishVitrinka } from "./vitrinka-publish"; /** Operational failure of a capture-family command; exitCode preserves the legacy script's codes. */ @@ -263,15 +263,25 @@ export function vitrinkaRefusal(input: { return undefined; } -/** `signal` is the run's cancellation; `stopGraceMs` exists for tests of the SIGINT-then-kill escalation. */ +/** + * `signal` is the run's cancellation; `stopGraceMs` exists for tests of the SIGINT-then-kill escalation. + * `onRecording` runs once the recorder wrote its first frame, which is when the movie's clock starts: arming (a + * private recorder copy, ScreenCaptureKit's enumeration, one stream per window) takes seconds, and a host that shows + * "recording" before it would count time that is not in the movie. + */ export async function runCapturePlanWithSignal({ plan, signal, stopGraceMs = RECORDER_STOP_GRACE_MS, + onRecording, + onPreparing, }: { plan: Plan; signal: AbortSignal; stopGraceMs?: number; + onRecording?: () => void; + /** The native recorder has to compile first (after a source change), which takes up to a minute. */ + onPreparing?: () => void; }): Promise<RunResult> { const throwIfCancelled = (stage: string) => { if (signal.aborted) { @@ -309,7 +319,12 @@ export async function runCapturePlanWithSignal({ let axTool = AX_TOOL_PATH; if (backend === "native") { validateNativeCapturePlan(plan); - axTool = ensureBinary(); + try { + axTool = await ensureBinaryAsync({ signal, onBuild: onPreparing }); + } catch (error) { + throwIfCancelled("while the recorder was being built"); + throw error; + } } using nativeControls = backend === "native" ? new NativeCaptureControls(cap) : undefined; @@ -399,6 +414,9 @@ export async function runCapturePlanWithSignal({ } // non-null: the retry block above throws when no attempt produced a dir const sessionDir = attempt.sessionDir!; + if (attempt.sessionDir && !signal.aborted) { + onRecording?.(); + } const t0 = Date.now(); const fired: FiredAction[] = []; diff --git a/src/control/lib/computer-use/schemas.ts b/src/control/lib/computer-use/schemas.ts index a9f32fe95b..13e3c4598a 100644 --- a/src/control/lib/computer-use/schemas.ts +++ b/src/control/lib/computer-use/schemas.ts @@ -20,10 +20,23 @@ const common = { timeout_ms: z.number().int().min(1).max(120000).optional(), note: noteSchema, }; +/** + * The window a call means, as get_app_state takes it. Refused unless the app's latest observation is of that + * window, so a call never acts on another window's references or pixels. + */ +const windowIdSchema = z.number().int().positive().optional(); +/** + * While the user works (a key, click, move or scroll in the last few seconds), an agent's call refuses to bring + * the app to the front or move the pointer. true takes focus anyway: only for a visible demonstration the user + * asked for. + */ +const allowForegroundSchema = z.boolean().default(false); const target = { ...common, element_index: z.number().int().nonnegative().optional(), element_ref: z.string().min(1).max(100).optional(), + window_id: windowIdSchema, + allow_foreground: allowForegroundSchema, }; const preparedTarget = { ...target, prepare: z.boolean().default(false) }; const point = { @@ -151,7 +164,9 @@ export const computerSchemas = { .object({ bundle_id: appSchema.optional(), path: z.string().min(1).max(4096).optional(), + /** false: launch or find the app without bringing it to the front. */ activate: z.boolean().default(true), + allow_foreground: allowForegroundSchema, timeout_ms: z.number().int().min(1).max(120000).optional(), }) .strict() @@ -192,6 +207,8 @@ export const computerSchemas = { coordinate_space: z.enum(["image", "screen"]).default("image"), duration: z.number().min(0.1).max(5).default(0.3), background: z.boolean().default(true), + window_id: windowIdSchema, + allow_foreground: allowForegroundSchema, /** The destination may lie outside the observed window, and the window may move with the drag. */ allow_outside: z.boolean().default(false), }) @@ -261,6 +278,7 @@ export const computerSchemas = { query: z.string().min(1).max(300), role: z.string().max(100).optional(), limit: z.number().int().min(1).max(100).default(30), + window_id: windowIdSchema, }) .strict(), resolve_visual_target: z diff --git a/src/control/lib/computer-use/session.test.ts b/src/control/lib/computer-use/session.test.ts index fc7931b57c..1b82d0b696 100644 --- a/src/control/lib/computer-use/session.test.ts +++ b/src/control/lib/computer-use/session.test.ts @@ -1920,3 +1920,53 @@ test("menu-bar-only observations explicitly select the native bar surface", asyn await session.observe({ app: "Fixture", top_menu: "File", limit: 40 }); expect(calls[2]).toEqual(["menu-see", "--app", "Fixture", "--menu", "File"]); }); + +test("a call that names a window acts only on that window's observation, and allow_foreground reaches ax-tool", async () => { + const f = fixture(); + const state = await f.computer.get_app_state({ app: "Fixture", image: false }); + const ref = state.elements[1].ref; + const sent = f.calls.length; + await expect(f.computer.click({ app: "Fixture", element_ref: ref, window_id: 11 })).rejects.toThrow( + "get_app_state({app, window_id: 11})" + ); + expect(() => f.computer.find({ app: "Fixture", query: "Fixture", window_id: 11 })).toThrow("not 11"); + expect(f.calls).toHaveLength(sent); + const result = await f.computer.click({ + app: "Fixture", + element_ref: ref, + window_id: 10, + prepare: true, + allow_foreground: true, + }); + expect(result.ok).toBe(true); + expect(f.calls.at(-1)).toContain("--allow-foreground"); + const plain = await f.computer.get_app_state({ app: "Fixture", image: false }); + await f.computer.click({ app: "Fixture", element_ref: plain.elements[1].ref, prepare: true }); + expect(f.calls.at(-1)).not.toContain("--allow-foreground"); +}); + +test("launch_app passes allow_foreground to ax-tool only when asked", async () => { + const f = fixture(); + f.native.run = async (call) => { + f.calls.push(call.args); + return { ok: true, requestAccepted: true }; + }; + await f.computer.launch_app({ bundle_id: "example.fixture" }); + await f.computer.launch_app({ bundle_id: "example.fixture", allow_foreground: true }); + expect(f.calls).toEqual([ + ["launch-app", "--bundle-id", "example.fixture"], + ["launch-app", "--bundle-id", "example.fixture", "--allow-foreground"], + ]); +}); + +test("a background click into a window that is not key carries the native effect note to the caller", async () => { + const f = fixture(); + const state = await f.computer.get_app_state({ app: "Fixture", image: false }); + const run = f.native.run; + f.native.run = async (call) => { + const result = await run(call); + return call.args[0] === "act" ? { ...result, inactiveWindowClick: true, effectNote: "not key" } : result; + }; + const result = await f.computer.click({ app: "Fixture", element_ref: state.elements[1].ref }); + expect(result.effectNote).toBe("not key"); +}); diff --git a/src/control/lib/computer-use/session.ts b/src/control/lib/computer-use/session.ts index 9c1f55e672..baf50987a5 100644 --- a/src/control/lib/computer-use/session.ts +++ b/src/control/lib/computer-use/session.ts @@ -134,6 +134,18 @@ export interface ComputerState { } | null; observedAt: string; } +/** A call that names a window must act on that window's observation, never on another window's refs or pixels. */ +function assertObservedWindow({ record, app, windowId }: { record: AppRecord; app: string; windowId?: number }) { + if (windowId === undefined || record.snapshot.window.id === windowId) { + return; + } + + throw new ComputerUseError( + "WRONG_WINDOW", + `The latest observation of ${app} is window ${record.snapshot.window.id}, not ${windowId}. Observe it first: get_app_state({app, window_id: ${windowId}}).` + ); +} + export class ComputerUseError extends Error { constructor( readonly code: string, @@ -937,6 +949,7 @@ export class ComputerUse { "launch-app", ...(options.bundle_id ? ["--bundle-id", options.bundle_id] : ["--path", options.path!]), ...(!options.activate ? ["--background"] : []), + ...(options.allow_foreground ? ["--allow-foreground"] : []), ], timeoutMs: options.timeout_ms ?? 12000, signal, @@ -1064,6 +1077,8 @@ export class ComputerUse { revision?: string; timeout_ms?: number; note?: string; + window_id?: number; + allow_foreground?: boolean; }; build: (record: AppRecord) => string[]; signal?: AbortSignal; @@ -1073,6 +1088,7 @@ export class ComputerUse { app: input.app, revision: input.revision, }); + assertObservedWindow({ record, app: input.app, windowId: input.window_id }); const argv = build(record); this.menus.close(input.app); const args = [ @@ -1082,6 +1098,7 @@ export class ComputerUse { "--snapshot", record.snapshot.snapshot, ...argv, + ...(input.allow_foreground ? ["--allow-foreground"] : []), "--refresh", ...(!record.image ? ["--no-image"] : []), ]; @@ -1167,6 +1184,9 @@ export class ComputerUse { clipboardRestore: result.clipboardRestore, recovery: result.recovery, note: typeof result.note === "string" ? result.note : undefined, + // A window-addressed click into a window that is not key: delivered, but a SwiftUI control may + // have taken it as the activation click. The caller checks the postcondition. + effectNote: typeof result.effectNote === "string" ? result.effectNote : undefined, windowBoundsAfter: windowBoundsSchema.safeParse(result.windowBoundsAfter).data, }; }); @@ -1423,6 +1443,8 @@ export class ComputerUse { revision?: string; timeout_ms?: number; prepare?: boolean; + window_id?: number; + allow_foreground?: boolean; }; action: string; flags: string[]; @@ -1612,6 +1634,7 @@ export class ComputerUse { const record = this.record({ app: options.app, }); + assertObservedWindow({ record, app: options.app, windowId: options.window_id }); const query = options.query.toLocaleLowerCase(); const elements: ComputerElement[] = []; let total = 0; diff --git a/src/control/lib/decision/native.test.ts b/src/control/lib/decision/native.test.ts index ef597a6c07..aa6e6c9434 100644 --- a/src/control/lib/decision/native.test.ts +++ b/src/control/lib/decision/native.test.ts @@ -216,6 +216,12 @@ describe("OCR reuse", () => { }); }); +describe("user active", () => { + test("is its own refusal, so recovery never retries the call in front", () => { + expect(actionRefusal({ ok: false, dispatchState: "not_started", refusal: "user_active" })).toBe("user_active"); + }); +}); + describe("user takeover", () => { test("is its own refusal whatever the dispatch state, and recovery stops before any read or model call", async () => { expect(actionRefusal({ ok: false, dispatchState: "uncertain", refusal: "user_takeover" })).toBe( diff --git a/src/control/lib/decision/recovery.ts b/src/control/lib/decision/recovery.ts index 672de9224c..ff5ec97a6c 100644 --- a/src/control/lib/decision/recovery.ts +++ b/src/control/lib/decision/recovery.ts @@ -20,6 +20,8 @@ export const refusalSchema = z.enum([ "refused", /** The user put the pointer in the takeover corner; ax-tool stopped and released every held input. */ "user_takeover", + /** The user is at the keyboard or mouse, so an agent's call refused to take focus or the pointer. */ + "user_active", ]); export type Refusal = z.infer<typeof refusalSchema>; export function actionRefusal(result: AxResult): Refusal { @@ -29,6 +31,10 @@ export function actionRefusal(result: AxResult): Refusal { return "user_takeover"; } + if (result.refusal === "user_active") { + return "user_active"; + } + if (result.dispatchState !== "not_started") { return "transport_uncertainty"; } @@ -155,6 +161,12 @@ export class RecoveryController { return null; } + if (category === "user_active") { + attempt.reason = + "The user is working, so taking focus was refused; act in the background instead of retrying in front."; + return null; + } + if (["permission", "authentication", "scope_changed", "transport_uncertainty", "refused"].includes(category)) { attempt.reason = "This refusal requires inspection or user input; no automatic recovery."; return null; diff --git a/src/control/lib/native-record.test.ts b/src/control/lib/native-record.test.ts index 897b8e2606..1831886ba6 100644 --- a/src/control/lib/native-record.test.ts +++ b/src/control/lib/native-record.test.ts @@ -104,6 +104,25 @@ describe("nativeCaptureArgv", () => { "/tmp/v.mp4", ]); }); + + it("passes the PNG frame cap and the windows a movie must never show", () => { + const argv = nativeCaptureArgv( + { + mode: "screen", + screenIndex: 0, + duration: 5, + maxFrames: 1, + excludeWindowIds: [71, 72], + videoOut: "/v.mp4", + }, + "/o" + ); + expect(argv.slice(-4)).toEqual(["--max-frames", "1", "--exclude-window-ids", "71,72"]); + expect(() => validateCaptureOptions({ mode: "screen", duration: 1, excludeWindowIds: [0] })).toThrow( + /excludeWindowIds/ + ); + expect(() => validateCaptureOptions({ mode: "screen", duration: 1, maxFrames: 0 })).toThrow(/maxFrames/); + }); }); describe("parseScreenList", () => { diff --git a/src/control/lib/native-record.ts b/src/control/lib/native-record.ts index 343ea56e22..fb528ce575 100644 --- a/src/control/lib/native-record.ts +++ b/src/control/lib/native-record.ts @@ -98,6 +98,14 @@ export function nativeCaptureArgv(cap: CaptureSpec, outDir: string): string[] { argv.push("--video-out", cap.videoOut); } + if (cap.maxFrames !== undefined) { + argv.push("--max-frames", String(cap.maxFrames)); + } + + if (cap.excludeWindowIds?.length) { + argv.push("--exclude-window-ids", cap.excludeWindowIds.join(",")); + } + return argv; } @@ -112,6 +120,7 @@ export function validateCaptureOptions(cap: CaptureSpec): void { ["activeFps", cap.activeFps, 0.5, 30], ["idleFps", cap.idleFps, 0.1, 5], ["threshold", cap.threshold, 0, 100], + ["maxFrames", cap.maxFrames, 1, 5000], ]; for (const [name, value, min, max] of numbers) { if (value !== undefined && (!Number.isFinite(value) || value < min || value > max)) { @@ -132,6 +141,8 @@ export function validateCaptureOptions(cap: CaptureSpec): void { cap.transparent, cap.codec, cap.indicator, + cap.maxFrames, + cap.excludeWindowIds, ]; if (cap.backend === "peekaboo" && (cap.mode === "isolated" || extended.some((value) => value !== undefined))) { throw new Error( @@ -165,6 +176,14 @@ export function validateCaptureOptions(cap: CaptureSpec): void { throw new Error("windowIds must contain positive CG window IDs"); } + if ( + cap.excludeWindowIds !== undefined && + (!Array.isArray(cap.excludeWindowIds) || + cap.excludeWindowIds.some((id) => !Number.isInteger(id) || id <= 0 || id > 0xffffffff)) + ) { + throw new Error("excludeWindowIds must contain positive CG window IDs"); + } + if ( cap.apps !== undefined && (!Array.isArray(cap.apps) || cap.apps.some((app) => typeof app !== "string" || !app.trim())) diff --git a/src/control/lib/recording-native.ts b/src/control/lib/recording-native.ts new file mode 100644 index 0000000000..d7cff1c8dc --- /dev/null +++ b/src/control/lib/recording-native.ts @@ -0,0 +1,169 @@ +import { existsSync } from "node:fs"; +import { rm, unlink } from "node:fs/promises"; +import { logger } from "@genesiscz/utils/logger"; +import { probeVideo } from "@genesiscz/utils/video/probe"; +import { runVideoCommand } from "@genesiscz/utils/video/process"; +import type { CaptureSpec } from "./capture-plan"; +import { CaptureRunError, runCapturePlanWithSignal } from "./capture-runner"; +import { captureFromFlags, validateCaptureOptions } from "./native-record"; +import { joinSegmentsArgv, type SegmentJoiner, type SegmentRecorder, type SegmentResult } from "./recording-session"; +import type { RecordingTarget } from "./recording-targets"; + +const { log } = logger.scoped("control-recording-native"); + +/** The `[screen-recording-denied]` code GenesisKit's `WidgetCaptureError` already recognizes. */ +export const SCREEN_RECORDING_DENIED = "screen-recording-denied"; + +/** + * Motion threshold for the recorder's frame-rate policy. A session keeps one PNG (`maxFrames: 1`), so the threshold + * only decides when the stream drops to the idle rate; a low one keeps typing and small edits at the active rate. + */ +const SESSION_MOTION_THRESHOLD = 0.5; + +export interface NativeRecorderOptions { + /** CG window ids no movie may show: the host's own recording controls. */ + excludeWindowIds?: number[]; + /** The recorder's frame rate while something moves (0.5–30). */ + fps?: number; + /** False hides the red border around what is recorded (it never shows in the movie either way). */ + indicator?: boolean; +} + +/** The capture spec one segment records: isolated windows or an app's windows, or a whole display. */ +export function segmentCaptureSpec({ + target, + output, + maxSeconds, + options, +}: { + target: RecordingTarget; + output: string; + maxSeconds: number; + options: NativeRecorderOptions; +}): CaptureSpec { + const duration = String(Math.max(0.1, Math.min(180, maxSeconds))); + const common = { + codec: "h264", + videoOut: output, + duration, + activeFps: options.fps === undefined ? undefined : String(options.fps), + threshold: String(SESSION_MOTION_THRESHOLD), + } as const; + let spec: CaptureSpec; + if (target.kind === "display") { + spec = { + mode: "screen", + backend: "native", + screenIndex: target.screenIndex, + duration: Number(duration), + codec: "h264", + videoOut: output, + activeFps: options.fps, + threshold: SESSION_MOTION_THRESHOLD, + }; + } else if (target.kind === "window") { + spec = captureFromFlags({ ...common, windowIds: target.windowIds.join(",") }); + } else { + spec = captureFromFlags({ ...common, includeApp: [target.app] }); + } + + spec.maxFrames = 1; + if (options.indicator === false) { + spec.indicator = false; + } + + if (options.excludeWindowIds?.length) { + spec.excludeWindowIds = options.excludeWindowIds; + } + + validateCaptureOptions(spec); + return spec; +} + +/** A failure text that names a missing Screen Recording grant carries the code a host handles itself. */ +export function refusalCode(message: string): string | undefined { + return /Screen Recording is not granted/i.test(message) ? SCREEN_RECORDING_DENIED : undefined; +} + +/** One `tools control capture record` run per segment, on the ScreenCaptureKit recorder. */ +export function nativeSegmentRecorder(options: NativeRecorderOptions = {}): SegmentRecorder { + return { + async record({ target, output, maxSeconds, signal, onRecording, onPreparing }): Promise<SegmentResult> { + const capture = segmentCaptureSpec({ target, output, maxSeconds, options }); + let sessionDir: string | undefined; + try { + const result = await runCapturePlanWithSignal({ + plan: { capture, actions: [] }, + signal, + onRecording, + onPreparing, + }); + sessionDir = result.sessionDir; + const envelope = result.capture as { + error?: unknown; + data?: { stats?: { durationMs?: unknown } }; + } | null; + const reported = envelope?.data?.stats?.durationMs; + const durationMs = typeof reported === "number" && reported > 0 ? reported : undefined; + const endedBy = signal.aborted ? "stop" : result.captureFailed ? "failure" : "deadline"; + const error = + typeof envelope?.error === "string" ? envelope.error : result.warnings.at(-1) || undefined; + const ok = existsSync(output) && (endedBy !== "failure" || (await playable(output))); + if (endedBy !== "failure") { + return { ok, endedBy, durationMs }; + } + + const code = error ? refusalCode(error) : undefined; + return { ok, endedBy, durationMs, error: code ? `[${code}] ${error}` : error, code }; + } catch (error) { + const message = + error instanceof CaptureRunError || error instanceof Error ? error.message : String(error); + const code = refusalCode(message); + return { + ok: false, + endedBy: signal.aborted ? "stop" : "failure", + error: code ? `[${code}] ${message}` : message, + code, + }; + } finally { + // The recorder writes its PNG frames and metadata into a session folder of its own; the movie is + // the product, so the folder goes with the run. + if (sessionDir) { + await rm(sessionDir, { recursive: true, force: true }); + } + } + }, + }; +} + +/** A movie a failed run left behind is kept only when ffprobe can read it. */ +async function playable(path: string): Promise<boolean> { + try { + await probeVideo({ input: path }); + return true; + } catch (error) { + log.warn({ error, path }, "a failed recording left an unreadable movie; dropping it"); + try { + await unlink(path); + } catch (cleanupError) { + log.debug({ error: cleanupError, path }, "unreadable movie already gone"); + } + + return false; + } +} + +/** ffmpeg concat of the segments at the first segment's size; one H.264 MP4. */ +export const ffmpegSegmentJoiner: SegmentJoiner = async ({ segments, output }) => { + const first = await probeVideo({ input: segments[0].path }); + const argv = joinSegmentsArgv({ + inputs: segments.map((segment) => segment.path), + width: first.displayWidth, + height: first.displayHeight, + output, + }); + const totalMs = segments.reduce((sum, segment) => sum + segment.durationMs, 0); + await runVideoCommand({ command: argv, timeoutMs: Math.max(120_000, totalMs * 2) }); + const joined = await probeVideo({ input: output }); + return { durationMs: Math.round(joined.durationUs / 1000) }; +}; diff --git a/src/control/lib/recording-session.test.ts b/src/control/lib/recording-session.test.ts new file mode 100644 index 0000000000..40d3045fff --- /dev/null +++ b/src/control/lib/recording-session.test.ts @@ -0,0 +1,439 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + FIRST_FRAME_MESSAGE, + type FinishedSegment, + joinSegmentsArgv, + PREPARING_MESSAGE, + parseRecordingCommand, + RecordingSession, + type RecordingSessionEvent, + type SegmentRecorder, + type SegmentResult, +} from "./recording-session"; +import { + defaultRecordingTarget, + groupRecordingApps, + parseRecordingWindows, + type RecordingTarget, +} from "./recording-targets"; + +const finder: RecordingTarget = { kind: "app", app: "4242", name: "Finder" }; +const display: RecordingTarget = { kind: "display", screenIndex: 0, name: "Built-in" }; +const editor: RecordingTarget = { kind: "window", windowIds: [77], app: "Editor", title: "notes.txt" }; + +interface FakeRun { + target: RecordingTarget; + output: string; + maxSeconds: number; + signal: AbortSignal; + /** Ends the run by itself, as the recorder's deadline or a failure would. */ + end: (result: SegmentResult, writeMovie?: boolean) => Promise<void>; + /** The first frame arrives (runs arm at once unless the fake was made with `armLater`). */ + arm: () => void; +} + +/** + * A recorder that writes a small file per run and finishes it when the session aborts the run. A run aborted before + * it armed writes no movie, as the real recorder cancelled while arming does. + */ +function fakeRecorder(options: { failFirst?: boolean; armLater?: boolean; prepares?: boolean } = {}) { + const runs: FakeRun[] = []; + const recorder: SegmentRecorder = { + record({ target, output, maxSeconds, signal, onRecording, onPreparing }) { + if (options.prepares) { + onPreparing?.(); + } + + return new Promise<SegmentResult>((resolve) => { + const index = runs.length; + let armed = false; + const end = async (result: SegmentResult, writeMovie = true) => { + if (writeMovie) { + await writeFile(output, `movie ${index} ${kindOf(target)}`); + } + + resolve(result); + }; + const arm = () => { + armed = true; + onRecording(); + }; + runs.push({ target, output, maxSeconds, signal, end, arm }); + if (options.failFirst && index === 0) { + void end({ ok: false, endedBy: "failure", error: "[screen-recording-denied] no grant" }, false); + return; + } + + signal.addEventListener( + "abort", + () => + void (armed + ? end({ ok: true, endedBy: "stop", durationMs: 1000 }) + : end({ ok: false, endedBy: "stop", error: "cancelled while arming" }, false)), + { once: true } + ); + if (!options.armLater) { + arm(); + } + }); + }, + }; + return { recorder, runs }; +} + +function kindOf(target: RecordingTarget): string { + return target.kind; +} + +describe("RecordingSession", () => { + let root: string; + let clock: number; + let events: RecordingSessionEvent[]; + let joined: FinishedSegment[][]; + + beforeEach(async () => { + root = join(tmpdir(), `recording-session-${randomUUID()}`); + await mkdir(root, { recursive: true }); + clock = 1_000_000; + events = []; + joined = []; + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + function session( + recorder: SegmentRecorder, + extra: { maxSeconds?: number; segmentSeconds?: number; slowStartMs?: number } = {} + ) { + return new RecordingSession({ + target: finder, + directory: join(root, "segments"), + output: join(root, "out.mp4"), + recorder, + join: async ({ segments, output }) => { + joined.push(segments); + await writeFile(output, segments.map((segment) => segment.path).join("\n")); + return { durationMs: segments.reduce((sum, segment) => sum + segment.durationMs, 0) }; + }, + emit: (event) => events.push(event), + now: () => clock, + tickMs: 0, + slowStartMs: 0, + ...extra, + }); + } + + /** The state changes past `starting`, which every run passes through while it arms. */ + const states = () => + events.flatMap((event) => + event.event === "state" && event.state !== "starting" + ? [`${event.state}:${event.reason}:${event.target.kind}`] + : [] + ); + + it("arming is not recording: no time counts until the first frame, and a pause then keeps nothing", async () => { + const { recorder, runs } = fakeRecorder({ armLater: true }); + const recording = session(recorder); + await recording.start(); + expect(events.at(-1)).toMatchObject({ event: "state", state: "starting", elapsedMs: 0 }); + clock += 2500; + expect(recording.elapsedMs()).toBe(0); + runs[0].arm(); + expect(events.at(-1)).toMatchObject({ event: "state", state: "recording", reason: "user" }); + clock += 1000; + expect(recording.elapsedMs()).toBe(1000); + await recording.command({ cmd: "pause" }); + await recording.command({ cmd: "resume" }); + clock += 3000; + await recording.command({ cmd: "pause" }); + expect(existsSync(runs[1].output)).toBe(false); + expect(recording.elapsedMs()).toBe(1000); + await recording.command({ cmd: "stop" }); + expect(await recording.finished).toBe("done"); + expect(joined).toHaveLength(0); + expect(events.find((event) => event.event === "done")).toMatchObject({ segments: 1 }); + }); + + it("pause, resume, switch and stop join every segment in order, each with its own target", async () => { + const { recorder, runs } = fakeRecorder(); + const recording = session(recorder); + await recording.start(); + clock += 4000; + await recording.command({ cmd: "pause" }); + clock += 60_000; + await recording.command({ cmd: "resume" }); + clock += 3000; + await recording.command({ cmd: "switch", target: display }); + clock += 2000; + await recording.command({ cmd: "stop" }); + + expect(await recording.finished).toBe("done"); + expect(runs.map((run) => run.target.kind)).toEqual(["app", "app", "display"]); + expect(states()).toEqual([ + "recording:user:app", + "paused:user:app", + "recording:user:app", + "recording:user:display", + "finishing:user:display", + ]); + expect(joined).toHaveLength(1); + expect(joined[0].map((segment) => segment.index)).toEqual([1, 2, 3]); + expect(joined[0].map((segment) => segment.target.kind)).toEqual(["app", "app", "display"]); + const done = events.find((event) => event.event === "done"); + expect(done).toEqual({ event: "done", path: join(root, "out.mp4"), durationMs: 3000, segments: 3 }); + expect(existsSync(join(root, "segments"))).toBe(false); + // The paused minute is not recording time. + const finishing = events.findLast((event) => event.event === "state"); + expect(finishing?.event === "state" && finishing.elapsedMs).toBe(9000); + }); + + it("one segment is moved into place without a join", async () => { + const { recorder } = fakeRecorder(); + const recording = session(recorder); + await recording.start(); + clock += 1500; + await recording.command({ cmd: "stop" }); + expect(await recording.finished).toBe("done"); + expect(joined).toHaveLength(0); + expect(await readFile(join(root, "out.mp4"), "utf8")).toBe("movie 0 app"); + }); + + it("restart drops every earlier segment and the clock", async () => { + const { recorder, runs } = fakeRecorder(); + const recording = session(recorder); + await recording.start(); + clock += 5000; + await recording.command({ cmd: "pause" }); + await recording.command({ cmd: "resume" }); + clock += 5000; + await recording.command({ cmd: "restart" }); + expect(existsSync(runs[0].output)).toBe(false); + expect(existsSync(runs[1].output)).toBe(false); + expect(recording.elapsedMs()).toBe(0); + clock += 2000; + await recording.command({ cmd: "pause" }); + await recording.command({ cmd: "resume" }); + clock += 1000; + await recording.command({ cmd: "stop" }); + expect(await recording.finished).toBe("done"); + expect(joined[0].map((segment) => segment.index)).toEqual([3, 4]); + }); + + it("discard deletes every segment and writes no movie", async () => { + const { recorder, runs } = fakeRecorder(); + const recording = session(recorder); + await recording.start(); + await recording.command({ cmd: "pause" }); + await recording.command({ cmd: "switch", target: editor }); + await recording.command({ cmd: "discard" }); + expect(await recording.finished).toBe("discarded"); + expect(events.at(-1)).toEqual({ event: "discarded" }); + expect(runs.every((run) => !existsSync(run.output))).toBe(true); + expect(existsSync(join(root, "out.mp4"))).toBe(false); + expect(existsSync(join(root, "segments"))).toBe(false); + }); + + it("a run that reaches its deadline is followed by the next, and the limit pauses the session", async () => { + const { recorder, runs } = fakeRecorder(); + const recording = session(recorder, { maxSeconds: 5, segmentSeconds: 3 }); + await recording.start(); + expect(runs[0].maxSeconds).toBe(3); + clock += 3000; + await runs[0].end({ ok: true, endedBy: "deadline", durationMs: 3000 }); + await Bun.sleep(0); + await recording.command({ cmd: "resume" }); + expect(runs).toHaveLength(2); + expect(runs[1].target).toEqual(finder); + expect(runs[1].maxSeconds).toBe(2); + clock += 2000; + await runs[1].end({ ok: true, endedBy: "deadline", durationMs: 2000 }); + await Bun.sleep(0); + await recording.command({ cmd: "resume" }); + expect(runs).toHaveLength(2); + expect(states()).toEqual([ + "recording:user:app", + "recording:recorder:app", + "paused:limit:app", + "paused:limit:app", + ]); + const refusal = events.findLast((event) => event.event === "error"); + expect(refusal).toMatchObject({ code: "limit", fatal: false }); + await recording.command({ cmd: "stop" }); + expect(await recording.finished).toBe("done"); + expect(joined[0].map((segment) => segment.durationMs)).toEqual([3000, 2000]); + }); + + it("a first run that fails without a movie ends the session with the refusal code", async () => { + const { recorder } = fakeRecorder({ failFirst: true }); + const recording = session(recorder); + await recording.start(); + expect(await recording.finished).toBe("failed"); + expect(events.at(-1)).toMatchObject({ event: "error", fatal: true }); + expect(events.at(-1)?.event === "error" && events.at(-1)).toMatchObject({ + message: expect.stringContaining("[screen-recording-denied]"), + }); + await recording.command({ cmd: "stop" }); + expect(events.filter((event) => event.event === "state" && event.state === "finishing")).toHaveLength(0); + }); + + it("a later run that fails pauses the session and keeps what was recorded", async () => { + const { recorder, runs } = fakeRecorder(); + const recording = session(recorder); + await recording.start(); + clock += 2000; + await recording.command({ cmd: "switch", target: editor }); + await runs[1].end({ ok: false, endedBy: "failure", error: "window 77 is not visible" }, false); + await Bun.sleep(0); + await recording.command({ cmd: "pause" }); + expect(states().at(-1)).toBe("paused:recorder:window"); + expect(events.findLast((event) => event.event === "error")).toMatchObject({ + message: "window 77 is not visible", + fatal: false, + }); + await recording.command({ cmd: "stop" }); + expect(await recording.finished).toBe("done"); + expect(await readFile(join(root, "out.mp4"), "utf8")).toBe("movie 0 app"); + }); + it("a stop before the first frame keeps no movie, even when the recorder left an empty file", async () => { + const recorder: SegmentRecorder = { + record: ({ output, signal }) => + new Promise<SegmentResult>((resolve) => { + signal.addEventListener( + "abort", + () => void writeFile(output, "").then(() => resolve({ ok: true, endedBy: "stop" })), + { once: true } + ); + }), + }; + const recording = session(recorder); + await recording.start(); + await recording.command({ cmd: "stop" }); + expect(await recording.finished).toBe("failed"); + expect(events.some((event) => event.event === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ event: "error", fatal: true }); + expect(existsSync(join(root, "out.mp4"))).toBe(false); + }); + + it("says it still waits for the first frame when no compile explains a slow start", async () => { + const { recorder } = fakeRecorder({ armLater: true }); + const recording = session(recorder, { slowStartMs: 20 }); + await recording.start(); + await Bun.sleep(60); + expect(events.filter((event) => event.event === "preparing")).toEqual([ + { event: "preparing", message: FIRST_FRAME_MESSAGE }, + ]); + await recording.command({ cmd: "discard" }); + + const prepared = fakeRecorder({ armLater: true, prepares: true }); + events = []; + const compiling = session(prepared.recorder, { slowStartMs: 20 }); + await compiling.start(); + await Bun.sleep(60); + expect(events.filter((event) => event.event === "preparing").map((event) => event.message)).toEqual([ + PREPARING_MESSAGE, + ]); + await compiling.command({ cmd: "discard" }); + }); + + it("says why a start is slow, and a discard while the recorder prepares ends the session at once", async () => { + const { recorder } = fakeRecorder({ prepares: true, armLater: true }); + const recording = session(recorder); + await recording.start(); + expect(events.filter((event) => event.event === "preparing")).toEqual([ + { event: "preparing", message: PREPARING_MESSAGE }, + ]); + + await recording.command({ cmd: "discard" }); + expect(await recording.finished).toBe("discarded"); + expect(existsSync(join(root, "out.mp4"))).toBe(false); + }); +}); + +describe("joinSegmentsArgv", () => { + it("scales and pads every input to the first segment's even size and concatenates them", () => { + const argv = joinSegmentsArgv({ inputs: ["/a.mp4", "/b.mp4"], width: 1281, height: 721, output: "/o.mp4" }); + expect(argv.slice(0, 6)).toEqual(["ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-y"]); + expect(argv).toContain("-nostdin"); + expect(argv.filter((value) => value === "-i")).toHaveLength(2); + const graph = argv[argv.indexOf("-filter_complex") + 1]; + expect(graph).toBe( + "[0:v]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p,settb=AVTB[v0];" + + "[1:v]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p,settb=AVTB[v1];" + + "[v0][v1]concat=n=2:v=1:a=0[out]" + ); + expect(argv.slice(-9)).toEqual([ + "-crf", + "20", + "-pix_fmt", + "yuv420p", + "-fps_mode", + "vfr", + "-movflags", + "+faststart", + "/o.mp4", + ]); + }); +}); + +describe("recording commands and targets", () => { + it("reads one command per line and names a line it cannot use", () => { + expect(parseRecordingCommand('{"cmd":"pause"}')).toEqual({ cmd: "pause" }); + expect(parseRecordingCommand('{"cmd":"switch","target":{"kind":"display","screenIndex":1}}')).toEqual({ + cmd: "switch", + target: { kind: "display", screenIndex: 1 }, + }); + expect(parseRecordingCommand("pause")).toMatchObject({ error: expect.stringContaining("Not a JSON") }); + expect(parseRecordingCommand('{"cmd":"switch","target":{"kind":"window","windowIds":[]}}')).toMatchObject({ + error: expect.stringContaining("Unknown command"), + }); + }); + + it("lists windows without system chrome or slivers, groups apps and picks the frontmost app's window", () => { + const raw = { + ok: true, + frontmostPid: 20, + windows: [ + { pid: 1, app: "Dock", windowId: 5, bounds: { x: 0, y: 0, width: 1000, height: 80 } }, + { pid: 9, app: "Terminal", windowId: 6, title: "zsh", bounds: { x: 0, y: 0, width: 800, height: 500 } }, + { + pid: 20, + app: "Editor", + windowId: 7, + title: "a.txt", + bounds: { x: 10, y: 10, width: 600, height: 400 }, + }, + { pid: 20, app: "Editor", windowId: 8, bounds: { x: 0, y: 0, width: 30, height: 30 } }, + { + pid: 20, + app: "Editor", + windowId: 9, + title: "b.txt", + bounds: { x: 50, y: 50, width: 600, height: 400 }, + }, + { pid: 30, app: "Viewer", title: "missing id", bounds: { x: 0, y: 0, width: 600, height: 400 } }, + ], + }; + const windows = parseRecordingWindows(raw); + expect(windows.map((window) => window.id)).toEqual([6, 7, 9]); + expect(groupRecordingApps(windows)).toEqual([ + { pid: 9, name: "Terminal", windowCount: 1 }, + { pid: 20, name: "Editor", windowCount: 2 }, + ]); + const targets = { windows, apps: groupRecordingApps(windows), displays: [], frontmostPid: 9 }; + // The frontmost app is this command's own terminal, so the pick falls through to the next app. + expect(defaultRecordingTarget(targets, new Set([9]))).toEqual({ + kind: "window", + windowIds: [7], + app: "Editor", + title: "a.txt", + pid: 20, + }); + }); +}); diff --git a/src/control/lib/recording-session.ts b/src/control/lib/recording-session.ts new file mode 100644 index 0000000000..195d63a606 --- /dev/null +++ b/src/control/lib/recording-session.ts @@ -0,0 +1,561 @@ +import { existsSync } from "node:fs"; +import { mkdir, rename, rm, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { toolDataDir } from "@genesiscz/utils/storage/root"; +import { z } from "zod"; +import { type RecordingTarget, recordingTargetSchema } from "./recording-targets"; + +const { log } = logger.scoped("control-recording-session"); + +/** + * The longest movie a session records, in seconds: the widget's video import (`probeVideo`) refuses anything longer + * than ten minutes, and both hosts hand the movie to it. At the limit the session pauses and waits for the person. + */ +export const RECORDING_SESSION_MAX_SECONDS = 600; + +/** The recorder stops a run after 180 s (`validateCaptureOptions`), so a longer recording is a chain of runs. */ +export const RECORDING_SEGMENT_MAX_SECONDS = 180; + +export const PREPARING_MESSAGE = + "Building the recorder after an update. This happens once and can take up to a minute. Discard cancels it."; + +/** Said when a segment has no first frame after `slowStartMs` and no compile explains it. */ +export const FIRST_FRAME_MESSAGE = + "Still waiting for the first frame. macOS sends none while the screen is locked or the window is hidden or minimized. Discard cancels it."; + +const SLOW_START_MS = 8000; + +export const recordingCommandSchema = z.discriminatedUnion("cmd", [ + z.object({ cmd: z.literal("pause") }), + z.object({ cmd: z.literal("resume") }), + z.object({ cmd: z.literal("restart") }), + z.object({ cmd: z.literal("stop") }), + z.object({ cmd: z.literal("discard") }), + z.object({ cmd: z.literal("switch"), target: recordingTargetSchema }), +]); + +export type RecordingCommand = z.infer<typeof recordingCommandSchema>; + +/** One stdin line as a session command, or the reason it is not one. */ +export function parseRecordingCommand(line: string): RecordingCommand | { error: string } { + let value: unknown; + try { + value = SafeJSON.parse(line, { strict: true }); + } catch (error) { + log.debug({ error, line: line.slice(0, 200) }, "recording command is not JSON"); + return { error: `Not a JSON command: ${line.slice(0, 200)}` }; + } + + const parsed = recordingCommandSchema.safeParse(value); + return parsed.success ? parsed.data : { error: `Unknown command: ${line.slice(0, 200)}` }; +} + +/** `starting`: a recorder run was asked for and has not written its first frame yet; nothing is in the movie. */ +export type RecordingState = "starting" | "recording" | "paused" | "finishing"; + +/** Why the session is in its state: the person asked, the length limit, or the recorder stopped by itself. */ +export type RecordingStateReason = "user" | "limit" | "recorder"; + +export type RecordingSessionEvent = + | { + event: "state"; + state: RecordingState; + reason: RecordingStateReason; + target: RecordingTarget; + elapsedMs: number; + segments: number; + limitMs: number; + } + | { event: "elapsed"; elapsedMs: number; limitMs: number } + | { event: "segment"; index: number; path: string; durationMs: number; target: RecordingTarget } + | { event: "done"; path: string; durationMs: number; segments: number } + | { event: "discarded" } + /** The recorder is not running yet for a known slow reason (it compiles after an update); `starting` stays. */ + | { event: "preparing"; message: string } + | { event: "error"; message: string; code?: string; fatal: boolean }; + +/** How one recorder run ended: asked to stop, reached its own deadline, or failed. */ +export type SegmentEnd = "stop" | "deadline" | "failure"; + +export interface SegmentResult { + /** True when the movie at `output` exists and is complete. */ + ok: boolean; + endedBy: SegmentEnd; + /** The recorder's own measure of the movie, when it reported one. */ + durationMs?: number; + error?: string; + /** A stable code for a refusal a host handles itself (`screen-recording-denied`). */ + code?: string; +} + +/** + * One recorder run: records `target` into `output` until `maxSeconds` or until `signal` aborts, then finalizes the + * movie. `onRecording` runs once, when the first frame is in. Resolves after the recorder exited; never rejects for + * a recording problem (that is `endedBy: "failure"`). + */ +export interface SegmentRecorder { + record(input: { + target: RecordingTarget; + output: string; + maxSeconds: number; + signal: AbortSignal; + onRecording: () => void; + /** The recorder needs a slow step before it can start (a compile after an update). */ + onPreparing?: () => void; + }): Promise<SegmentResult>; +} + +export interface FinishedSegment { + index: number; + path: string; + durationMs: number; + target: RecordingTarget; +} + +/** Joins finished segments into `output`, one H.264 MP4, and returns the joined movie's length. */ +export type SegmentJoiner = (input: { segments: FinishedSegment[]; output: string }) => Promise<{ durationMs: number }>; + +export type RecordingOutcome = "done" | "discarded" | "failed"; + +interface ActiveSegment { + index: number; + path: string; + target: RecordingTarget; + maxSeconds: number; + /** When the first frame came in; unset while the recorder arms. */ + startedAt?: number; + /** The reason the `recording` state will carry once the first frame is in. */ + reason: RecordingStateReason; + controller: AbortController; + /** Set before this session aborts the run, so its end is not mistaken for a rollover or a failure. */ + stopping: boolean; + /** The recorder said why its start is slow (a compile): the first-frame hint stays quiet. */ + preparing?: boolean; + /** Fires the first-frame hint; cleared by the first frame or the run's end. */ + slowStart?: ReturnType<typeof setTimeout>; + result: Promise<SegmentResult>; +} + +export function recordingsDir(...segments: string[]): string { + return toolDataDir("control", "recordings", ...segments); +} + +/** + * A recording made of segments, one recorder run each. Pause ends the current segment and resume starts a new one + * with the same target; switch ends it and starts one with the new target; restart drops every segment and starts + * again; stop joins the segments into one movie; discard deletes them. A run that ends by itself at its 180 s + * deadline is followed by the next one at once. Commands run one at a time, in arrival order. + */ +export class RecordingSession { + private state: RecordingState | "ended" = "paused"; + private target: RecordingTarget; + private readonly segments: FinishedSegment[] = []; + private current: ActiveSegment | undefined; + private counter = 0; + /** Wall time of every finished segment, capped at its run's own limit. */ + private recordedMs = 0; + private queue: Promise<void> = Promise.resolve(); + private ticker: ReturnType<typeof setInterval> | undefined; + private settle: (outcome: RecordingOutcome) => void = () => {}; + /** Resolves once with how the session ended. */ + readonly finished: Promise<RecordingOutcome>; + private readonly limitMs: number; + private readonly segmentSeconds: number; + private readonly now: () => number; + + constructor( + private readonly options: { + target: RecordingTarget; + /** The segment folder; created on start, removed when the session ends. */ + directory: string; + /** Where the joined movie goes. */ + output: string; + recorder: SegmentRecorder; + join: SegmentJoiner; + emit: (event: RecordingSessionEvent) => void; + maxSeconds?: number; + segmentSeconds?: number; + now?: () => number; + /** Milliseconds between `elapsed` events while recording; 0 sends none. */ + tickMs?: number; + /** A segment with no first frame after this long says so (`FIRST_FRAME_MESSAGE`); 0 never does. */ + slowStartMs?: number; + } + ) { + this.target = options.target; + this.limitMs = Math.round((options.maxSeconds ?? RECORDING_SESSION_MAX_SECONDS) * 1000); + this.segmentSeconds = Math.min(options.segmentSeconds ?? RECORDING_SEGMENT_MAX_SECONDS, 180); + this.now = options.now ?? Date.now; + this.finished = new Promise((resolve) => { + this.settle = resolve; + }); + } + + /** Starts the first segment. A first segment that fails without a movie ends the session as `failed`. */ + start(): Promise<void> { + return this.enqueue(async () => { + await mkdir(this.options.directory, { recursive: true }); + this.beginSegment("user"); + const tick = this.options.tickMs ?? 1000; + if (tick > 0) { + this.ticker = setInterval(() => { + if (this.state === "recording") { + this.options.emit({ event: "elapsed", elapsedMs: this.elapsedMs(), limitMs: this.limitMs }); + } + }, tick); + } + }); + } + + command(command: RecordingCommand): Promise<void> { + return this.enqueue(() => this.apply(command)); + } + + elapsedMs(): number { + return this.recordedMs + (this.current ? this.liveMs(this.current) : 0); + } + + /** Time a run has been in the movie: none while it arms, never past its own limit. */ + private liveMs(run: ActiveSegment): number { + if (run.startedAt === undefined) { + return 0; + } + + return Math.min(Math.max(0, this.now() - run.startedAt), run.maxSeconds * 1000); + } + + private enqueue(work: () => Promise<void> | void): Promise<void> { + const next = this.queue.then(async () => { + if (this.state === "ended") { + return; + } + + try { + await work(); + } catch (error) { + log.warn({ error }, "recording session step failed"); + this.options.emit({ event: "error", message: messageOf(error), fatal: false }); + } + }); + this.queue = next; + return next; + } + + private async apply(command: RecordingCommand): Promise<void> { + log.info({ command: command.cmd, state: this.state, segments: this.segments.length }, "recording command"); + if (this.state === "finishing") { + this.options.emit({ event: "error", message: "The recording is already being saved.", fatal: false }); + return; + } + + switch (command.cmd) { + case "pause": + if (this.state === "recording" || this.state === "starting") { + await this.stopSegment(); + this.setState("paused", "user"); + } + + return; + case "resume": + if (this.state === "paused") { + this.resumeOrRefuse(this.target); + } + + return; + case "switch": + await this.stopSegment(); + this.target = command.target; + this.resumeOrRefuse(command.target); + return; + case "restart": + await this.stopSegment(); + await this.deleteSegments(); + this.recordedMs = 0; + this.beginSegment("user"); + return; + case "stop": + await this.stopSegment(); + await this.finish(); + return; + case "discard": + await this.stopSegment(); + await this.deleteSegments(); + await this.removeDirectory(); + this.options.emit({ event: "discarded" }); + this.end("discarded"); + return; + } + } + + private resumeOrRefuse(target: RecordingTarget): void { + if (this.recordedMs >= this.limitMs) { + this.setState("paused", "limit"); + this.options.emit({ + event: "error", + message: `The recording reached its ${formatLimit(this.limitMs)} limit. Stop to keep it, or restart.`, + code: "limit", + fatal: false, + }); + return; + } + + this.target = target; + this.beginSegment("user"); + } + + private beginSegment(reason: RecordingStateReason): void { + this.counter += 1; + const index = this.counter; + const path = join(this.options.directory, `segment-${String(index).padStart(3, "0")}.mp4`); + const maxSeconds = Math.min(this.segmentSeconds, (this.limitMs - this.recordedMs) / 1000); + const controller = new AbortController(); + const target = this.target; + const run: ActiveSegment = { + index, + path, + target, + maxSeconds, + reason, + controller, + stopping: false, + result: Promise.resolve({ ok: false, endedBy: "failure" }), + }; + this.current = run; + log.info({ index, target, maxSeconds }, "recording segment requested"); + this.setState("starting", reason); + run.result = this.options.recorder + .record({ + target, + output: path, + maxSeconds, + signal: controller.signal, + onRecording: () => this.markRecording(run), + onPreparing: () => { + run.preparing = true; + this.options.emit({ event: "preparing", message: PREPARING_MESSAGE }); + }, + }) + .catch((error: unknown) => ({ ok: false, endedBy: "failure" as const, error: messageOf(error) })); + const slowStartMs = this.options.slowStartMs ?? SLOW_START_MS; + if (slowStartMs > 0) { + run.slowStart = setTimeout(() => { + if (this.current === run && run.startedAt === undefined && !run.stopping && !run.preparing) { + this.options.emit({ event: "preparing", message: FIRST_FRAME_MESSAGE }); + } + }, slowStartMs); + } + + void run.result.then((result) => { + clearTimeout(run.slowStart); + if (!run.stopping) { + void this.enqueue(() => this.endedByItself(run, result)); + } + }); + } + + /** The run's first frame is in: from here its time is in the movie. */ + private markRecording(run: ActiveSegment): void { + if (this.current !== run || run.stopping || run.startedAt !== undefined) { + return; + } + + run.startedAt = this.now(); + clearTimeout(run.slowStart); + log.info({ index: run.index }, "recording segment started"); + this.setState("recording", run.reason); + } + + private async stopSegment(): Promise<void> { + const run = this.current; + if (!run) { + return; + } + + run.stopping = true; + run.controller.abort(); + const result = await run.result; + this.current = undefined; + this.record(run, result); + } + + /** Keeps a finished run's movie, or says why there is none. */ + private record(run: ActiveSegment, result: SegmentResult): void { + const wallMs = this.liveMs(run); + if (run.startedAt === undefined || !existsSync(run.path)) { + if (result.endedBy === "failure" || !result.ok || run.startedAt === undefined) { + log.warn({ index: run.index, error: result.error }, "recording segment produced no movie"); + } + + // A run whose first frame never came can still leave an empty file (a stop while it waited, a locked + // screen); joined, it made a 0-byte movie reported as done. + if (existsSync(run.path)) { + void unlink(run.path).catch((error: unknown) => { + log.debug({ error, path: run.path }, "could not remove a frameless segment"); + }); + } + + return; + } + + const segment: FinishedSegment = { + index: run.index, + path: run.path, + durationMs: result.durationMs ?? wallMs, + target: run.target, + }; + this.segments.push(segment); + this.recordedMs += wallMs; + this.options.emit({ event: "segment", ...segment }); + } + + private async endedByItself(run: ActiveSegment, result: SegmentResult): Promise<void> { + if (this.current !== run) { + return; + } + + this.current = undefined; + this.record(run, result); + if (result.endedBy === "deadline" && result.ok) { + if (this.recordedMs >= this.limitMs) { + log.info({ recordedMs: this.recordedMs }, "recording reached its limit"); + this.setState("paused", "limit"); + return; + } + + this.beginSegment("recorder"); + return; + } + + const message = result.error?.trim() || "The recorder stopped by itself."; + if (this.segments.length === 0 && this.counter === 1) { + this.options.emit({ event: "error", message, code: result.code, fatal: true }); + await this.removeDirectory(); + this.end("failed"); + return; + } + + this.setState("paused", "recorder"); + this.options.emit({ event: "error", message, code: result.code, fatal: false }); + } + + private async finish(): Promise<void> { + this.setState("finishing", "user"); + if (this.segments.length === 0) { + this.options.emit({ event: "error", message: "Nothing was recorded.", fatal: true }); + await this.removeDirectory(); + this.end("failed"); + return; + } + + const ordered = [...this.segments].sort((a, b) => a.index - b.index); + let durationMs: number; + if (ordered.length === 1) { + await rename(ordered[0].path, this.options.output); + durationMs = ordered[0].durationMs; + } else { + ({ durationMs } = await this.options.join({ segments: ordered, output: this.options.output })); + } + + await this.removeDirectory(); + log.info({ output: this.options.output, segments: ordered.length, durationMs }, "recording saved"); + this.options.emit({ event: "done", path: this.options.output, durationMs, segments: ordered.length }); + this.end("done"); + } + + private async deleteSegments(): Promise<void> { + for (const segment of this.segments.splice(0)) { + try { + await unlink(segment.path); + } catch (error) { + log.debug({ error, path: segment.path }, "recording segment already gone"); + } + } + } + + private async removeDirectory(): Promise<void> { + await rm(this.options.directory, { recursive: true, force: true }); + } + + private setState(state: RecordingState, reason: RecordingStateReason): void { + this.state = state; + this.options.emit({ + event: "state", + state, + reason, + target: this.target, + elapsedMs: this.elapsedMs(), + segments: this.segments.length, + limitMs: this.limitMs, + }); + } + + private end(outcome: RecordingOutcome): void { + this.state = "ended"; + clearInterval(this.ticker); + this.ticker = undefined; + this.settle(outcome); + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function formatLimit(ms: number): string { + const seconds = Math.round(ms / 1000); + return seconds % 60 === 0 ? `${seconds / 60} min` : `${seconds} s`; +} + +/** + * ffmpeg arguments that join segments of any sizes into one H.264 MP4 at the first segment's size: each input is + * scaled to fit, padded with black and given square pixels, then the concat filter chains them. The recorder writes + * variable frame rate movies (a frame only when something moved), so the output keeps their timestamps. + */ +export function joinSegmentsArgv({ + inputs, + width, + height, + output, +}: { + inputs: string[]; + width: number; + height: number; + output: string; +}): string[] { + const w = Math.max(2, Math.floor(width / 2) * 2); + const h = Math.max(2, Math.floor(height / 2) * 2); + const chains = inputs.map( + (_, index) => + `[${index}:v]scale=${w}:${h}:force_original_aspect_ratio=decrease,` + + `pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p,settb=AVTB[v${index}]` + ); + const labels = inputs.map((_, index) => `[v${index}]`).join(""); + return [ + "ffmpeg", + "-hide_banner", + "-nostdin", + "-loglevel", + "error", + "-y", + ...inputs.flatMap((input) => ["-i", input]), + "-filter_complex", + `${chains.join(";")};${labels}concat=n=${inputs.length}:v=1:a=0[out]`, + "-map", + "[out]", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "20", + "-pix_fmt", + "yuv420p", + "-fps_mode", + "vfr", + "-movflags", + "+faststart", + output, + ]; +} diff --git a/src/control/lib/recording-targets.ts b/src/control/lib/recording-targets.ts new file mode 100644 index 0000000000..872392745c --- /dev/null +++ b/src/control/lib/recording-targets.ts @@ -0,0 +1,207 @@ +import { logger } from "@genesiscz/utils/logger"; +import { z } from "zod"; +import { parseScreenList } from "./native-record"; +import { type AxResult, runAxAsync } from "./runner"; + +const { log } = logger.scoped("control-recording-targets"); + +/** One thing a recording session can record: exact windows, every visible window of an app, or a whole display. */ +export const recordingTargetSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("window"), + windowIds: z.array(z.number().int().positive().max(0xffffffff)).min(1).max(16), + app: z.string().optional(), + title: z.string().optional(), + pid: z.number().int().positive().optional(), + }), + z.object({ + kind: z.literal("app"), + /** A pid, an app name or a bundle id, as `--include-app` takes it. */ + app: z.string().trim().min(1), + name: z.string().optional(), + }), + z.object({ + kind: z.literal("display"), + /** NSScreen order, as `ax-tool screens` lists it. */ + screenIndex: z.number().int().nonnegative(), + name: z.string().optional(), + }), +]); + +export type RecordingTarget = z.infer<typeof recordingTargetSchema>; + +export interface RecordingWindow { + id: number; + pid: number; + app: string; + title: string; + bounds: { x: number; y: number; width: number; height: number }; +} + +export interface RecordingApp { + pid: number; + name: string; + windowCount: number; +} + +export interface RecordingDisplay { + index: number; + name: string; + isPrimary: boolean; + width: number; + height: number; + scaleFactor: number; +} + +export interface RecordingTargets { + /** On-screen windows, front to back. */ + windows: RecordingWindow[]; + /** Apps with at least one listed window, in the order their front window appears. */ + apps: RecordingApp[]; + displays: RecordingDisplay[]; + /** The WindowServer's frontmost application, when it reported one. */ + frontmostPid?: number; +} + +/** + * Window owners that are system chrome rather than something a person records. GenesisTools stays listed: the hub + * and the review window are worth recording, and a host filters its own panels out by pid. + */ +const SYSTEM_OWNERS = new Set([ + "Dock", + "WindowServer", + "Window Server", + "Control Center", + "Notification Center", + "NotificationCenter", + "Spotlight", + "SystemUIServer", + "loginwindow", + "Screenshot", + "TextInputMenuAgent", + "Wallpaper", +]); + +/** Smaller than this on either side is a strip, a badge or an invisible helper window, not a recording target. */ +const MIN_WINDOW_SIDE = 60; + +const frontRowSchema = z.object({ + pid: z.number().int(), + app: z.string(), + windowId: z.number().int().positive(), + title: z.string().optional(), + bounds: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }), +}); + +/** `ax-tool front` rows (layer 0, on screen, front to back) as recordable windows. Malformed rows are skipped. */ +export function parseRecordingWindows(raw: unknown): RecordingWindow[] { + const rows = (raw as { windows?: unknown } | null | undefined)?.windows; + if (!Array.isArray(rows)) { + return []; + } + + const windows: RecordingWindow[] = []; + for (const row of rows) { + const parsed = frontRowSchema.safeParse(row); + if (!parsed.success) { + continue; + } + + const { pid, app, windowId, title, bounds } = parsed.data; + if (SYSTEM_OWNERS.has(app) || app.length === 0) { + continue; + } + + if (bounds.width < MIN_WINDOW_SIDE || bounds.height < MIN_WINDOW_SIDE) { + continue; + } + + windows.push({ id: windowId, pid, app, title: title ?? "", bounds }); + } + + return windows; +} + +/** One row per app, in the order its front window appears. */ +export function groupRecordingApps(windows: RecordingWindow[]): RecordingApp[] { + const apps = new Map<number, RecordingApp>(); + for (const window of windows) { + const app = apps.get(window.pid); + if (app) { + app.windowCount += 1; + } else { + apps.set(window.pid, { pid: window.pid, name: window.app, windowCount: 1 }); + } + } + + return [...apps.values()]; +} + +export function parseRecordingDisplays(raw: unknown): RecordingDisplay[] { + return parseScreenList(raw, "cocoa").map((screen) => ({ + index: screen.index, + name: screen.name, + isPrimary: screen.isPrimary, + width: screen.points.width, + height: screen.points.height, + scaleFactor: screen.scaleFactor, + })); +} + +/** Everything a picker offers, from one `ax-tool front` and one `ax-tool screens`. Reads only. */ +export async function listRecordingTargets( + options: { run?: (request: { args: string[]; timeoutMs: number }) => Promise<AxResult> } = {} +): Promise<RecordingTargets> { + const run = options.run ?? runAxAsync; + const [front, screens] = await Promise.all([ + run({ args: ["front"], timeoutMs: 5000 }), + run({ args: ["screens"], timeoutMs: 5000 }), + ]); + if (!front.ok) { + throw new Error(`Cannot list windows: ${front.error ?? "ax-tool front failed"}`); + } + + if (!screens.ok) { + throw new Error(`Cannot list displays: ${screens.error ?? "ax-tool screens failed"}`); + } + + const windows = parseRecordingWindows(front); + const frontmost = (front as { frontmostPid?: unknown }).frontmostPid; + const targets: RecordingTargets = { + windows, + apps: groupRecordingApps(windows), + displays: parseRecordingDisplays(screens), + ...(typeof frontmost === "number" ? { frontmostPid: frontmost } : {}), + }; + log.debug( + { windows: targets.windows.length, apps: targets.apps.length, displays: targets.displays.length }, + "recording targets listed" + ); + return targets; +} + +/** The front window of the app the person was in: the frontmost app unless it is in `excludePids` (a terminal). */ +export function defaultRecordingTarget(targets: RecordingTargets, excludePids: Set<number>): RecordingTarget | null { + const usable = targets.windows.filter((window) => !excludePids.has(window.pid)); + const front = + (targets.frontmostPid !== undefined && usable.find((window) => window.pid === targets.frontmostPid)) || + usable[0]; + if (front) { + return { kind: "window", windowIds: [front.id], app: front.app, title: front.title, pid: front.pid }; + } + + const display = targets.displays.find((entry) => entry.isPrimary) ?? targets.displays[0]; + return display ? { kind: "display", screenIndex: display.index, name: display.name } : null; +} + +/** A short human name for a target, for logs and the CLI. */ +export function describeRecordingTarget(target: RecordingTarget): string { + switch (target.kind) { + case "window": + return [target.app, target.title].filter(Boolean).join(" · ") || `window ${target.windowIds.join(",")}`; + case "app": + return target.name ?? target.app; + case "display": + return target.name ?? `display ${target.screenIndex}`; + } +} diff --git a/src/control/lib/runner.test.ts b/src/control/lib/runner.test.ts index ee787a5367..ee1e26d3a4 100644 --- a/src/control/lib/runner.test.ts +++ b/src/control/lib/runner.test.ts @@ -14,6 +14,7 @@ import { axCommandLine, DEFAULT_AX_RUN_BOUNDARY, ensureBinary, + foregroundGuardEnvironment, getBinaryPath, nativeEnvironment, REAL_AX_TOOL_IN_TESTS, @@ -756,3 +757,12 @@ test("every ax-tool spawn carries the overlay note: one call's note over the com } ); }); + +test("foregroundGuardEnvironment turns the guard on under an agent only, and an explicit setting wins", () => { + expect(foregroundGuardEnvironment({ CLAUDECODE: "1" })).toEqual({ GENESIS_CONTROL_FOREGROUND_GUARD: "1" }); + expect(foregroundGuardEnvironment({ CODEX_THREAD_ID: "thread" })).toEqual({ + GENESIS_CONTROL_FOREGROUND_GUARD: "1", + }); + expect(foregroundGuardEnvironment({})).toEqual({}); + expect(foregroundGuardEnvironment({ CLAUDECODE: "1", GENESIS_CONTROL_FOREGROUND_GUARD: "0" })).toEqual({}); +}); diff --git a/src/control/lib/runner.ts b/src/control/lib/runner.ts index 46ac5bfe38..42c0e088c8 100644 --- a/src/control/lib/runner.ts +++ b/src/control/lib/runner.ts @@ -1,7 +1,8 @@ -import { spawnSync } from "node:child_process"; +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { agentSessionIds } from "@genesiscz/utils/agent/host"; +import { agentSessionIds, resolveAgentHost } from "@genesiscz/utils/agent/host"; +import { untilAborted } from "@genesiscz/utils/async"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -311,6 +312,118 @@ export function ensureBinary(): string { ); } +interface PendingBuild { + binary: Promise<string>; + /** Stops this process from waiting on the compile, so it can exit while the compile finishes. */ + release: () => void; +} + +let pendingBuild: PendingBuild | null = null; + +/** + * `ensureBinary` for a caller that must stay responsive while the native CLI compiles (20 to 60 s + * after a source change): a recording session that still has to read Stop and Discard, a listing + * a host cancels. `onBuild` runs once when a compile is needed, before the wait, so the caller can + * say why it is slow. The worker leads its own process group: a caller that gives up (`signal`) or + * is killed while it waits leaves the compile running, and the next caller reuses that binary + * instead of compiling again from the start. + */ +export async function ensureBinaryAsync({ + signal, + onBuild, +}: { + signal?: AbortSignal; + onBuild?: () => void; +} = {}): Promise<string> { + refuseRealMachineInTest("ax-tool (ensureBinaryAsync hands out, and may build, the real binary)"); + if (verifiedBinary !== null) { + return verifiedBinary; + } + + if (!nativeNeedsBuild({ binary: BINARY_PATH, sourceDir: SWIFT_SOURCE })) { + verifiedBinary = BINARY_PATH; + return verifiedBinary; + } + + if (!existsSync(join(SWIFT_SOURCE, "Package.swift"))) { + return ensureBinary(); + } + + onBuild?.(); + if (!pendingBuild) { + const build = buildInOwnGroup(); + pendingBuild = build; + void build.binary.then( + () => { + pendingBuild = null; + }, + () => { + pendingBuild = null; + } + ); + } + + const build = pendingBuild; + signal?.addEventListener("abort", () => build.release(), { once: true }); + return untilAborted(build.binary, signal); +} + +function buildInOwnGroup(): PendingBuild { + logger.info({ source: SWIFT_SOURCE }, "ax-tool binary missing or stale; compiling native CLI in its own group"); + // stderr is ignored: once a cancelled caller is gone, a write to its pipe would end the compile + // with SIGPIPE. The worker's stdout result carries a failure's compiler output. + const child = spawn(process.execPath, [BUILD_WORKER, BINARY_PATH, SWIFT_SOURCE, BUILD_LOCK], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + let stdout = ""; + child.stdout?.setEncoding("utf-8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + const deadline = setTimeout(() => child.kill("SIGTERM"), NATIVE_BUILD_WORKER_TIMEOUT_MS); + const release = () => { + child.unref(); + deadline.unref(); + // The pipe keeps the event loop alive on its own (measured on Bun 1.3: 4 s with the child unref'd alone). + const pipe = child.stdout; + if (pipe && "unref" in pipe && typeof pipe.unref === "function") { + pipe.unref(); + } + }; + return { binary: waitForBuild({ child, deadline, stdout: () => stdout }), release }; +} + +async function waitForBuild({ + child, + deadline, + stdout, +}: { + child: ChildProcess; + deadline: ReturnType<typeof setTimeout>; + stdout: () => string; +}): Promise<string> { + const status = await new Promise<number | null>((resolve, reject) => { + child.once("error", (error) => { + clearTimeout(deadline); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(deadline); + resolve(code); + }); + }); + const outcome = parseBuildOutcome(stdout()); + if (status === 0 && outcome?.ok && existsSync(BINARY_PATH)) { + logger.info(outcome.built ? "ax-tool built successfully" : "ax-tool was built by another process; reusing it"); + verifiedBinary = BINARY_PATH; + return verifiedBinary; + } + + const details = (outcome?.error ?? stdout().trim()).slice(-4000); + throw new Error(`ax-tool build failed (${status ?? "killed"}):\n${details}`); +} + export interface AxSpawnRequest { binary: string; args: string[]; @@ -604,12 +717,26 @@ export function nativeEnvironment(note?: string): NodeJS.ProcessEnv { return { ...environment, + ...foregroundGuardEnvironment(environment), ...cursorEnvironment({ note: note ?? cursorNote ?? environment[CURSOR_NOTE_ENV], session: environment[CURSOR_SESSION_ENV] ?? sessionLabel ?? undefined, }), }; } + +/** + * Under an agent, ax-tool refuses to take focus or the pointer while the user works (2026-10-23: a test run took + * the user's focus again and again). A person running a command just pressed Return, so outside an agent the guard + * stays off. An explicit GENESIS_CONTROL_FOREGROUND_GUARD in the environment wins either way. + */ +export function foregroundGuardEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (environment.GENESIS_CONTROL_FOREGROUND_GUARD !== undefined || !resolveAgentHost(environment).isInAgent) { + return {}; + } + + return { GENESIS_CONTROL_FOREGROUND_GUARD: "1" }; +} function nativeArguments(args: string[]): string[] { const mutating = [ "act", @@ -638,7 +765,6 @@ export async function runAxAsync(options: { /** Shown on the cursor overlay's label for this one call. */ note?: string; }): Promise<AxResult> { - const clock = new Stopwatch(); const timeoutMs = Math.floor(options.timeoutMs ?? 10000); if (options.signal?.aborted || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) { return { @@ -650,15 +776,22 @@ export async function runAxAsync(options: { const args = nativeArguments(options.args); let binary: string; try { - binary = ensureBinary(); + binary = await ensureBinaryAsync({ signal: options.signal }); } catch (error) { if (error instanceof RealMachineInTestError) { throw error; } + if (options.signal?.aborted) { + return { ok: false, dispatchState: "not_started", error: "Native command cancelled before dispatch." }; + } + logger.error({ error }, "Native build unavailable"); return { ok: false, dispatchState: "not_started", error: "Native build unavailable; no action dispatched." }; } + // The budget starts once the binary is ready: the one compile after a source change (20 to 60 s) is not the + // command's time, and counting it made a 5 s window listing fail right after the compile it waited for. + const clock = new Stopwatch(); const remainingMs = Math.floor(timeoutMs - clock.elapsedMs); if (remainingMs < 1 || options.signal?.aborted) { return { ok: false, dispatchState: "not_started", error: "Native command deadline reached before dispatch." }; diff --git a/src/du/lib/index/build.ts b/src/du/lib/index/build.ts index 3e782cb318..2099182b6a 100644 --- a/src/du/lib/index/build.ts +++ b/src/du/lib/index/build.ts @@ -21,6 +21,7 @@ import { } from "./store"; import type { BuildResult, IndexEvent, ProgressEvent } from "./types"; import { IndexError } from "./types"; +import { sameVolume, volumeUuidAt } from "./volume"; import { runIndexWalk, type WalkNode, type WalkOutput, type WalkProgress } from "./walk"; const log = logger.child({ component: "du:index:build" }); @@ -307,7 +308,12 @@ export async function buildIndex(opts: BuildOptions): Promise<BuildResult> { // Once the rows are in, a cancel no longer helps anyone: the commit is about a second. let reset: BuildResult["reset"] = opts.reset ? "requested" : null; const knownFsid = getMeta(db, "volume_fsid"); - if (!reset && knownFsid !== null && walk.end.fsid !== "" && knownFsid !== walk.end.fsid) { + const knownUuid = getMeta(db, "volume_uuid"); + const volumeUuid = volumeUuidAt(root); + if ( + !reset && + !sameVolume({ fsid: knownFsid, uuid: knownUuid }, { fsid: walk.end.fsid || null, uuid: volumeUuid }) + ) { reset = "volume_changed"; log.warn({ knownFsid, fsid: walk.end.fsid, root }, "volume changed; rebuilding from empty"); } @@ -366,6 +372,7 @@ export async function buildIndex(opts: BuildOptions): Promise<BuildResult> { const meta: Record<string, string> = { root, volume_fsid: walk.end.fsid, + ...(volumeUuid ? { volume_uuid: volumeUuid } : {}), private: withPrivate ? "1" : "0", min_dir_bytes: String(minDirBytes), big_file_floor: String(bigFileFloor), diff --git a/src/du/lib/index/update.ts b/src/du/lib/index/update.ts index d6e701d485..f0df5a9433 100644 --- a/src/du/lib/index/update.ts +++ b/src/du/lib/index/update.ts @@ -28,6 +28,7 @@ import { } from "./store"; import type { DirRow, IndexEvent, RegionResult, UpdateResult } from "./types"; import { IndexError } from "./types"; +import { sameVolume, volumeUuidAt } from "./volume"; import { runIndexWalk, type WalkOutput } from "./walk"; const log = logger.child({ component: "du:index:update" }); @@ -372,6 +373,10 @@ async function runUpdate(opts: UpdateOptions, targets: Target[], startedAt: numb const bigFileFloor = Number(getMeta(db, "big_file_floor") ?? INDEX_DEFAULTS.bigFileFloor); const bigFileLimit = Number(getMeta(db, "big_file_limit") ?? INDEX_DEFAULTS.bigFileLimit); const fsid = getMeta(db, "volume_fsid"); + const knownUuid = getMeta(db, "volume_uuid"); + // Read once, and only when needed: a moved fsid, or an index that has no UUID stored yet. + let currentUuid: string | null | undefined = knownUuid === null ? volumeUuidAt(root.name) : undefined; + let movedFsid: string | null = null; const queueReadAt = now(); const queued = opts.fromQueue @@ -484,10 +489,18 @@ async function runUpdate(opts: UpdateOptions, targets: Target[], startedAt: numb } if (fsid !== null && walk.end.fsid !== "" && walk.end.fsid !== fsid) { - throw new IndexError( - "volume_changed", - `${owner.path} is on another volume than the index (${walk.end.fsid}, built on ${fsid}). Run a build.` - ); + if (currentUuid === undefined) { + currentUuid = volumeUuidAt(root.name); + } + + if (!sameVolume({ fsid, uuid: knownUuid }, { fsid: walk.end.fsid, uuid: currentUuid })) { + throw new IndexError( + "volume_changed", + `${owner.path} is on another volume than the index (${walk.end.fsid}, built on ${fsid}). Run a build.` + ); + } + + movedFsid = walk.end.fsid; } walks.push({ owner, walk }); @@ -535,6 +548,13 @@ async function runUpdate(opts: UpdateOptions, targets: Target[], startedAt: numb assertConsistent(db, touched); setMeta(db, "last_update_scan_id", String(scanId)); setMeta(db, "blocked_dirs", String(countBlocked(db))); + if (movedFsid !== null) { + setMeta(db, "volume_fsid", movedFsid); + } + + if (knownUuid === null && currentUuid) { + setMeta(db, "volume_uuid", currentUuid); + } if (queued.length > 0) { db.run("DELETE FROM dirty_queue WHERE path IN (SELECT value FROM json_each(?)) AND queued_at_ms <= ?", [ SafeJSON.stringify(queued.map((q) => q.path)), diff --git a/src/du/lib/index/volume.test.ts b/src/du/lib/index/volume.test.ts new file mode 100644 index 0000000000..5d6e94eba8 --- /dev/null +++ b/src/du/lib/index/volume.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { sameVolume, volumeUuidAt } from "./volume"; + +describe("sameVolume", () => { + it("trusts the UUID over a device number that moved on remount", () => { + expect(sameVolume({ fsid: "0100000e0000001a", uuid: "A" }, { fsid: "010000100000001a", uuid: "A" })).toBe(true); + }); + + it("reports another volume when the UUIDs differ", () => { + expect(sameVolume({ fsid: "01", uuid: "A" }, { fsid: "01", uuid: "B" })).toBe(false); + }); + + it("accepts a moved fsid once for an index written before the UUID was stored", () => { + expect(sameVolume({ fsid: "0100000e0000001a", uuid: null }, { fsid: "010000100000001a", uuid: "A" })).toBe( + true + ); + }); + + it("falls back to the fsid when no UUID is readable", () => { + expect(sameVolume({ fsid: "01", uuid: null }, { fsid: "02", uuid: null })).toBe(false); + expect(sameVolume({ fsid: "01", uuid: null }, { fsid: "01", uuid: null })).toBe(true); + }); + + it("has nothing to compare without a known fsid", () => { + expect(sameVolume({ fsid: null, uuid: null }, { fsid: "02", uuid: null })).toBe(true); + }); +}); + +describe.if(process.platform === "darwin")("volumeUuidAt", () => { + it("names the volume UUID of the home folder", () => { + expect(volumeUuidAt(process.env.HOME ?? "/")).toMatch(/^[0-9A-F-]{36}$/); + }); +}); diff --git a/src/du/lib/index/volume.ts b/src/du/lib/index/volume.ts new file mode 100644 index 0000000000..2c064672d7 --- /dev/null +++ b/src/du/lib/index/volume.ts @@ -0,0 +1,54 @@ +import { logger } from "@genesiscz/utils/logger"; + +const log = logger.child({ module: "du-index-volume" }); + +/** What identifies the volume an index was built on: the walker's `statfs` fsid and the APFS volume UUID. */ +export interface VolumeIdentity { + fsid: string | null; + uuid: string | null; +} + +/** + * Whether a walk is on the volume the index was built on. + * + * The fsid alone is not stable: macOS hands a volume a new device number when it mounts in another + * order, so after a reboot the boot volume's fsid changed (0100000e… vs 01000010…) and every update + * failed with `volume_changed` for six days (2026-10-17 to 2026-10-23). The volume UUID survives a + * remount, so it decides whenever both sides have one. An index written before the UUID was stored + * keeps the path as its only other evidence, so a moved fsid with a readable UUID is accepted once + * and the caller stores that UUID. + */ +export function sameVolume(known: VolumeIdentity, current: VolumeIdentity): boolean { + if (known.uuid && current.uuid) { + return known.uuid === current.uuid; + } + + if (!known.fsid || !current.fsid || known.fsid === current.fsid) { + return true; + } + + return known.uuid === null && current.uuid !== null; +} + +/** The APFS/HFS volume UUID of the volume holding `path`, or null when macOS does not report one. */ +export function volumeUuidAt(path: string): string | null { + if (process.platform !== "darwin") { + return null; + } + + const df = Bun.spawnSync(["/bin/df", "-P", path], { stderr: "pipe" }); + const mount = df.stdout.toString().trim().split("\n").at(-1)?.split(/\s+/).slice(5).join(" "); + if (df.exitCode !== 0 || !mount) { + log.warn({ path, stderr: df.stderr.toString().trim() }, "df could not name the mount point"); + return null; + } + + const info = Bun.spawnSync(["/usr/sbin/diskutil", "info", "-plist", mount], { stderr: "pipe" }); + const match = info.stdout.toString().match(/<key>VolumeUUID<\/key>\s*<string>([^<]+)<\/string>/); + if (info.exitCode !== 0 || !match) { + log.warn({ path, mount, stderr: info.stderr.toString().trim() }, "diskutil reported no volume UUID"); + return null; + } + + return match[1]; +} diff --git a/src/genesis-tools/README.md b/src/genesis-tools/README.md index 7d51db8e66..8618d49bb1 100644 --- a/src/genesis-tools/README.md +++ b/src/genesis-tools/README.md @@ -40,6 +40,15 @@ tools genesis-tools logs crashes # crash records and macOS crash repo tools genesis-tools logs day --level warn --tool ai --since 2h # the pino day log, through rg tools genesis-tools logs profile --min-ms 5000 --scope cli # slow timers (tools config profiling) tools genesis-tools logs paths +tools genesis-tools probe # what of GenesisTools this Mac has, and which links a click opens +tools genesis-tools probe md finder # exit 1, with the fix command, when one of them is missing +tools genesis-tools probe --url <url> # whether a click on this one link opens what it names +tools genesis-tools probe --json # the same for code (the mods plugin reads it) ``` +`probe` (`src/utils/genesis-tools/probe.ts`) checks the parts (`tools`, `app`, `router`, `markdown`, `cmux`) and +each link kind (`md`, `finder`, `hub`, `claude-find`). A kind opens when GenesisTools.app is installed, it is the +https handler, the router config routes that path (by `route()`, so a hand-made route counts), and the app or +command the route names is there. It reads only. `.claude/rules/genesis-links.md` says where links are built. + Every listing takes `--json`. `--since` reads `30m`, `24h`, `7d`, `HH:MM` or `YYYY-MM-DD`. diff --git a/src/genesis-tools/commands/probe.ts b/src/genesis-tools/commands/probe.ts new file mode 100644 index 0000000000..390c7d3f20 --- /dev/null +++ b/src/genesis-tools/commands/probe.ts @@ -0,0 +1,179 @@ +import { suggestCommand } from "@genesiscz/utils/cli"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { + GENESIS_LINK_KINDS, + type GenesisLinkKind, + type GenesisToolsProbe, + type LinkState, + linkState, + probeGenesisTools, + readProbeFacts, +} from "@genesiscz/utils/genesis-tools/probe"; +import { out } from "@genesiscz/utils/logger"; +import { createBoxTable, formatDotStatus, renderCliHeader } from "@genesiscz/utils/table"; +import type { Command } from "commander"; +import pc from "picocolors"; + +const PARTS = ["tools", "app", "router", "markdown", "cmux"] as const; +type Part = (typeof PARTS)[number]; +const NAMES: readonly string[] = [...PARTS, ...GENESIS_LINK_KINDS]; + +/** The router preset that writes each link kind's route. */ +const PRESET_OF: Record<GenesisLinkKind, string> = { + md: "genesis-md", + finder: "finder", + hub: "genesis-hub", + "claude-find": "claude-find", +}; + +interface Row { + name: string; + ok: boolean; + detail: string; + fix: string | null; +} + +function partRow(part: Part, probe: GenesisToolsProbe): Row { + const install = toolCommand("browser-router install"); + + switch (part) { + case "tools": + return { + name: part, + ok: probe.tools.path !== null, + detail: probe.tools.path ?? "no checkout found", + fix: null, + }; + case "app": + return { + name: part, + ok: probe.app.installed, + detail: probe.app.installed ? probe.app.path : `not at ${probe.app.path}`, + fix: probe.app.installed ? null : install, + }; + case "router": { + const { defaultHandler, httpsHandler, linkHost } = probe.router; + const ok = defaultHandler && linkHost !== null; + const detail = !defaultHandler + ? `https opens in ${httpsHandler ?? "no app"}` + : linkHost + ? `https handler, link host ${linkHost}` + : "https handler, no link host"; + const fix = !defaultHandler ? install : linkHost ? null : toolCommand("browser-router link-host", "<host>"); + + return { name: part, ok, detail, fix }; + } + case "markdown": + return { + name: part, + ok: probe.markdown.path !== null, + detail: probe.markdown.path ?? "no app opens Genesis Markdown links", + fix: probe.markdown.path ? null : "install Genesis Markdown", + }; + case "cmux": + return { name: part, ok: probe.cmux.path !== null, detail: probe.cmux.path ?? "not installed", fix: null }; + } +} + +function fixFor(kind: GenesisLinkKind | null, state: LinkState): string | null { + switch (state.problem) { + case null: + case "not-darwin": + return null; + case "app-missing": + case "not-https-handler": + return toolCommand("browser-router install"); + case "no-link-host": + return toolCommand("browser-router link-host", "<host>"); + case "no-route": + return kind + ? toolCommand("browser-router presets enable", PRESET_OF[kind]) + : toolCommand("browser-router presets"); + case "target-missing": + return kind === "md" ? "install Genesis Markdown" : null; + } +} + +function linkRow(kind: GenesisLinkKind, state: LinkState): Row { + return { + name: `${kind} links`, + ok: state.opens, + detail: state.opens ? (state.action ?? "") : (state.reason ?? ""), + fix: fixFor(kind, state), + }; +} + +function printRows(rows: Row[]): void { + const table = createBoxTable(["PART", "STATE", "DETAIL"]); + + for (const row of rows) { + table.push([row.name, formatDotStatus(row.ok ? "ok" : "err", row.ok ? "ok" : "missing"), row.detail]); + } + + out.println(table.toString()); + + for (const row of rows.filter((row) => !row.ok && row.fix)) { + out.println(`${pc.yellow(row.name)}: ${row.fix}`); + } +} + +export function registerProbeCommand(program: Command): void { + program + .command("probe") + .description( + "What of GenesisTools this Mac has (the CLI, the app, the link router, Genesis Markdown, cmux) and which " + + "genesis.tools links a click opens; exits 1 when a named part is missing" + ) + .argument("[names...]", `Parts to check: ${NAMES.join(", ")}`) + .option("--url <url>", "Whether a click on this one link opens what it names") + .option("--json", "Print the probe as JSON") + .action((names: string[], options: { url?: string; json?: boolean }) => { + if (options.url) { + const state = linkState(options.url, readProbeFacts()); + + if (options.json) { + out.result(state); + } else { + printRows([ + { + name: "link", + ok: state.opens, + detail: state.reason ?? state.action ?? "", + fix: fixFor(null, state), + }, + ]); + } + + process.exitCode = state.opens ? 0 : 1; + return; + } + + const unknown = names.filter((name) => !NAMES.includes(name)); + + if (unknown.length > 0) { + out.log.error(`Unknown part: ${unknown.join(", ")}. The parts: ${NAMES.join(", ")}`); + out.println(suggestCommand("tools genesis-tools", { replaceCommand: ["probe", "md", "finder"] })); + process.exitCode = 1; + return; + } + + const probe = probeGenesisTools(); + const rows = [ + ...PARTS.map((part) => partRow(part, probe)), + ...GENESIS_LINK_KINDS.map((kind) => linkRow(kind, probe.links[kind])), + ]; + const picked = + names.length > 0 ? rows.filter((row) => names.some((name) => row.name.split(" ")[0] === name)) : rows; + + if (options.json) { + out.result(probe); + } else { + renderCliHeader("GenesisTools on this Mac", "parts and links"); + printRows(picked); + } + + if (names.length > 0 && picked.some((row) => !row.ok)) { + process.exitCode = 1; + } + }); +} diff --git a/src/genesis-tools/index.ts b/src/genesis-tools/index.ts index 2ab17a6b6c..147b22ce54 100644 --- a/src/genesis-tools/index.ts +++ b/src/genesis-tools/index.ts @@ -2,12 +2,16 @@ import { runTool } from "@genesiscz/utils/cli"; import { Command } from "commander"; import { registerLogsCommand } from "./commands/logs"; +import { registerProbeCommand } from "./commands/probe"; const program = new Command() .name("genesis-tools") - .description("Developer tools for GenesisTools itself: what failed, crashed or ran slow, and who ran it"); + .description( + "Developer tools for GenesisTools itself: what failed, crashed or ran slow, and who ran it; what of it this Mac has" + ); registerLogsCommand(program); +registerProbeCommand(program); if (import.meta.main) { await runTool(program, { tool: "genesis-tools" }); diff --git a/src/git/lib/changes-read.ts b/src/git/lib/changes-read.ts index 944c4d16d3..b873a9b243 100644 --- a/src/git/lib/changes-read.ts +++ b/src/git/lib/changes-read.ts @@ -1,7 +1,7 @@ import { lstatSync } from "node:fs"; import { join } from "node:path"; import { committedChanges, type FileChange, uncommittedChanges } from "@app/git/lib/changes"; -import { createGit } from "@genesiscz/utils/git"; +import { createGit, pairStatusRenames } from "@genesiscz/utils/git"; import { logger } from "@genesiscz/utils/logger"; const log = logger.scoped("git-changes").log; @@ -21,8 +21,10 @@ export async function readUncommittedChanges({ cwd, now }: { cwd?: string; now: const git = createGit({ cwd }); const [root, status] = await Promise.all([git.getRepoRoot(), git.status({ untracked: "all" })]); log.debug({ root, entries: status.entries.length }, "read uncommitted status"); + // A folder moved outside git: its deleted files and their untracked twins become renames, dated by the new file. + const entries = await pairStatusRenames(root, status.entries); - return uncommittedChanges(status.entries, mtimeUnder(root), now); + return uncommittedChanges(entries, mtimeUnder(root), now); } export async function readCommittedChanges({ cwd, commits }: { cwd?: string; commits: number }): Promise<FileChange[]> { diff --git a/src/git/lib/changes.ts b/src/git/lib/changes.ts index 8858725a37..1f8be69a57 100644 --- a/src/git/lib/changes.ts +++ b/src/git/lib/changes.ts @@ -201,6 +201,10 @@ export function describeUncommitted(status: string): string { return "copied (staged)"; } + if (unstaged === "R") { + return "moved (unstaged)"; + } + return status; } diff --git a/src/gitlab/README.md b/src/gitlab/README.md index b0e67a2b0b..bf561c8b40 100644 --- a/src/gitlab/README.md +++ b/src/gitlab/README.md @@ -202,7 +202,7 @@ Fixed in the next push. - **Actions**: `T` reply, reply-resolve, none; `Y` reply, none; `D` keep, reword, move, delete; `N` comment, none. - **Anchors** carry the text of the line: `check` refuses a line outside the diff, or a line whose text differs, and names the line where that text really is. - **`check`** also wants a `[NN%]` badge on every verdict, the text each action sends, and the configured `review.draftRules`. -- **`render`**: quoted comments are never shortened; text meant to be posted is a fenced block indented by three spaces (display only). An answer in my own thread is signed `[NN%] <agent>:` from its badge (`--agent`, default `Opus`). +- **`render`**: quoted comments are never shortened; text meant to be posted is a fenced block indented by three spaces (display only). An answer in my own thread is signed `[NN%] <agent>:` from its badge, and `--proposal` puts the same name into `author.agent` (`--agent`, default `$GENESIS_TOOLS_AGENT`, else `Opus`). A review merged from two models names both, e.g. `--agent "Opus and Fable"`. - **`comments post`**: a dry run lists each step; `--apply` posts them as drafts, stops at the first failure, reads every result back and records it in the ledger. A D draft that `publish` turned into a thread is answered with `--answers D05`. --- diff --git a/src/gitlab/commands/pr-review.ts b/src/gitlab/commands/pr-review.ts index 87513ecf80..11dc719671 100644 --- a/src/gitlab/commands/pr-review.ts +++ b/src/gitlab/commands/pr-review.ts @@ -30,6 +30,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { type FetchReviewOptions, runFetchReview } from "@app/gitlab/commands/fetch-review"; import { progress, type TargetOptions, withProject } from "@app/gitlab/commands/shared"; +import { defaultAgentName } from "@app/gitlab/lib/agent-name"; import { currentUser, type ProjectApi, resolveProjectApi } from "@app/gitlab/lib/client"; import { FETCH_FORMATS, loadConfig } from "@app/gitlab/lib/config"; import { gitResult } from "@app/gitlab/lib/git"; @@ -184,7 +185,11 @@ export function registerPrReview(pr: Command): Command { .option("--expand <ids>", "Print these ids in full, e.g. F02,T01 (--give reads the saved facts)") .option("--refresh", "--give: with --expand, collect the facts again instead of reading the saved ones") .option("--proposal-skeleton", "--give: print a review proposal JSON pre-filled from the facts") - .option("--agent <name>", "--give: author.agent in the proposal skeleton", "agent") + .option( + "--agent <name>", + "--give: author.agent in the proposal skeleton (default: $GENESIS_TOOLS_AGENT, else agent)", + defaultAgentName("agent") + ) .option("--no-impact", "--give: skip the scan of other open MRs") .option( "--impact-limit <n>", diff --git a/src/gitlab/commands/review-judgements.ts b/src/gitlab/commands/review-judgements.ts index 9a7f2a8be3..62a356eabf 100644 --- a/src/gitlab/commands/review-judgements.ts +++ b/src/gitlab/commands/review-judgements.ts @@ -16,6 +16,7 @@ import { resolve } from "node:path"; import { platform } from "node:process"; import { pickMode } from "@app/gitlab/commands/pr-review"; import { progress, type TargetOptions, withProject } from "@app/gitlab/commands/shared"; +import { defaultAgentName } from "@app/gitlab/lib/agent-name"; import { resolveProjectApi } from "@app/gitlab/lib/client"; import { loadConfig } from "@app/gitlab/lib/config"; import { parseJudgementsFile, reportPathFor } from "@app/gitlab/lib/judgements"; @@ -131,7 +132,11 @@ export function registerReviewJudgements(review: Command): void { "--repo <checkout>", "The checkout file links and code views come from (default: the current directory)" ) - .option("--agent <name>", "Who signs an answer in your own thread: [90%] <name>: …", "Opus") + .option( + "--agent <name>", + "Who signs: author.agent of the proposal and [90%] <name>: in your own thread (default: $GENESIS_TOOLS_AGENT, else Opus)", + defaultAgentName() + ) .option("--context-lines <n>", "Lines around each thread's anchor (default: review.fetch.contextLines)") ).action(runRender); } @@ -221,7 +226,11 @@ export function registerCommentsPost(comments: Command): void { .option("--do <ids>", "Run each item's Action, e.g. T01,D03,N01") .option("--answers <ids>", "Post each item's Proposed answer into my own thread (a D item after publish)") .option("--apply", "Post for real") - .option("--agent <name>", "Who signs an answer in your own thread: [90%] <name>: …", "Opus") + .option( + "--agent <name>", + "Who signs: author.agent of the proposal and [90%] <name>: in your own thread (default: $GENESIS_TOOLS_AGENT, else Opus)", + defaultAgentName() + ) ).action(runPost); } diff --git a/src/gitlab/lib/agent-name.ts b/src/gitlab/lib/agent-name.ts new file mode 100644 index 0000000000..5a4f831d18 --- /dev/null +++ b/src/gitlab/lib/agent-name.ts @@ -0,0 +1,10 @@ +import { env } from "@genesiscz/utils/env"; + +/** + * Who signs a review. `--agent` wins; without it the name comes from `GENESIS_TOOLS_AGENT`, so a Fable or Codex + * session names itself instead of inheriting "Opus". + */ +export function defaultAgentName(fallback = "Opus"): string { + const fromEnv = env.agents.getName()?.trim(); + return fromEnv ? fromEnv : fallback; +} diff --git a/src/gitlab/lib/client.ts b/src/gitlab/lib/client.ts index 6b2ac42d35..734809e3ab 100644 --- a/src/gitlab/lib/client.ts +++ b/src/gitlab/lib/client.ts @@ -524,6 +524,9 @@ export interface GitLabUser { name: string; web_url: string; state?: string; + /** Only `/user` (the token owner) returns these two. */ + email?: string; + commit_email?: string; } export interface GitLabProject { diff --git a/src/gitlab/lib/pr-review.test.ts b/src/gitlab/lib/pr-review.test.ts index 0a29c4982c..f54aadb09a 100644 --- a/src/gitlab/lib/pr-review.test.ts +++ b/src/gitlab/lib/pr-review.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { factsBaseName, factsPathOf } from "@app/gitlab/commands/pr-review"; +import { defaultAgentName } from "@app/gitlab/lib/agent-name"; import type { ProjectApi } from "@app/gitlab/lib/client"; import { mergeConfig, NEUTRAL_CONFIG } from "@app/gitlab/lib/config"; import { applyRefs, emptyIdMap } from "@app/gitlab/lib/ids"; @@ -41,6 +42,7 @@ import type { DraftSummary } from "@app/gitlab/lib/review-drafts"; import { collectThreadContext, type Discussion } from "@app/gitlab/lib/review-render"; import { parseProposal } from "@app/hub/lib/proposal"; import { type DetectProbes, detectGenesisTools, resetGenesisToolsCache } from "@genesiscz/utils/cli/genesis-tools"; +import { env } from "@genesiscz/utils/env"; import { json2md } from "@genesiscz/utils/json2md"; const GIT_DIFF = [ @@ -978,3 +980,22 @@ describe("the api impact source", () => { } }); }); + +describe("defaultAgentName", () => { + test("GENESIS_TOOLS_AGENT names the signer, trimmed", async () => { + await env.testing.withOverrides({ GENESIS_TOOLS_AGENT: " Fable " }, async () => { + expect(defaultAgentName()).toBe("Fable"); + expect(defaultAgentName("agent")).toBe("Fable"); + }); + }); + + test("an unset or blank variable falls back", async () => { + await env.testing.withOverrides({ GENESIS_TOOLS_AGENT: " " }, async () => { + expect(defaultAgentName()).toBe("Opus"); + expect(defaultAgentName("agent")).toBe("agent"); + }); + await env.testing.withOverrides({ GENESIS_TOOLS_AGENT: undefined }, async () => { + expect(defaultAgentName()).toBe("Opus"); + }); + }); +}); diff --git a/src/hub/README.md b/src/hub/README.md index 8d15afdb12..895c3f310c 100644 --- a/src/hub/README.md +++ b/src/hub/README.md @@ -29,6 +29,19 @@ A missing PR commit does not trigger a project-diff lookup. To open that commit Other `open` flags: `--agent`, `--filter`, `--palette [text]`, `--find [text]`, `--session-search [text]`, `--digest`, `--rules`, `--prompts`, `--handoff`, `--no-build` (`tools hub open --help`). +## History: Back, Forward and who went where + +The hub keeps a log of every place it showed, with who took it there: `user` (a click, a key or a menu in the hub), `agent`, or `link` (a link or `tools hub open` that named nobody). An agent adds why. The top-left arrows (⌘[ and ⌘]) walk it, and the clock button beside them (⌘Y) lists it, newest first; a click hops to any entry. A new place never replaces the old one: Back always returns. + +**Agents open things with `nav open`, not `open`**, so the person sees why and can go back: + + tools hub nav open '#563' --note "the PR whose Swift suite fails" + tools hub nav open --repo /path/to/checkout --commit 4e7a3bc --note "the commit that moved the focus" + tools hub nav open 'genesis-tools://hub?pr=owner%2Frepo%2342&commit=4e7a3bc' --note "why" + tools hub nav list [--json] [--limit 20] + +`nav open` takes a hub link, a PR ref or page, a commit sha (with `--pr` or `--repo`), a checkout folder, or the `open` flags. It records the entry in the hub's own database (`~/.genesis-tools/hub/data.db`, one file for every hub feature: a new one adds its migrations to `src/hub/lib/data-db.ts`), then opens the hub on it. `--actor` defaults to `agent` inside an agent session (its id goes in as `--from`), else `link`. Settings → Hub → History sets how many places are kept (200) and whether an agent's place is shown at once (the default) or only added to History. Every entry writes one `hub nav:` line to the day log. + ## Links `--print-url` prints the link for the same flags instead of opening: @@ -36,7 +49,7 @@ Other `open` flags: `--agent`, `--filter`, `--palette [text]`, `--find [text]`, tools hub open --print-url --pr 'owner/repo#42' --commit 4e7a3bc # genesis-tools://hub?pr=owner%2Frepo%2342&commit=4e7a3bc -A `genesis-tools://hub?…` link opens the hub where it points, with or without the browser router. Its keys are the `open` flags without dashes: `mode`, `session`, `agent`, `pr`, `reveal`, `commit`, `repo`, `tab`, `filter`, `worktree`, `decision`, `question` (`HubRequest.linkKeys` in `src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift`). Every other key is dropped: a link shows a place, it never runs a command. The `#` of a PR ref must be written `%23`. +A `genesis-tools://hub?…` link opens the hub where it points, with or without the browser router. Its keys are the `open` flags without dashes: `mode`, `session`, `agent`, `pr`, `reveal`, `commit`, `repo`, `tab`, `filter`, `worktree`, `decision`, `question`, and for the history `actor` (user, agent, link), `note`, `from` (the agent's session) and `nav` (an entry `nav open` stored) (`HubRequest.linkKeys` in `src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift`). Every other key is dropped: a link shows a place, it never runs a command. The `#` of a PR ref must be written `%23`. Through the browser router, the same query on the link host opens the hub as well, so a terminal or a chat that only makes `https://` clickable works too: diff --git a/src/hub/cli.ts b/src/hub/cli.ts index 78aa92716d..107e800bfd 100644 --- a/src/hub/cli.ts +++ b/src/hub/cli.ts @@ -23,6 +23,7 @@ import { registerDevCommands } from "./commands/dev"; import { registerDigestCommand } from "./commands/digest"; import { registerForecastCommand } from "./commands/forecast"; import { registerInsightsCommands } from "./commands/insights"; +import { registerNavCommands } from "./commands/nav"; import { registerNotifyCommands } from "./commands/notify"; import { registerProcsCommand } from "./commands/procs"; import { registerPromptsCommand } from "./commands/prompts"; @@ -1284,6 +1285,7 @@ registerSearchCommand(program); registerDigestCommand(program); registerForecastCommand(program); registerRulesCommand(program); +registerNavCommands(program); registerInsightsCommands(program); registerProcsCommand(program); registerPromptsCommand(program); diff --git a/src/hub/commands/dev.ts b/src/hub/commands/dev.ts index ea72f8dd68..b26bf71c4b 100644 --- a/src/hub/commands/dev.ts +++ b/src/hub/commands/dev.ts @@ -64,7 +64,7 @@ export function registerDevCommands(program: Command): void { ) .action(async (flags: MonitorFlags) => { const sources = devMonitorSources(); - const start = `[${new Date().toTimeString().slice(0, 8)}] monitor watching ${sources.perfLog}, ${sources.profileLog()}, ${sources.relayLog}, ${sources.hangs}, ${sources.crashes}`; + const start = `[${new Date().toTimeString().slice(0, 8)}] monitor watching ${sources.perfLog}, ${sources.genesisPerfLog}, ${sources.profileLog()}, ${sources.relayLog}, ${sources.hangs}, ${sources.crashes}`; out.print(`${flags.json ? SafeJSON.stringify({ kind: "start", text: start }) : start}\n`); await withInterrupt((signal) => runDevMonitor({ diff --git a/src/hub/commands/nav.ts b/src/hub/commands/nav.ts new file mode 100644 index 0000000000..727bc288e4 --- /dev/null +++ b/src/hub/commands/nav.ts @@ -0,0 +1,352 @@ +import { statSync } from "node:fs"; +import { resolve } from "node:path"; +import { createBuildProgressDisplay } from "@app/macos/lib/permissions/swift-progress"; +import { agentSessionIds } from "@genesiscz/utils/agent/host"; +import { suggestEnumFlag } from "@genesiscz/utils/cli"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { env } from "@genesiscz/utils/env"; +import { formatRelativeTime } from "@genesiscz/utils/format"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger, out } from "@genesiscz/utils/logger"; +import { createBoxTable, renderCliHeader, truncateDisplay } from "@genesiscz/utils/table"; +import { type Command, InvalidArgumentError } from "commander"; +import pc from "picocolors"; +import { hubDbPath, openHubDb } from "../lib/data-db"; +import { listNav, navKeep, recordNav } from "../lib/navigation/store"; +import { navTargetProblem, navTitle, parseNavInput } from "../lib/navigation/target"; +import { + isNavActor, + NAV_ACTORS, + type NavActor, + type NavEntry, + type NavTarget, + normalizeTarget, +} from "../lib/navigation/types"; +import { HUB_MODES, HUB_TABS, hubUrl, openHub } from "../lib/open"; + +const { log } = logger.scoped("hub:nav"); + +function positiveInt(value: string): number { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError("a whole number above 0"); + } + + return parsed; +} + +function isFolder(path: string): boolean { + try { + return statSync(resolve(path)).isDirectory(); + } catch (error) { + log.debug({ path, error }, "hub nav: not a folder"); + return false; + } +} + +/** The one log line per navigation, in the day log (`~/.genesis-tools/logs/<date>.log`). */ +function logNavigation(entry: NavEntry, via: string): void { + // Debug: the day log keeps every level, and an agent's terminal does not need the block. + log.debug( + { via, actor: entry.actor, target: entry.target, note: entry.note, from: entry.from, id: entry.id }, + `hub nav: ${entry.actor} → ${entry.title}${entry.note ? ` (${entry.note})` : ""}` + ); +} + +/** The calling agent's own session, so an agent need not pass `--from`. */ +function callerSession(): string | undefined { + return agentSessionIds(env.getProcessEnv())[0]?.id; +} + +function actorFlag(value: string | undefined, fallback: NavActor): NavActor | null { + if (value === undefined) { + return fallback; + } + + if (isNavActor(value)) { + return value; + } + + out.log.error(suggestEnumFlag(toolCommand("hub nav open"), "--actor", NAV_ACTORS, { given: value })); + return null; +} + +interface NavOpenFlags { + pr?: string; + commit?: string; + repo?: string; + worktree?: string; + session?: string; + agent?: string; + mode?: string; + tab?: string; + reveal?: string; + actor?: string; + note?: string; + from?: string; + printUrl?: boolean; + activate: boolean; + build: boolean; +} + +function flagTarget(flags: NavOpenFlags): NavTarget { + return normalizeTarget({ + pr: flags.pr, + commit: flags.commit, + repo: flags.repo ? (/^\.{1,2}(?:[/\\]|$)/.test(flags.repo) ? resolve(flags.repo) : flags.repo) : undefined, + worktree: flags.worktree ? resolve(flags.worktree) : undefined, + session: flags.session, + agent: flags.agent, + mode: flags.mode, + tab: flags.tab, + reveal: flags.reveal, + }); +} + +async function navOpen(input: string | undefined, flags: NavOpenFlags): Promise<void> { + const parsed = parseNavInput(input ?? "", isFolder); + + if ("error" in parsed) { + out.log.error(parsed.error); + process.exitCode = 1; + return; + } + + const target: NavTarget = { ...parsed.target, ...flagTarget(flags) }; + const problem = navTargetProblem(target); + + if (problem) { + out.log.error(problem); + process.exitCode = 1; + return; + } + + if (target.mode !== undefined && !(HUB_MODES as readonly string[]).includes(target.mode)) { + out.log.error(suggestEnumFlag(toolCommand("hub nav open"), "--mode", HUB_MODES, { given: target.mode })); + process.exitCode = 1; + return; + } + + if (target.tab !== undefined && !(HUB_TABS as readonly string[]).includes(target.tab)) { + out.log.error(suggestEnumFlag(toolCommand("hub nav open"), "--tab", HUB_TABS, { given: target.tab })); + process.exitCode = 1; + return; + } + + const from = flags.from ?? parsed.from ?? callerSession(); + // An agent's shell names its session in the environment; anything else that asked is a link. + const actor = actorFlag(flags.actor ?? parsed.actor, from ? "agent" : "link"); + + if (actor === null) { + process.exitCode = 1; + return; + } + + const note = flags.note ?? parsed.note; + const mode = HUB_MODES.find((value) => value === target.mode); + const tab = HUB_TABS.find((value) => value === target.tab); + const link = { + mode, + session: target.session, + agent: target.agent, + pr: target.pr, + reveal: target.reveal, + repo: target.repo, + commit: target.commit, + tab, + worktree: target.worktree, + actor, + note, + from, + }; + + if (flags.printUrl) { + out.println(hubUrl(link)); + return; + } + + const entry: NavEntry = { id: crypto.randomUUID(), at: Date.now(), actor, title: navTitle(target), target }; + + if (note) { + entry.note = note; + } + + if (from) { + entry.from = from; + } + + const db = openHubDb(); + + try { + recordNav(db, entry); + } finally { + db.close(); + } + + logNavigation(entry, "cli"); + const progress = createBuildProgressDisplay({ + step: (message) => out.log.step(message), + spinner: () => out.spinner(), + }); + + try { + const opened = await openHub({ + ...link, + nav: entry.id, + activate: flags.activate, + build: flags.build, + onStep: progress.onStep, + onProgress: progress.onProgress, + }); + progress.finish(); + out.log.success( + `Hub opened on ${entry.title}${opened.built ? " (app rebuilt first)" : ""}; recorded as ${actor}` + ); + } catch (error) { + progress.finish(); + out.log.error( + `Recorded ${entry.title}, but the hub did not open: ${error instanceof Error ? error.message : error}` + ); + process.exitCode = 1; + } +} + +interface NavRecordFlags { + id: string; + actor: string; + title: string; + target: string; + at?: string; + note?: string; + from?: string; + keep?: number; + json?: boolean; +} + +/** The app's door: one entry the hub's engine made or changed. */ +function navRecord(flags: NavRecordFlags): void { + if (!isNavActor(flags.actor)) { + out.log.error(suggestEnumFlag(toolCommand("hub nav record"), "--actor", NAV_ACTORS, { given: flags.actor })); + process.exitCode = 1; + return; + } + + const target = normalizeTarget(SafeJSON.parse(flags.target, { strict: true })); + const at = flags.at === undefined ? Date.now() : Number(flags.at); + const entry: NavEntry = { + id: flags.id, + at: Number.isFinite(at) ? at : Date.now(), + actor: flags.actor, + title: flags.title, + target, + }; + + if (flags.note) { + entry.note = flags.note; + } + + if (flags.from) { + entry.from = flags.from; + } + + const db = openHubDb(); + + try { + const result = recordNav(db, entry, flags.keep); + logNavigation(entry, "hub"); + + if (flags.json) { + out.result({ entry, ...result }); + } + } finally { + db.close(); + } +} + +function navList(flags: { json?: boolean; limit?: number }): void { + const db = openHubDb(); + + try { + const entries = listNav(db, flags.limit); + + if (flags.json) { + out.result({ entries, keep: navKeep(db), path: hubDbPath() }); + return; + } + + renderCliHeader("Hub history", `${entries.length} places, newest first · ${hubDbPath()}`); + const table = createBoxTable(["WHEN", "WHO", "PLACE", "NOTE"]); + const tint = { user: pc.dim, agent: pc.magenta, link: pc.cyan } as const; + + for (const entry of entries) { + table.push([ + formatRelativeTime(new Date(entry.at)), + tint[entry.actor](entry.actor), + truncateDisplay(entry.title, 60), + truncateDisplay(entry.note ?? "", 60), + ]); + } + + out.println(table.toString()); + out.println(pc.dim(`Open one: ${toolCommand("hub nav open")} <link> --note "<why>"`)); + } finally { + db.close(); + } +} + +export function registerNavCommands(program: Command): void { + const nav = program + .command("nav") + .description( + "The hub's navigation history: every place it showed, who took it there (user, agent, link) and why. Back, Forward and History (⌘Y) in the hub walk it" + ); + + nav.command("list") + .description("The places, newest first") + .option("--json", "machine-readable output: { entries, keep, path }") + .option("--limit <n>", "at most this many", positiveInt) + .action((flags: { json?: boolean; limit?: number }) => navList(flags)); + + nav.command("open") + .description( + `Open the hub on a place AND record who asked and why. Agents: use this instead of ${toolCommand("hub open")}, so the person can go Back` + ) + .argument( + "[link-or-target]", + "a hub link, a PR (#563, group/app!12, a PR page), a commit sha, or a checkout folder" + ) + .option("--pr <ref>", "the PR/MR: 42, group/app#42, or its page URL") + .option("--commit <sha>", "a commit (7 to 40 hex characters) or <from>..<to>, in --pr or in --repo") + .option("--repo <path|owner/repo>", "with --commit and no --pr: the commit's checkout or forge project") + .option("--worktree <path>", "open the Worktrees mode on this checkout") + .option("--session <id>", "select this session (id or prefix)") + .option("--agent <id>", "open this agent in the Agents mode") + .option("--mode <mode>", HUB_MODES.join("|")) + .option("--tab <tab>", HUB_TABS.join("|")) + .option("--reveal <path>", "with --pr: this repo-relative file in its review") + .option("--actor <actor>", `${NAV_ACTORS.join("|")} (default: agent inside an agent session, else link)`) + .option("--note <text>", "why you open it; the hub's History shows it under the place") + .option("--from <session>", "the agent session that asks (default: this shell's own agent session)") + .option("--print-url", "print the hub link (with actor, note, from) instead of opening and recording") + .option("--no-activate", "open behind the window in front, without taking focus") + .option("--no-build", "never build; fail when GenesisTools.app is missing or stale") + .action(async (input: string | undefined, flags: NavOpenFlags) => navOpen(input, flags)); + + nav.command("record") + .description("Store one entry (GenesisTools.app's door: every place the hub shows)") + .requiredOption("--id <id>", "the entry id; an existing one is replaced") + .requiredOption("--actor <actor>", NAV_ACTORS.join("|")) + .requiredOption("--title <text>", "what the place is") + .requiredOption("--target <json>", 'the place as the hub\'s link query, JSON: {"pr":"…","commit":"…"}') + .option("--at <ms>", "when, milliseconds since the epoch (default now)") + .option("--note <text>", "why") + .option("--from <session>", "the agent session that asked") + .option( + "--keep <n>", + "keep the newest n entries (the hub's History setting); stored for later writers", + positiveInt + ) + .option("--json", "print the stored entry") + .action((flags: NavRecordFlags) => navRecord(flags)); +} diff --git a/src/hub/lib/agents/codex-agents.test.ts b/src/hub/lib/agents/codex-agents.test.ts index 55c4b4dce1..8222095b8b 100644 --- a/src/hub/lib/agents/codex-agents.test.ts +++ b/src/hub/lib/agents/codex-agents.test.ts @@ -1,7 +1,17 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, utimesSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import * as storage from "@genesiscz/utils/storage/storage"; import { @@ -362,6 +372,43 @@ describe("attachCodexAgents", () => { }); }); +describe("a compressed rollout (.jsonl.zst, one zstd frame as Codex 0.164 writes it)", () => { + test("reads the head, model and tool calls of its content, and a closed rollout is never running", () => { + const dir = temp(); + const plain = rollout( + dir, + CHILD_A, + meta({ id: CHILD_A, parent: ROOT, depth: 1, nickname: "Cicero", role: "worker", path: "/root/a" }) + + turnContext("gpt-6.1-sol") + + turn("task_started") + + call("custom_tool_call") + + call("function_call") + + call("custom_tool_call_output"), + 30_000 + ); + const zst = { filePath: `${plain.filePath}.zst`, mtime: plain.mtime }; + writeFileSync(zst.filePath, compressBytes(readFileSync(plain.filePath))); + utimesSync(zst.filePath, zst.mtime / 1000, zst.mtime / 1000); + + expect(codexAgentHeadOf(zst.filePath)).toEqual(codexAgentHeadOf(plain.filePath)); + expect(codexAgentHeadOf(zst.filePath)?.nickname).toBe("Cicero"); + expect(codexAgentModel(zst.filePath)).toBe("gpt-6.1-sol"); + // Fresh and mid-turn: the plain rollout runs; the compressed one was closed by Codex. + expect(codexTurnState({ filePath: plain.filePath, mtime: plain.mtime, now: NOW })).toBe("running"); + expect(codexTurnState({ filePath: zst.filePath, mtime: zst.mtime, now: NOW })).toBe("killed"); + + const cache = join(dir, "cache.json"); + const nodeOf = () => + (attachCodexAgents([record(CHILD_A, zst)], { now: NOW, cachePath: cache, model: null }).get(ROOT) ?? [])[0]; + + expect(nodeOf()).toMatchObject({ name: "Cicero", model: "gpt-6.1-sol", status: "killed", toolCalls: 2 }); + // Counted to its end once: the entry's offset is its size on disk, so the next read answers from the entry. + const saved: unknown = SafeJSON.parse(readFileSync(cache, "utf8"), { strict: true }); + expect(saved).toMatchObject({ [zst.filePath]: { offset: statSync(zst.filePath).size, calls: 2 } }); + expect(nodeOf()?.toolCalls).toBe(2); + }); +}); + describe("buildCodexParent", () => { const row: ParentRow = { provider: "codex", diff --git a/src/hub/lib/agents/codex-agents.ts b/src/hub/lib/agents/codex-agents.ts index 10024a910a..581a657bd0 100644 --- a/src/hub/lib/agents/codex-agents.ts +++ b/src/hub/lib/agents/codex-agents.ts @@ -2,6 +2,9 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, sta import { basename, dirname } from "node:path"; import { POLLED_LISTING_REUSE_MS } from "@app/ai/lib/sessions/agent-session-rows"; import { catalogHistory } from "@genesiscz/utils/agent-sessions/open-service"; +import { readHead, readTailWindow } from "@genesiscz/utils/ai/transcripts/native-scan"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readLinesSync } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { toolDataDir } from "@genesiscz/utils/storage/root"; @@ -92,6 +95,7 @@ export async function recentCodexAgentRecords({ ); } +/** A plain rollout's bytes. A `.jsonl.zst` one is read through `readHead` / `readTailWindow` and never by offset. */ function readBytes(path: string, position: number, length: number): Buffer { const fd = openSync(path, "r"); @@ -117,7 +121,7 @@ export function codexAgentHeadOf(path: string): CodexAgentHead | null { let head: string; try { - head = readBytes(path, 0, HEAD_BYTES).toString("utf8"); + head = readHead(path, HEAD_BYTES).toString("utf8"); } catch (error) { logger.debug({ error, path }, "[hub agents] unreadable codex agent head"); return null; @@ -149,15 +153,16 @@ export function codexAgentHeadOf(path: string): CodexAgentHead | null { * - `turn_aborted`: interrupted. * - `task_started` with nothing after it: mid-turn, running while the file is fresh and killed once * it went quiet. + * + * A `.jsonl.zst` rollout is one Codex closed and compressed, so it is never running: as if it went quiet. */ export function codexTurnState(options: { filePath: string; mtime: number; now: number }): AgentStatus { const { filePath, mtime, now } = options; + const closed = isZstdPath(filePath); let tail: string; try { - const size = statSync(filePath).size; - - tail = readBytes(filePath, Math.max(0, size - TAIL_BYTES), TAIL_BYTES).toString("utf8"); + tail = readTailWindow(filePath, TAIL_BYTES).buffer.toString("utf8"); } catch (error) { logger.debug({ error, filePath }, "[hub agents] unreadable codex agent tail"); return "completed"; @@ -178,10 +183,10 @@ export function codexTurnState(options: { filePath: string; mtime: number; now: } if (last === "task_started") { - return now - mtime < TURN_STALE_MS ? "running" : "killed"; + return !closed && now - mtime < TURN_STALE_MS ? "running" : "killed"; } - return now - mtime < NO_MARKER_FRESH_MS ? "running" : "completed"; + return !closed && now - mtime < NO_MARKER_FRESH_MS ? "running" : "completed"; } /** @@ -192,7 +197,7 @@ export function codexAgentModel(path: string): string | null { let text: string; try { - text = readBytes(path, 0, MODEL_SCAN_BYTES).toString("utf8"); + text = readHead(path, MODEL_SCAN_BYTES).toString("utf8"); } catch (error) { logger.debug({ error, path }, "[hub agents] unreadable codex agent model"); return null; @@ -229,9 +234,10 @@ interface ScanEntry { type ScanCache = Record<string, ScanEntry>; -const CALL_MARKERS = [Buffer.from('"type":"custom_tool_call"'), Buffer.from('"type":"function_call"')]; +const CALL_MARKERS = ['"type":"custom_tool_call"', '"type":"function_call"']; -function countCalls(buffer: Buffer): number { +/** Markers are ASCII, so a match's length is the same in a string and in its bytes. */ +function countCalls(buffer: Buffer | string): number { let count = 0; for (const marker of CALL_MARKERS) { @@ -246,8 +252,25 @@ function countCalls(buffer: Buffer): number { return count; } -/** Tool calls of an append-only rollout, counting only what was appended since the last read. */ +/** + * Tool calls of an append-only rollout, counting only what was appended since the last read. A `.jsonl.zst` rollout + * never grows: its decompressed lines are counted once, and the entry (offset = its size on disk) answers after that. + */ function scanCalls(path: string, size: number, entry: ScanEntry | undefined): { calls: number; offset: number } { + if (isZstdPath(path)) { + if (entry && entry.size === size && entry.offset === size) { + return { calls: entry.calls, offset: size }; + } + + let counted = 0; + + for (const line of readLinesSync(path)) { + counted += countCalls(line); + } + + return { calls: counted, offset: size }; + } + let calls = entry && entry.size <= size ? entry.calls : 0; let offset = entry && entry.size <= size ? entry.offset : 0; @@ -331,7 +354,8 @@ function nodeOf( if (model === null && !modelFinal) { model = codexAgentModel(record.filePath); - modelFinal = model !== null || size > MODEL_SCAN_BYTES; + // A compressed rollout is closed: a model its head does not name never comes. + modelFinal = model !== null || size > MODEL_SCAN_BYTES || isZstdPath(record.filePath); } const next: ScanEntry = { diff --git a/src/hub/lib/agents/parent.ts b/src/hub/lib/agents/parent.ts index c5e602ea91..6de41680f9 100644 --- a/src/hub/lib/agents/parent.ts +++ b/src/hub/lib/agents/parent.ts @@ -1,5 +1,5 @@ -import { closeSync, openSync, readSync } from "node:fs"; import { readTaskNotifications } from "@genesiscz/utils/ai/transcripts/file-scan"; +import { readHead } from "@genesiscz/utils/ai/transcripts/native-scan"; import { listSubagents } from "@genesiscz/utils/ai/transcripts/subagents"; import { logger } from "@genesiscz/utils/logger"; import { readSessionTeam, unreadInbox } from "./team"; @@ -9,21 +9,13 @@ import type { WorkerAgent } from "./workers"; const HEAD_BYTES = 16 * 1024; -/** The first `timestamp` in a transcript's head: when the session started. */ +/** The first `timestamp` in a transcript's head (a `.jsonl.zst` rollout's decompressed head): when the session started. */ export function startedAtOf(path: string): string | null { - let fd: number | null = null; try { - fd = openSync(path, "r"); - const buffer = Buffer.alloc(HEAD_BYTES); - const read = readSync(fd, buffer, 0, HEAD_BYTES, 0); - return /"timestamp":"([^"]+)"/.exec(buffer.subarray(0, read).toString("utf8"))?.[1] ?? null; + return /"timestamp":"([^"]+)"/.exec(readHead(path, HEAD_BYTES).toString("utf8"))?.[1] ?? null; } catch (error) { logger.debug({ error, path }, "[hub agents] unreadable parent head"); return null; - } finally { - if (fd !== null) { - closeSync(fd); - } } } diff --git a/src/hub/lib/composer/dispatch.ts b/src/hub/lib/composer/dispatch.ts index b6cfd527dc..bc15b0a6ef 100644 --- a/src/hub/lib/composer/dispatch.ts +++ b/src/hub/lib/composer/dispatch.ts @@ -14,6 +14,11 @@ import { boundedCommand } from "@genesiscz/utils/process/bounded-command"; import type { WidgetAsset, WidgetOutgoing } from "../widget/types"; import type { OutboxDispatcher } from "./engine"; +/** The transport the widget delivers through: the `tools` runner of the widget's own checkout. */ +export function widgetDeliverDeps(signal?: AbortSignal): DeliverDeps { + return { runTool: (args: string[]) => runWidgetDelivery({ args, signal }) }; +} + async function runWidgetDelivery({ args, signal }: { args: string[]; signal?: AbortSignal }) { const nativeTurn = (args[0] === "claude" && args[1] === "worker") || args[0] === "grok"; const result = await boundedCommand({ diff --git a/src/hub/lib/composer/outbox.test.ts b/src/hub/lib/composer/outbox.test.ts index d6fba0282e..9e746434b4 100644 --- a/src/hub/lib/composer/outbox.test.ts +++ b/src/hub/lib/composer/outbox.test.ts @@ -70,13 +70,16 @@ import { readShelfAttachment, removeShelfItem, stageShelfImage, + stageShelfMedia, } from "../widget/shelf"; import { claudeAssistantText, discoverWidgetCatalog, + INBOX_ITEMS_PER_SESSION, inboxWindowCounts, lastAssistantTextOf, lastClaudeAssistantText, + listedInboxItems, readWidgetChanges, readWidgetDecisionEvents, realWidgetSources, @@ -87,6 +90,7 @@ import { widgetSnapshot, widgetTitle, } from "../widget/snapshot"; +import { isStaged, resendAnsweredDecisions, sendStagedDecisions, unstageDecisions } from "../widget/staged"; import { MAX_WIDGET_STATE_BYTES, mutateWidgetState, readWidgetState } from "../widget/storage"; import { parseWidgetSessionKey, @@ -99,13 +103,15 @@ import { widgetSessionKey, } from "../widget/types"; import { + addLiveVoiceNote, discardVoiceNote, editVoiceNote, + incomingClipPath, listVoiceNotes, recordVoiceNote, transcribeVoiceNote, } from "../widget/voice-notes"; -import { watchWidget } from "../widget/watch"; +import { StdinLines, WARM_SESSIONS, watchWidget, widgetSelectionLine } from "../widget/watch"; import { importWidgetAsset, reviseVideoAsset } from "./assets"; import { widgetDeliveryReceipt, widgetDispatcher } from "./dispatch"; import { processWidgetOutbox } from "./engine"; @@ -619,6 +625,99 @@ describe("widget source and delivery contracts", () => { ).rejects.toThrow("changed"); }); + test("staged decisions go one message per session; unstaging keeps them waiting", async () => { + const directory = await root(); + const files = { file: join(directory, "decisions.jsonl"), events: join(directory, "events.jsonl") }; + const post = (sessionId: string, prompts: string[]) => + postDecisions( + files.file, + files.events, + { + sessionId, + provider: "codex", + decisions: prompts.map((prompt) => ({ prompt, options: ["yes", "no"] })), + }, + { env: {} } + ); + const [a1, a2, a3] = await post("staged-a", ["First?", "Second?", "Left alone?"]); + const [b1] = await post("staged-b", ["Other?"]); + await updateDecisions(files.file, files.events, { + updates: [ + { id: a1.id, state: "drafted", draftOption: "a" }, + { id: a2.id, state: "drafted", draft: "only if it is cheap" }, + { id: a3.id, state: "drafted", draftOption: "b" }, + { id: b1.id, state: "drafted", draftOption: "b" }, + ], + }); + const sent: string[] = []; + const result = await sendStagedDecisions({ + ids: [a1.id, b1.id, a2.id], + files, + deliver: { + codexWorkerFor: () => "fixture-worker", + runTool: async (args: string[]) => { + sent.push(args.join(" ")); + return { success: true, stdout: '{"queued":false,"turnId":"fixture-turn"}', stderr: "" }; + }, + }, + }); + expect(result).toMatchObject({ sent: 3, queued: 0, failed: 0 }); + expect(result.sessions.map((entry) => [entry.session, entry.numbers])).toEqual([ + ["staged-a", [1, 2]], + ["staged-b", [1]], + ]); + expect(sent).toHaveLength(2); + const rows = readDecisions(files.file); + const row = (id: string) => rows.find((entry) => entry.id === id); + expect(row(a1.id)).toMatchObject({ option: "a" }); + expect(row(a2.id)?.answer).toBe("only if it is cheap"); + expect(row(a1.id)?.draftOption).toBeUndefined(); + // Not chosen for this send: still staged, still waiting. + expect(row(a3.id)).toMatchObject({ state: "drafted", draftOption: "b" }); + + expect(await unstageDecisions({ ids: [a3.id, a1.id], files })).toBe(1); + const after = readDecisions(files.file).find((entry) => entry.id === a3.id); + expect(after?.state).toBe("drafted"); + expect(after?.draftOption ?? "").toBe(""); + expect(after && isStaged(after)).toBe(false); + }); + + test("a staged send no receiver took counts as queued, and Send again delivers it later", async () => { + const directory = await root(); + const files = { file: join(directory, "decisions.jsonl"), events: join(directory, "events.jsonl") }; + const [one] = await postDecisions( + files.file, + files.events, + { sessionId: "queued-a", provider: "codex", decisions: [{ prompt: "Ship it?", options: ["yes", "no"] }] }, + { env: {} } + ); + await updateDecisions(files.file, files.events, { + updates: [{ id: one.id, state: "drafted", draftOption: "a" }], + }); + const first = await sendStagedDecisions({ ids: [one.id], files, deliver: { codexWorkerFor: () => null } }); + expect(first).toMatchObject({ sent: 0, queued: 1, failed: 0 }); + expect(first.sessions[0]?.result).toMatchObject({ + delivered: false, + detail: expect.stringContaining("worker"), + }); + expect(readDecisions(files.file).find((row) => row.id === one.id)?.state).toBe("answered"); + + const sent: string[] = []; + const again = await resendAnsweredDecisions({ + ids: [one.id], + files, + deliver: { + codexWorkerFor: () => "fixture-worker", + runTool: async (args: string[]) => { + sent.push(args.join(" ")); + return { success: true, stdout: '{"queued":false,"turnId":"fixture-turn"}', stderr: "" }; + }, + }, + }); + expect(again).toMatchObject({ sent: 1, queued: 0, failed: 0 }); + expect(sent).toHaveLength(1); + }); + test("a media-only reply keeps the original session and card title in one literal message", async () => { const directory = await root(); const files = { file: join(directory, "decisions.jsonl"), events: join(directory, "events.jsonl") }; @@ -1244,7 +1343,8 @@ test("malformed stored times sort as the oldest activity instead of breaking the root: directory, sources: { ...emptySources, - decisions: () => readDecisions(file).map((row) => ({ ...row, updatedTs: "not a time" })), + decisions: () => + readDecisions(file).map((row) => ({ ...row, createdTs: "not a time", updatedTs: "not a time" })), }, }); expect(snapshot.sessions[0]?.activityAt).toBe(0); @@ -1677,10 +1777,32 @@ test("decision cards carry recorded answers and supersede links; only open decis number: 3, title: widgetTitle(replacement.prompt), }); + // The overview lists the session's entries, the waiting one first. + expect(session?.items?.[0]).toMatchObject({ id: `decision:${replacement.id}`, needsAnswer: true }); expect( snapshot.notifications?.sessions.find((entry) => entry.pendingItem?.sourceId === elsewhere.id)?.pendingItem ).toMatchObject({ number: 1, title: "Other" }); + // A saved draft updates the row but asks nothing new: the item keeps the time it was asked, so the widget + // sees no new arrival (it chimed and flashed on every Save draft, 2026-10-23). + const askedAt = session?.pendingItem?.at; + await Bun.sleep(5); + await updateDecisions(file, events, { + updates: [{ id: replacement.id, state: "drafted", draft: "back both up", expectedRevision: 1 }], + }); + const drafted = await widgetSnapshot({ root: directory, sources }); + expect(drafted.notifications?.sessions.find((entry) => entry.key === sessionKey)?.pendingItem?.at).toBe(askedAt); + expect(drafted.cards.find((entry) => entry.sourceId === replacement.id)?.at).toBe(askedAt); + // The staged list is its own, uncapped: the widget sends from it, not from a session's eight listed items. + expect(drafted.notifications?.staged.map((entry) => entry.sourceId)).toEqual([replacement.id]); + // "Mark all read" stops the badge counting it, but the staged pick is unsent input and stays. + await Bun.sleep(5); + await performWidgetAction({ root: directory, input: { action: "inbox-clear", at: Date.now() } }); + const cleared = await widgetSnapshot({ root: directory, sources }); + expect(cleared.notifications?.sessions.find((entry) => entry.key === sessionKey)?.needsAnswer ?? 0).toBe(0); + expect(cleared.notifications?.staged.map((entry) => entry.sourceId)).toEqual([replacement.id]); + expect(readDecisions(file).find((row) => row.id === replacement.id)?.updatedTs).not.toBe(replacement.createdTs); + await updateDecisions(file, events, { updates: [{ id: replacement.id, state: "superseded", supersededBy: elsewhere.id, comment: "moved" }], }); @@ -1692,6 +1814,23 @@ test("decision cards carry recorded answers and supersede links; only open decis title: "Other", }); expect(moved.notifications?.sessions.find((entry) => entry.key === sessionKey)?.needsAnswer ?? 0).toBe(0); + expect(moved.notifications?.staged).toEqual([]); +}); + +test("a session lists its waiting entries first, then the newest, at most eight", () => { + const item = (id: string, at: number, needsAnswer = false) => ({ + id, + sourceId: id, + kind: needsAnswer ? ("decision" as const) : ("answer" as const), + key: "k", + at, + needsAnswer, + }); + const items = [item("old", 1), item("ask", 2, true), ...Array.from({ length: 9 }, (_, i) => item(`n${i}`, 10 + i))]; + const listed = listedInboxItems(items); + expect(listed).toHaveLength(INBOX_ITEMS_PER_SESSION); + expect(listed.map((entry) => entry.id).slice(0, 3)).toEqual(["ask", "n8", "n7"]); + expect(listed.map((entry) => entry.id)).not.toContain("old"); }); test("alert preferences default on with the Glass sound, load from an older state, and refuse a bad sound", async () => { @@ -2120,7 +2259,7 @@ describe("durable capture and file shelf", () => { const { item } = await importShelfFile({ root: directory, input }); await unlink(item.path); await expect(attachShelfItem({ root: directory, id: item.id, key: widgetSessionKey(target) })).rejects.toThrow( - "unavailable" + "no longer on the shelf" ); expect((await readWidgetState(directory)).drafts).toEqual({}); }); @@ -2298,6 +2437,36 @@ test("explicit shelf images deduplicate while general image files remain file re expect((await listWidgetShelf(directory)).items).toHaveLength(2); }); +test("a screen recording stages as a video capture and attaches to a draft as that video asset", async () => { + const directory = await root(); + const input = join(directory, "recording-20261023-221500.mp4"); + await writeFile(input, "fixture-recording"); + const probe = spyOn(videos, "probeVideo").mockImplementation(async ({ input: path }) => ({ + path: resolve(path), + durationUs: 12_000_000, + width: 1280, + height: 720, + displayWidth: 1280, + displayHeight: 720, + rotation: 0, + bytes: (await files.stat(path)).size, + codec: "fixture", + })); + try { + const item = await stageShelfMedia({ root: directory, input, name: "Screen recording.mp4", type: "video" }); + expect(item).toMatchObject({ kind: "capture", name: "Screen recording.mp4", bytes: 17 }); + const asset = (await readWidgetState(directory)).assets[item.assetId!]; + expect(asset).toMatchObject({ type: "video", durationUs: 12_000_000, status: "pending" }); + expect(await attachShelfItem({ root: directory, id: item.id, key: widgetSessionKey(target) })).toEqual({ + added: true, + mode: "image", + }); + expect((await readWidgetState(directory)).drafts[widgetSessionKey(target)].assetIds).toEqual([item.assetId!]); + } finally { + probe.mockRestore(); + } +}); + test("cancel after producing a capture cleans temporary bytes and allows restart", async () => { const directory = await root(); const controller = new AbortController(); @@ -2485,7 +2654,9 @@ test("shelf attachment descriptors inspect metadata and draft without creating o ); expect(await stateFile.bytes()).toEqual(before); const absent = join(directory, "not-created"); - await expect(readShelfAttachment({ root: absent, id: "missing", key: "chosen" })).rejects.toThrow("unavailable"); + await expect(readShelfAttachment({ root: absent, id: "missing", key: "chosen" })).rejects.toThrow( + "no longer on the shelf" + ); expect(await Bun.file(join(absent, "state.json")).exists()).toBe(false); }); @@ -2998,6 +3169,21 @@ describe("widget inbox notifications", () => { expect(cleared.sessions.find((session) => session.key.includes("live-fixture"))?.status).toBe( "waiting" ); + // Staging the cleared decision updates its row; it was asked before the clear, so it stays read. + decisions = decisions.map((row) => + row.sessionId === "live-fixture" + ? { + ...row, + state: "drafted", + draftOption: "yes", + createdTs: new Date(now - hour).toISOString(), + updatedTs: new Date(now + 500).toISOString(), + } + : row + ); + const afterStage = (await widgetSnapshot({ root: directory, sources, now })).notifications; + expect(afterStage?.needsAnswer).toBe(0); + expect(afterStage?.staged.map((item) => item.sourceId)).toEqual(["d_1_live-fixture"]); decisions = [...decisions, decision("new-fixture", now + 1000)]; expect((await widgetSnapshot({ root: directory, sources, now })).notifications?.needsAnswer).toBe(1); } @@ -3133,6 +3319,28 @@ describe("private Widget voice notes", () => { const note = await recordVoiceNote({ root, input }); return { root, input, note }; } + test("a live dictation from the reply box becomes a transcribed note with its audio and where it was spoken", async () => { + const root = await mkdtemp(join(tmpdir(), "widget-voice-live-")); + const clip = await incomingClipPath(root); + const audio = new Uint8Array(32_000).map((_, index) => (index % 2 === 1 ? 32 : 0)); + await Bun.write(clip, audio); + const context = { sessionKey: "local:claude:fixture:", sessionTitle: "fixture", decisionNumber: 18 }; + const note = await addLiveVoiceNote({ root, clip, text: "Fixture words", context, provider: "fixture" }); + expect(note.transcription).toBe("ready"); + expect(note.text).toBe("Fixture words"); + expect(note.context).toEqual(context); + expect(note.clip.durationMs).toBe(1000); + expect(note.clip.peakRms).toBeGreaterThan(0); + expect(await Bun.file(note.clip.path).bytes()).toEqual(audio); + expect(await Bun.file(clip).exists()).toBe(false); + expect((await listVoiceNotes(root)).notes.map((entry) => entry.id)).toEqual([note.id]); + // Only the incoming folder may hand a clip over: any other path stays where it is. + const outside = join(root, "outside.pcm"); + await Bun.write(outside, audio); + await expect(addLiveVoiceNote({ root, clip: outside, text: "x" })).rejects.toThrow("incoming folder"); + expect(await Bun.file(outside).exists()).toBe(true); + }); + test("local record, explicit fixture transcription, edit and guarded discard use one private note", async () => { const { root, input, note } = await fixture(); expect((await files.stat(note.clip.path)).mode & 0o777).toBe(0o600); @@ -4094,6 +4302,128 @@ test("incoming answer, Decision and pending-form writes wake the Widget without }); }); +test("a snapshot carries the cards of the sessions that need you beside the selected one's", async () => { + const directory = await root(); + const file = join(directory, "decisions.jsonl"); + const events = join(directory, "events.jsonl"); + for (const sessionId of ["picked", "waiting-too"]) { + await postDecisions( + file, + events, + { sessionId, provider: "codex", decisions: [{ prompt: `Proceed in ${sessionId}?`, options: ["yes"] }] }, + { env: {} } + ); + } + await postDecisions( + file, + events, + { sessionId: "quiet", provider: "codex", decisions: [{ type: "todo", prompt: "Later", options: [] }] }, + { env: {} } + ); + const sources: WidgetSources = { ...emptySources, decisions: () => readDecisions(file) }; + const roster = await widgetSnapshot({ root: directory, sources }); + const keyOf = (sessionId: string): string => { + const key = roster.sessions.find((entry) => entry.target.sessionId === sessionId)?.key; + if (!key) { + throw new Error(`the roster has no session ${sessionId}`); + } + + return key; + }; + await mutateWidgetState(directory, (state) => { + state.selectedKey = keyOf("picked"); + state.preferences.showChanges = false; + }); + + const plain = await widgetSnapshot({ root: directory, sources }); + expect(plain.cards.map((card) => card.sessionKey)).toEqual([keyOf("picked")]); + expect(plain.warmKeys).toBeUndefined(); + + const warm = await widgetSnapshot({ root: directory, sources, warm: 4 }); + expect(warm.warmKeys).toEqual([keyOf("waiting-too")]); + expect(new Set(warm.cards.map((card) => card.sessionKey))).toEqual( + new Set([keyOf("picked"), keyOf("waiting-too")]) + ); + expect(warm.cards.some((card) => card.sessionKey === keyOf("quiet"))).toBe(false); + expect(warm.selectedKey).toBe(keyOf("picked")); +}); + +test("a session picked on the watch's stdin is the next snapshot's selection, with the warm sessions asked for", async () => { + const directory = await root(); + await env.testing.withOverrides({ GENESIS_TOOLS_HOME: directory }, async () => { + const controller = new AbortController(); + const asked: { selectedKey?: string; warm?: number }[] = []; + let pick: ((key: string) => void) | undefined; + let ready!: () => void; + const started = new Promise<void>((resolve) => { + ready = resolve; + }); + let picked!: () => void; + const pickedSnapshot = new Promise<void>((resolve) => { + picked = resolve; + }); + const worker = watchWidget({ + root: directory, + signal: controller.signal, + emit: () => ready(), + selections: (listener) => { + pick = listener; + return () => { + pick = undefined; + }; + }, + dependencies: { + inboxPaths: { + answerLog: join(directory, "question", "log"), + database: join(directory, "question", "qa.db"), + decisions: join(directory, "question", "decisions", "decisions.jsonl"), + }, + watchInbox: async () => ({ active: true, errorCount: 0, unsubscribe: async () => {} }), + snapshot: async (options) => { + asked.push({ selectedKey: options?.selectedKey, warm: options?.warm }); + if (options?.selectedKey === "local:codex:picked:") { + picked(); + } + return { + version: 1, + state: await readWidgetState(directory), + activity: [], + sessions: [], + cards: [], + manifests: {}, + changes: null, + errors: [], + selectedKey: options?.selectedKey ?? null, + }; + }, + dispatcher: { + validate: async () => {}, + dispatch: async () => ({ delivered: true, channel: "fixture" }), + }, + }, + }); + try { + await withTimeout(started, 3000); + expect(asked[0]).toEqual({ selectedKey: undefined, warm: WARM_SESSIONS }); + pick?.("local:codex:picked:"); + await withTimeout(pickedSnapshot, 3000); + } finally { + controller.abort(); + await withTimeout(worker, 3000); + } + expect(pick).toBeUndefined(); + }); +}); + +test("the watch's stdin control lines: whole lines only, and only a select with a key", () => { + const keys: (string | undefined)[] = []; + const lines = new StdinLines((line) => keys.push(widgetSelectionLine(line))); + lines.push('{"select":"local:claude:a:"}\n{"sel'); + lines.push('ect":"local:claude:b:"}\n\n'); + lines.push('{"select":""}\nnot json\n{"other":1}\n'); + expect(keys).toEqual(["local:claude:a:", "local:claude:b:", undefined, undefined, undefined]); +}); + test("a session queue directory that cannot be watched leaves the Widget running on its safety refresh", async () => { const directory = await root(); await env.testing.withOverrides({ GENESIS_TOOLS_HOME: directory }, async () => { diff --git a/src/hub/lib/data-db.ts b/src/hub/lib/data-db.ts new file mode 100644 index 0000000000..217f4ae6b6 --- /dev/null +++ b/src/hub/lib/data-db.ts @@ -0,0 +1,40 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type Migration, runMigrations } from "@genesiscz/utils/database/migrations"; +import { logger } from "@genesiscz/utils/logger"; +import { Storage } from "@genesiscz/utils/storage"; +import { importLegacyNavDb, NAV_MIGRATIONS } from "./navigation/schema"; + +const { log } = logger.scoped("hub:data"); + +/** + * Every hub feature that keeps rows: one scope each, all in one file. A new feature adds its migrations here instead + * of opening another SQLite file (Martin, 2026-10-25: "no need to create sqlite db for every thing"). + */ +const HUB_SCHEMAS: ReadonlyArray<{ scope: string; migrations: Migration[] }> = [ + { scope: "hub_nav", migrations: NAV_MIGRATIONS }, +]; + +/** `~/.genesis-tools/hub/data.db`: the hub's own data, one file per machine, shared by every hub and agent. */ +export function hubDbPath(): string { + return join(new Storage("hub").getBaseDir(), "data.db"); +} + +/** Opens data.db (or `:memory:` in a test) with every hub schema applied. */ +export function openHubDb(dbPath: string = hubDbPath()): Database { + if (dbPath !== ":memory:") { + mkdirSync(dirname(dbPath), { recursive: true }); + } + + const db = new Database(dbPath); + db.exec("PRAGMA journal_mode = WAL;"); + db.exec("PRAGMA busy_timeout = 2000;"); + for (const { scope, migrations } of HUB_SCHEMAS) { + runMigrations(db, migrations, { tableName: scope }); + } + + importLegacyNavDb(db); + log.debug({ dbPath }, "hub data: opened"); + return db; +} diff --git a/src/hub/lib/dev-monitor.test.ts b/src/hub/lib/dev-monitor.test.ts index 42a5777a09..358a27d6ff 100644 --- a/src/hub/lib/dev-monitor.test.ts +++ b/src/hub/lib/dev-monitor.test.ts @@ -53,6 +53,44 @@ describe("classifyPerfLine", () => { ).toBe("hang"); }); + test("a burst of child processes is an event; its ongoing and over lines collapse into one", () => { + expect( + classifyPerfLine( + "[19:26:02.100] mark spawn.storm 51 child processes started in 10 s, 49 running; GenesisTools agents blame ×51" + ) + ).toMatchObject({ kind: "storm", time: "19:26:02.100", key: undefined }); + expect(classifyPerfLine("[19:26:32.100] mark spawn.storm ongoing 30 s: 400 child processes started")?.key).toBe( + "storm" + ); + }); + + test("a Genesis Markdown line carries its tag, and its hang sample lives in its own folder", () => { + const event = classifyPerfLine( + "[17:12:43.850] [md] mark main-stall 1.0s so far — sampling to hang-2026-10-23-171243.txt recent=[x]", + DEFAULT_CLASSIFY, + "/home/fixture/.genesis/logs/hangs" + ); + expect(event).toMatchObject({ + kind: "hang", + file: "/home/fixture/.genesis/logs/hangs/hang-2026-10-23-171243.txt", + }); + expect(classifyPerfLine("[17:11:59.681] [md] mark main-stall recovered after 1806ms load=159")?.kind).toBe( + "stall" + ); + }); + + test("a main thread spinning at full CPU is an event, and its sample names the file", () => { + expect(classifyPerfLine("[17:10:32.100] mark main-spin 5s at 99% cpu: the main thread never idles")?.kind).toBe( + "spin" + ); + expect(classifyPerfLine("[17:10:34.200] mark main-spin bodies in 2s: bodies[widget.inbox.row=812]")?.kind).toBe( + "spin" + ); + expect( + classifyPerfLine("[17:10:32.101] mark main-spin 5.0s so far — sampling to hang-2026-10-23-171032.txt")?.file + ).toEndWith("logs/hangs/hang-2026-10-23-171032.txt"); + }); + test("short stalls, quick main-thread spans and routine lines are left out", () => { expect(classifyPerfLine("[01:48:30.597] mark main-stall recovered after 319ms load=6.3")).toBeNull(); expect(classifyPerfLine("[01:48:30.597] mark main-stall recovered after 819ms load=6.3")?.kind).toBe("stall"); diff --git a/src/hub/lib/dev-monitor.ts b/src/hub/lib/dev-monitor.ts index 85d7894ecc..3cc0536f78 100644 --- a/src/hub/lib/dev-monitor.ts +++ b/src/hub/lib/dev-monitor.ts @@ -16,7 +16,19 @@ const log = logger.child({ component: "hub:dev-monitor" }); * agent on each instead of the user finding a "not responding" banner first. */ export interface DevEvent { - kind: "wedge" | "hang" | "stall" | "layout-loop" | "slow-main" | "jank" | "error" | "relay" | "crash" | "slow"; + kind: + | "wedge" + | "spin" + | "hang" + | "stall" + | "layout-loop" + | "slow-main" + | "jank" + | "error" + | "relay" + | "crash" + | "slow" + | "storm"; /** The time the source line carries ("01:46:40.479"), or the file's time for a crash or a hang file. */ time: string; text: string; @@ -127,24 +139,50 @@ export function shortCommand(command: string): string { const timePattern = /^\[(\d\d:\d\d:\d\d(?:\.\d+)?)\]\s*/; -/** An app-perf.log line as an event, or null for the routine ones (spans, marks, quick stalls). */ -export function classifyPerfLine(line: string, options: ClassifyOptions = DEFAULT_CLASSIFY): DevEvent | null { +/** + * A perf log line as an event, or null for the routine ones (spans, marks, quick stalls). GenesisTools writes + * app-perf.log; Genesis Markdown writes `~/.genesis/logs/perf.log`, its lines tagged after the time (`[md] mark …`), + * and its hang samples go to `hangsDir`. + */ +export function classifyPerfLine( + line: string, + options: ClassifyOptions = DEFAULT_CLASSIFY, + hangsDir = genesisToolsDir("logs", "hangs") +): DevEvent | null { const time = line.match(timePattern)?.[1] ?? ""; - const body = line.replace(timePattern, "").replace(/^mark\s+/, ""); + const body = line + .replace(timePattern, "") + .replace(/^\[[a-z-]+\]\s+/, "") + .replace(/^mark\s+/, ""); const short = (text: string) => text.replace(/ recent=\[.*$/, "").slice(0, 300); if (/WEDGED|main-stall ongoing/.test(body)) { return { kind: "wedge", time, text: short(body) }; } - const sampled = body.match(/main-stall .*sampling to (\S+)/); + // A burst of child processes (GenesisKit `SpawnMeter`): one blame per open note was 9,300 launches an hour. + if (/^spawn\.storm /.test(body)) { + return { + kind: "storm", + time, + text: short(body), + key: /^spawn\.storm (ongoing|over)/.test(body) ? "storm" : undefined, + }; + } + + const sampled = body.match(/main-(?:stall|spin) .*sampling to (\S+)/); if (sampled) { - return { kind: "hang", time, text: short(body), file: genesisToolsDir("logs", "hangs", sampled[1]) }; + return { kind: "hang", time, text: short(body), file: join(hangsDir, sampled[1]) }; } const stacks = body.match(/main-stall stacks .*→ (\S+)/); if (stacks) { - return { kind: "hang", time, text: short(body), file: genesisToolsDir("logs", "hangs", stacks[1]) }; + return { kind: "hang", time, text: short(body), file: join(hangsDir, stacks[1]) }; + } + + // A main thread that never idles (an update loop) never delays a ping, so HangWatch reports it by CPU time. + if (/^main-spin /.test(body)) { + return { kind: "spin", time, text: short(body) }; } const recovered = body.match(/main-stall recovered after (\d+)ms/); @@ -395,6 +433,9 @@ export function collapse(events: DevEvent[]): DevEvent[] { export function devMonitorSources() { return { perfLog: genesisToolsDir("logs", "app-perf.log"), + /** Genesis and Genesis Markdown, which share GenesisKit's hang watcher and spawn meter. */ + genesisPerfLog: join(homedir(), ".genesis", "logs", "perf.log"), + genesisHangs: join(homedir(), ".genesis", "logs", "hangs"), profileLog: () => genesisToolsDir("logs", `${formatLocalDate(new Date())}-profiling.log`), relayLog: genesisToolsDir("app", "link-relay.log"), hangs: genesisToolsDir("logs", "hangs"), @@ -406,6 +447,7 @@ export function devMonitorSources() { export async function runDevMonitor(options: DevMonitorOptions): Promise<void> { const sources = devMonitorSources(); const perf = new TextTail(sources.perfLog, options.fromStart); + const genesisPerf = new TextTail(sources.genesisPerfLog, options.fromStart); const relay = new TextTail(sources.relayLog, options.fromStart); const profile = new TextTail(sources.profileLog, options.fromStart); const crashes = new FolderWatch(sources.crashes, (name) => /^Genesis/.test(name) && /\.(ips|crash)$/.test(name)); @@ -413,18 +455,27 @@ export async function runDevMonitor(options: DevMonitorOptions): Promise<void> { // process could not log any more, which is the case worth a separate event. // `.txt` only: the daily pass stores captures older than yesterday as `hang-….txt.zst`, which is not a new hang. const hangs = new FolderWatch(sources.hangs, (name) => name.startsWith("hang-") && name.endsWith(".txt")); + const genesisHangs = new FolderWatch( + sources.genesisHangs, + (name) => name.startsWith("hang-") && name.endsWith(".txt") + ); const announced = new Set<string>(); const batcher = new EventBatcher(options.minDelayMs, options.emit); while (!options.signal.aborted) { - for (const line of perf.read()) { - const event = classifyPerfLine(line, options); - if (event) { - if (event.file) { - announced.add(basename(event.file)); - } + for (const [tail, hangsDir, app] of [ + [perf, sources.hangs, ""], + [genesisPerf, sources.genesisHangs, "Genesis: "], + ] as const) { + for (const line of tail.read()) { + const event = classifyPerfLine(line, options, hangsDir); + if (event) { + if (event.file) { + announced.add(basename(event.file)); + } - batcher.add(event); + batcher.add({ ...event, text: app + event.text }); + } } } @@ -439,7 +490,7 @@ export async function runDevMonitor(options: DevMonitorOptions): Promise<void> { } } - for (const path of hangs.fresh()) { + for (const path of [...hangs.fresh(), ...genesisHangs.fresh()]) { if (!announced.has(basename(path))) { batcher.add({ kind: "hang", diff --git a/src/hub/lib/insights/index.ts b/src/hub/lib/insights/index.ts index a1ce2734c3..5fc85dd752 100644 --- a/src/hub/lib/insights/index.ts +++ b/src/hub/lib/insights/index.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { closeSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs"; +import { closeSync, mkdirSync, openSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { type AgentSessionRow, @@ -25,6 +25,7 @@ import { codexModelOf, readTail, scanClaudeNative, toolInputKeys } from "@genesi import { buildToolStats, buildTurnCosts, type CallPricer } from "@genesiscz/utils/ai/transcripts/turn-cost"; import { concurrentMap } from "@genesiscz/utils/async"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; import { Storage } from "@genesiscz/utils/storage"; import { cached } from "@genesiscz/utils/storage/cache-flag"; @@ -89,7 +90,8 @@ export function catalogPricer(): CallPricer { function readText(path: string): string | null { try { - return readFileSync(path, "utf8"); + // A `.jsonl.zst` rollout (Codex 0.164 compresses old ones) reads as its decompressed text. + return readWholeTextSync(path); } catch (err) { log.warn({ err, path }, "session file unreadable"); return null; diff --git a/src/hub/lib/navigation/navigation.test.ts b/src/hub/lib/navigation/navigation.test.ts new file mode 100644 index 0000000000..afb0b14af8 --- /dev/null +++ b/src/hub/lib/navigation/navigation.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openHubDb } from "../data-db"; +import { hubArgs, hubUrl } from "../open"; +import { listNav, navKeep, recordNav } from "./store"; +import { navTargetProblem, navTitle, parseNavInput } from "./target"; +import { type NavEntry, normalizeTarget } from "./types"; + +function entry(id: string, at: number, extra: Partial<NavEntry> = {}): NavEntry { + return { id, at, actor: "user", title: id, target: { mode: "sessions", session: id }, ...extra }; +} + +describe("the navigation log", () => { + it("lists newest first, replaces by id and keeps the agent's note and session", () => { + const db = openHubDb(":memory:"); + recordNav(db, entry("a", 1)); + recordNav( + db, + entry("b", 2, { actor: "agent", note: "the failing run", from: "session-1", target: { pr: "563" } }) + ); + recordNav(db, entry("a", 3, { title: "a again" })); + const rows = listNav(db); + expect(rows.map((row) => row.id)).toEqual(["a", "b"]); + expect(rows[0].title).toBe("a again"); + expect(rows[1]).toMatchObject({ + actor: "agent", + note: "the failing run", + from: "session-1", + target: { pr: "563" }, + }); + db.close(); + }); + + it("keeps the newest entries, and a later writer without a cap uses the one the hub stored", () => { + const db = openHubDb(":memory:"); + expect(navKeep(db)).toBe(200); + for (let index = 0; index < 5; index++) { + recordNav(db, entry(`p${index}`, index), 3); + } + + expect(listNav(db).map((row) => row.id)).toEqual(["p4", "p3", "p2"]); + expect(navKeep(db)).toBe(3); + recordNav(db, entry("agent", 10, { actor: "agent" })); + expect(listNav(db).map((row) => row.id)).toEqual(["agent", "p4", "p3"]); + expect(listNav(db, 1).map((row) => row.id)).toEqual(["agent"]); + db.close(); + }); + + it("drops unknown target keys and empty values, and reads an unknown actor as user", () => { + expect(normalizeTarget({ pr: "1", snapshot: "/tmp/x.png", commit: " ", tab: 3 })).toEqual({ pr: "1" }); + const db = openHubDb(":memory:"); + recordNav(db, entry("x", 1)); + db.run(`UPDATE nav_entries SET actor = 'robot', target = '{"pr":"1","menu":"Go/Back"}'`); + expect(listNav(db)[0]).toMatchObject({ actor: "user", target: { pr: "1" } }); + expect(Object.keys(listNav(db)[0].target)).toEqual(["pr"]); + db.close(); + }); +}); + +describe("what tools hub nav open names", () => { + const noFolder = () => false; + + it("reads a hub link in either form with its actor, note and session", () => { + expect( + parseNavInput("genesis-tools://hub?pr=owner%2Frepo%23563&commit=0751d8a971&actor=agent¬e=why", noFolder) + ).toEqual({ + target: { pr: "owner/repo#563", commit: "0751d8a971" }, + actor: "agent", + note: "why", + from: undefined, + }); + expect(parseNavInput("https://genesis.example/hub?repo=%2Fsrc%2Fapp&commit=abcdef0&from=s1", noFolder)).toEqual( + { + target: { repo: "/src/app", commit: "abcdef0" }, + actor: undefined, + note: undefined, + from: "s1", + } + ); + }); + + it("reads a PR ref, a PR page, a sha and a folder", () => { + expect(parseNavInput("#563", noFolder)).toEqual({ target: { pr: "#563" } }); + expect(parseNavInput("https://github.com/acme/app/pull/12", noFolder)).toEqual({ + target: { pr: "https://github.com/acme/app/pull/12" }, + }); + expect(parseNavInput("0751d8a971", noFolder)).toEqual({ target: { commit: "0751d8a971" } }); + expect(parseNavInput("/src/app", (path) => path === "/src/app")).toEqual({ target: { worktree: "/src/app" } }); + expect("error" in parseNavInput("not a place", noFolder)).toBe(true); + expect("error" in parseNavInput("https://example.org/other?pr=1", noFolder)).toBe(true); + }); + + it("refuses what the hub could not open, and names the place in a few words", () => { + expect(navTargetProblem({})).toContain("name a place"); + expect(navTargetProblem({ commit: "0751d8a971" })).toContain("--pr"); + expect(navTargetProblem({ pr: "nope" })).toContain("--pr takes"); + expect(navTargetProblem({ repo: "/src/app", commit: "0751d8a971" })).toBeUndefined(); + expect(navTitle({ pr: "563", commit: "0751d8a971ff" })).toBe("PR #563 · commit 0751d8a971"); + expect(navTitle({ pr: "https://github.com/acme/app/pull/12" })).toBe("PR acme/app/pull/12"); + expect(navTitle({ repo: "/src/app", commit: "aaaaaaa..bbbbbbb" })).toBe("commit aaaaaaa..bbbbbbb in app"); + expect(navTitle({ worktree: "/src/app-feature" })).toBe("worktree app-feature"); + expect(navTitle({ mode: "inbox" })).toBe("Inbox"); + }); + + it("hands the actor, note, session and entry id to the hub as flags and link keys", () => { + const args = hubArgs({ pr: "563", actor: "agent", note: "why", from: "s1", nav: "id-1" }); + expect(args).toEqual([ + "--hub", + "--pr", + "563", + "--actor", + "agent", + "--note", + "why", + "--from", + "s1", + "--nav", + "id-1", + ]); + expect(hubUrl({ pr: "563", actor: "agent", note: "a b" })).toBe( + "genesis-tools://hub?pr=563&actor=agent¬e=a%20b" + ); + }); +}); + +describe("hub data.db", () => { + it("the first open copies an old navigation.db in and renames it aside", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-hub-data-")); + try { + const old = openHubDb(join(dir, "navigation.db")); + recordNav(old, { id: "n1", at: 1, actor: "agent", title: "PR #1", target: { pr: "1" }, note: "why" }); + old.close(); + const db = openHubDb(join(dir, "data.db")); + expect(listNav(db).map((entry) => entry.id)).toEqual(["n1"]); + expect(existsSync(join(dir, "navigation.db"))).toBe(false); + db.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/hub/lib/navigation/schema.ts b/src/hub/lib/navigation/schema.ts new file mode 100644 index 0000000000..9493be1671 --- /dev/null +++ b/src/hub/lib/navigation/schema.ts @@ -0,0 +1,60 @@ +import type { Database } from "bun:sqlite"; +import { existsSync, renameSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { Migration } from "@genesiscz/utils/database/migrations"; +import { logger } from "@genesiscz/utils/logger"; + +const { log } = logger.scoped("hub:nav"); + +/** The navigation log's tables in the hub's data.db (`openHubDb`). */ +export const NAV_MIGRATIONS: Migration[] = [ + { + id: "001-hub-nav-entries", + description: + "one row per place the hub showed: when, who (user, agent, link), the place, its title and the note", + apply: (db) => { + db.exec(`CREATE TABLE IF NOT EXISTS nav_entries ( + id TEXT PRIMARY KEY, + at INTEGER NOT NULL, + actor TEXT NOT NULL, + title TEXT NOT NULL, + target TEXT NOT NULL, + note TEXT, + from_session TEXT + );`); + db.exec("CREATE INDEX IF NOT EXISTS idx_nav_entries_at ON nav_entries(at);"); + db.exec("CREATE TABLE IF NOT EXISTS nav_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);"); + }, + }, +]; + +/** + * The log's first home was hub/navigation.db (2026-10-25): copied into data.db once, then renamed aside. Outside the + * migrations because SQLite refuses ATTACH inside their transaction; INSERT OR IGNORE makes a second process harmless. + */ +export function importLegacyNavDb(db: Database): void { + if (!db.filename || db.filename === ":memory:") { + return; + } + + const legacy = join(dirname(db.filename), "navigation.db"); + if (legacy === db.filename || !existsSync(legacy)) { + return; + } + + try { + db.exec(`ATTACH DATABASE '${legacy.replaceAll("'", "''")}' AS legacy`); + try { + db.exec("INSERT OR IGNORE INTO nav_entries SELECT * FROM legacy.nav_entries"); + db.exec("INSERT OR IGNORE INTO nav_meta SELECT * FROM legacy.nav_meta"); + } finally { + db.exec("DETACH DATABASE legacy"); + } + + const aside = `${legacy}.migrated-${new Date().toISOString().slice(0, 10)}`; + renameSync(legacy, aside); + log.info({ from: legacy, aside }, "hub nav: moved the log into data.db"); + } catch (error) { + log.warn({ error, legacy }, "hub nav: could not move the old log into data.db; it stays where it is"); + } +} diff --git a/src/hub/lib/navigation/store.ts b/src/hub/lib/navigation/store.ts new file mode 100644 index 0000000000..64cb4e11ba --- /dev/null +++ b/src/hub/lib/navigation/store.ts @@ -0,0 +1,82 @@ +import type { Database } from "bun:sqlite"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { isNavActor, NAV_DEFAULT_KEEP, type NavEntry, normalizeTarget } from "./types"; + +interface NavRow { + id: string; + at: number; + actor: string; + title: string; + target: string; + note: string | null; + from_session: string | null; +} + +function fromRow(row: NavRow): NavEntry { + const entry: NavEntry = { + id: row.id, + at: row.at, + actor: isNavActor(row.actor) ? row.actor : "user", + title: row.title, + target: normalizeTarget(SafeJSON.parse(row.target, { strict: true })), + }; + + if (row.note) { + entry.note = row.note; + } + + if (row.from_session) { + entry.from = row.from_session; + } + + return entry; +} + +/** The cap the hub last set (its History setting), else the default. */ +export function navKeep(db: Database): number { + const row = db.query<{ value: string }, []>("SELECT value FROM nav_meta WHERE key = 'keep'").get(); + const keep = row ? Number(row.value) : Number.NaN; + return Number.isInteger(keep) && keep > 0 ? keep : NAV_DEFAULT_KEEP; +} + +/** + * Adds the entry, or replaces the row with its id, then keeps the newest `keep` rows. A `keep` given here is stored + * as the cap for later writers that do not know it (an agent's `tools hub nav open`). + */ +export function recordNav(db: Database, entry: NavEntry, keep?: number): { kept: number; dropped: number } { + const write = db.transaction(() => { + if (keep !== undefined) { + db.run("INSERT OR REPLACE INTO nav_meta (key, value) VALUES ('keep', ?)", [String(keep)]); + } + + db.run( + `INSERT OR REPLACE INTO nav_entries (id, at, actor, title, target, note, from_session) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + entry.id, + entry.at, + entry.actor, + entry.title, + SafeJSON.stringify(normalizeTarget(entry.target)), + entry.note ?? null, + entry.from ?? null, + ] + ); + const cap = keep ?? navKeep(db); + const dropped = db.run( + "DELETE FROM nav_entries WHERE id NOT IN (SELECT id FROM nav_entries ORDER BY at DESC, rowid DESC LIMIT ?)", + [cap] + ).changes; + return { kept: cap, dropped }; + }); + return write(); +} + +/** Newest first. */ +export function listNav(db: Database, limit?: number): NavEntry[] { + const rows = + limit === undefined + ? db.query<NavRow, []>("SELECT * FROM nav_entries ORDER BY at DESC, rowid DESC").all() + : db.query<NavRow, [number]>("SELECT * FROM nav_entries ORDER BY at DESC, rowid DESC LIMIT ?").all(limit); + return rows.map(fromRow); +} diff --git a/src/hub/lib/navigation/target.ts b/src/hub/lib/navigation/target.ts new file mode 100644 index 0000000000..4233b78431 --- /dev/null +++ b/src/hub/lib/navigation/target.ts @@ -0,0 +1,124 @@ +import { basename } from "node:path"; +import { isHubCommitSha, isHubPrRef } from "../pr-ref"; +import { type NavTarget, normalizeTarget } from "./types"; + +/** The query of a hub link, `genesis-tools://hub?…` or `https://<link host>/hub?…`; undefined for anything else. */ +function hubLinkQuery(input: string): URLSearchParams | undefined { + if (!URL.canParse(input)) { + return undefined; + } + + const url = new URL(input); + + if (url.protocol === "genesis-tools:") { + // `genesis-tools://hub?…` parses with `hub` as the host; some builders write `genesis-tools:hub?…`. + return url.host === "hub" || url.pathname.replace(/^\/+/, "") === "hub" ? url.searchParams : undefined; + } + + if ((url.protocol === "https:" || url.protocol === "http:") && /^\/hub\/?$/.test(url.pathname)) { + return url.searchParams; + } + + return undefined; +} + +export type ParsedNavInput = { target: NavTarget; actor?: string; note?: string; from?: string } | { error: string }; + +/** + * What `tools hub nav open <link-or-target>` names: a hub link (its `actor`, `note` and `from` come along), a PR or MR + * page, a PR ref (`#563`, `group/app!12`), a commit sha (needs `--pr` or `--repo` beside it), or a checkout folder. + */ +export function parseNavInput(input: string, isFolder: (path: string) => boolean): ParsedNavInput { + const text = input.trim(); + + if (text === "") { + return { target: {} }; + } + + const query = hubLinkQuery(text); + + if (query) { + const raw: Record<string, string> = {}; + + for (const [key, value] of query) { + raw[key] = value; + } + + return { + target: normalizeTarget(raw), + actor: query.get("actor") ?? undefined, + note: query.get("note") ?? undefined, + from: query.get("from") ?? undefined, + }; + } + + if (isHubPrRef(text)) { + return { target: { pr: text } }; + } + + if (isHubCommitSha(text)) { + return { target: { commit: text } }; + } + + if (isFolder(text)) { + return { target: { worktree: text } }; + } + + return { + error: `${text} is not a hub link, a PR (#563, group/app!12, a PR page), a commit sha or a checkout folder`, + }; +} + +/** What a place is, in a few words, until the hub names it better ("PR #563 · commit 0751d8a971"). */ +export function navTitle(target: NavTarget): string { + const commit = target.commit + ? `commit ${target.commit + .split("..") + .map((sha) => sha.slice(0, 10)) + .join("..")}` + : ""; + + if (target.pr) { + const pr = /^\d+$/.test(target.pr) ? `#${target.pr}` : target.pr.replace(/^https?:\/\/[^/]+\//, ""); + return [`PR ${pr}`, commit].filter(Boolean).join(" · "); + } + + if (target.repo && commit) { + return `${commit} in ${basename(target.repo)}`; + } + + if (target.worktree) { + return target.worktree === "cleanup" ? "worktree cleanup" : `worktree ${basename(target.worktree)}`; + } + + if (target.agent) { + return `agent ${target.agent}`; + } + + if (target.session) { + return `session ${target.session.slice(0, 8)}`; + } + + return target.mode ? target.mode[0].toUpperCase() + target.mode.slice(1) : "Hub"; +} + +/** Why the place cannot open as given (the same rules as `tools hub open`); undefined when it can. */ +export function navTargetProblem(target: NavTarget): string | undefined { + if (Object.keys(target).length === 0) { + return "name a place: a hub link, --pr, --commit with --repo, --worktree, --session or --mode"; + } + + if (target.pr !== undefined && !isHubPrRef(target.pr)) { + return `--pr takes 42, #42, <project>#42 or a PR page URL, got ${target.pr}`; + } + + if (target.commit !== undefined && !isHubCommitSha(target.commit)) { + return `--commit takes a sha of 7 to 40 hex characters, or <from>..<to>, got ${target.commit}`; + } + + if (target.commit !== undefined && target.pr === undefined && target.repo === undefined) { + return "--commit needs the PR it is in (--pr) or its project (--repo <path|owner/repo>)"; + } + + return undefined; +} diff --git a/src/hub/lib/navigation/types.ts b/src/hub/lib/navigation/types.ts new file mode 100644 index 0000000000..f7d16b6a7e --- /dev/null +++ b/src/hub/lib/navigation/types.ts @@ -0,0 +1,68 @@ +/** + * The hub's navigation history: every place the hub showed, who took it there and why. The log lives here, in + * the `nav_entries` table of `~/.genesis-tools/hub/data.db` (`openHubDb`); GenesisTools.app reads and writes it through `tools hub nav`, and its + * GenesisKit engine (`NavigationHistory`) walks it with Back, Forward and a hop. + */ + +/** Who moved the hub: a click in the hub, an agent (`tools hub nav open`), or a link that named nobody. */ +export const NAV_ACTORS = ["user", "agent", "link"] as const; +export type NavActor = (typeof NAV_ACTORS)[number]; + +/** + * A place, as the hub's link query names it (`HUB_LINK_KEYS`, read by `HubRequest` in the app). `selection` is the + * hub's own: the row its mode had selected (a session row id, a worktree path, a PR's id), which the hub restores + * exactly; a place an agent named has the link keys only. + */ +export const NAV_TARGET_KEYS = [ + "mode", + "session", + "agent", + "pr", + "reveal", + "repo", + "commit", + "tab", + "worktree", + "selection", +] as const; +export type NavTargetKey = (typeof NAV_TARGET_KEYS)[number]; +export type NavTarget = Partial<Record<NavTargetKey, string>>; + +export interface NavEntry { + id: string; + /** Milliseconds since the epoch. */ + at: number; + actor: NavActor; + title: string; + target: NavTarget; + note?: string; + /** The agent session that asked for it. */ + from?: string; +} + +export const NAV_DEFAULT_KEEP = 200; + +export function isNavActor(value: unknown): value is NavActor { + return typeof value === "string" && (NAV_ACTORS as readonly string[]).includes(value); +} + +function isNavTargetKey(value: string): value is NavTargetKey { + return (NAV_TARGET_KEYS as readonly string[]).includes(value); +} + +/** The known keys of `raw` with a non-empty string value; anything else is dropped. */ +export function normalizeTarget(raw: unknown): NavTarget { + const target: NavTarget = {}; + + if (typeof raw !== "object" || raw === null) { + return target; + } + + for (const [key, value] of Object.entries(raw)) { + if (isNavTargetKey(key) && typeof value === "string" && value.trim() !== "") { + target[key] = value.trim(); + } + } + + return target; +} diff --git a/src/hub/lib/open.ts b/src/hub/lib/open.ts index e967a88c8a..4ba1717238 100644 --- a/src/hub/lib/open.ts +++ b/src/hub/lib/open.ts @@ -52,6 +52,14 @@ export interface OpenHubOptions { prompts?: boolean; /** Opens the handoff composer for `session` (or the selected session). */ handoff?: boolean; + /** Who asked for this place, for the hub's history: `user`, `agent` or `link` (the hub's default). */ + actor?: string; + /** Why the place was opened; the history list shows it under the place. */ + note?: string; + /** The agent session that asked for it. */ + from?: string; + /** The history entry `tools hub nav open` already stored, so the hub updates it instead of adding one. */ + nav?: string; /** false keeps the window behind whatever is in front (`--no-activate`). */ activate?: boolean; /** false never builds; a missing or stale app is then an error. */ @@ -141,6 +149,17 @@ export function hubArgs(options: OpenHubOptions): string[] { args.push("--handoff"); } + for (const [flag, value] of [ + ["--actor", options.actor], + ["--note", options.note], + ["--from", options.from], + ["--nav", options.nav], + ] as const) { + if (value) { + args.push(flag, value); + } + } + if (options.activate === false) { args.push("--no-activate"); } diff --git a/src/hub/lib/timeline.test.ts b/src/hub/lib/timeline.test.ts index f0feb95619..804b755e93 100644 --- a/src/hub/lib/timeline.test.ts +++ b/src/hub/lib/timeline.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentSessionRow } from "@app/ai/lib/sessions/agent-session-rows"; import type { DecisionRecord } from "@app/question/lib/decisions/store"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { env } from "@genesiscz/utils/env"; import { Storage } from "@genesiscz/utils/storage"; import type { ThreadsResult } from "./pr"; @@ -999,4 +1000,19 @@ describe("timeline session branch", () => { expect(realTimelineDeps.lastBranch(path)).toBe("feat/new"); expect(realTimelineDeps.lastBranch(join(dir, "missing.jsonl"))).toBeNull(); }); + + test("a compressed rollout (.jsonl.zst) starts at its first record's time and its branch is read from its content", () => { + const dir = mkdtempSync(`${tmpdir()}/gt-timeline-branch-`); + const path = join(dir, "rollout-fixture.jsonl.zst"); + const huge = `{"type":"user","message":{"content":"${"x".repeat(200 * 1024)}"}}`; + const first = `{"timestamp":"2026-10-16T18:00:00.000Z","type":"session_meta","payload":{"id":"fixture"}}`; + writeFileSync( + path, + compressBytes([first, `{"git_branch":"feat/old"}`, `{"git_branch":"feat/new"}`, huge, ""].join("\n")) + ); + + // Its birth time on disk is when it was compressed, after the session. + expect(realTimelineDeps.birth(path)).toBe(Date.parse("2026-10-16T18:00:00.000Z")); + expect(realTimelineDeps.lastBranch(path)).toBe("feat/new"); + }); }); diff --git a/src/hub/lib/timeline.ts b/src/hub/lib/timeline.ts index 8a27720f21..118e37f093 100644 --- a/src/hub/lib/timeline.ts +++ b/src/hub/lib/timeline.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join, relative } from "node:path"; import { type AgentSessionRow, @@ -9,7 +9,9 @@ import { import { decisionFiles } from "@app/question/lib/decisions/read"; import { type DecisionRecord, readDecisions } from "@app/question/lib/decisions/store"; import { cleanSessionTitle } from "@genesiscz/utils/agent-sessions/user-text"; +import { readTailWindow } from "@genesiscz/utils/ai/transcripts/native-scan"; import { concurrentMap } from "@genesiscz/utils/async"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; import { startOfDay } from "@genesiscz/utils/date"; import { type CommandRunner, spawnRunner } from "@genesiscz/utils/git/origins"; import { type CommitInfo, LOG_FORMAT_WITH_SOURCE, parseLogZ } from "@genesiscz/utils/git/porcelain"; @@ -18,6 +20,7 @@ import { logger } from "@genesiscz/utils/logger"; import { profiler } from "@genesiscz/utils/profile"; import { Storage } from "@genesiscz/utils/storage"; import { cached, capMaxCacheAge } from "@genesiscz/utils/storage/cache-flag"; +import { startedAtOf } from "./agents/parent"; import type { NotifyItem } from "./notify"; import { readNotifyState } from "./notify-poll"; import type { ThreadsResult } from "./pr"; @@ -1283,6 +1286,12 @@ export const realTimelineDeps: TimelineDeps = { sessions: (hours) => listAgentSessionRows({ hours, withUsage: false, maxDiscoveryAgeMs: POLLED_LISTING_REUSE_MS }), birth: (path) => { try { + if (isZstdPath(path)) { + // A compressed rollout was born when Codex compressed it, after the session: its first record says when. + const started = Date.parse(startedAtOf(path) ?? ""); + return Number.isFinite(started) ? started : null; + } + const info = statSync(path); return info.birthtimeMs > 0 ? info.birthtimeMs : null; } catch (error) { @@ -1292,25 +1301,19 @@ export const realTimelineDeps: TimelineDeps = { }, lastBranch: (path) => { try { - const size = statSync(path).size; - const fd = openSync(path, "r"); - - try { - for (const bytes of BRANCH_TAIL_BYTES) { - const length = Math.min(bytes, size); - const buffer = Buffer.alloc(length); - const read = readSync(fd, buffer, 0, length, size - length); - const branch = lastRecordedBranch(buffer.subarray(0, read).toString("utf8")); - - if (branch || length === size) { - return branch; - } - } + // A compressed rollout decompresses whole for any window, so it reads the largest one once. + const windows = isZstdPath(path) ? BRANCH_TAIL_BYTES.slice(-1) : BRANCH_TAIL_BYTES; - return null; - } finally { - closeSync(fd); + for (const bytes of windows) { + const { buffer, start } = readTailWindow(path, bytes); + const branch = lastRecordedBranch(buffer.toString("utf8")); + + if (branch || start === 0) { + return branch; + } } + + return null; } catch (error) { log.debug({ error, path }, "timeline: could not read the session's last branch"); return null; diff --git a/src/hub/lib/widget/actions.ts b/src/hub/lib/widget/actions.ts index 84c3cfb797..3b35784949 100644 --- a/src/hub/lib/widget/actions.ts +++ b/src/hub/lib/widget/actions.ts @@ -9,6 +9,7 @@ import { toolDataDir } from "@genesiscz/utils/storage/root"; import { videoSettingsSchema } from "@genesiscz/utils/video/types"; import { z } from "zod"; import { confirmVideoAsset, importWidgetAsset, reviseVideoAsset } from "../composer/assets"; +import { widgetDeliverDeps } from "../composer/dispatch"; import { changeOutgoing, enqueueWidgetMessage } from "../composer/outbox"; import { createWidgetHandoff } from "./handoff"; import { @@ -19,6 +20,7 @@ import { } from "./screenshot"; import { readShelfAttachment } from "./shelf"; import { type WidgetSources, widgetProvider, widgetResultNode } from "./snapshot"; +import { resendAnsweredDecisions, sendStagedDecisions, unstageDecisions } from "./staged"; import { acknowledgeWidgetInbox, mutateWidgetState, readWidgetState, widgetRoot } from "./storage"; import { parseWidgetSessionKey, @@ -42,6 +44,35 @@ export const widgetActionSchema = z.discriminatedUnion("action", [ draft: z.string().optional(), draftOption: z.string().optional(), }), + z.object({ + action: z.literal("send-staged"), + ids: z.array(z.string()).min(1), + targets: z + .record( + z.string(), + z.object({ + provider: z.string().optional(), + sourceHome: z.string().optional(), + cwd: z.string().optional(), + }) + ) + .optional(), + }), + z.object({ + action: z.literal("resend-answers"), + ids: z.array(z.string()).min(1), + targets: z + .record( + z.string(), + z.object({ + provider: z.string().optional(), + sourceHome: z.string().optional(), + cwd: z.string().optional(), + }) + ) + .optional(), + }), + z.object({ action: z.literal("unstage"), ids: z.array(z.string()).min(1) }), z.object({ action: z.literal("preferences"), patch: widgetPreferencesPatchSchema }), z.object({ action: z.literal("visibility"), key: z.string(), pinned: z.boolean() }), z.object({ action: z.literal("draft"), key: z.string(), draft: widgetDraftSchema }), @@ -111,6 +142,20 @@ export async function performWidgetAction({ ...(request.draftOption !== undefined ? { draftOption: request.draftOption } : {}), }); } + case "send-staged": + return sendStagedDecisions({ + ids: request.ids, + targets: request.targets, + deliver: widgetDeliverDeps(signal), + }); + case "resend-answers": + return resendAnsweredDecisions({ + ids: request.ids, + targets: request.targets, + deliver: widgetDeliverDeps(signal), + }); + case "unstage": + return { unstaged: await unstageDecisions({ ids: request.ids }) }; case "selection": return mutateWidgetState(root, (state) => { state.selectedKey = request.key; diff --git a/src/hub/lib/widget/roster-index.ts b/src/hub/lib/widget/roster-index.ts index f595227075..db913e9903 100644 --- a/src/hub/lib/widget/roster-index.ts +++ b/src/hub/lib/widget/roster-index.ts @@ -6,6 +6,7 @@ import { HistoryDatabase } from "@genesiscz/utils/agent-sessions/database"; import { catalogHistory } from "@genesiscz/utils/agent-sessions/open-service"; import { resolveHistoryProvider } from "@genesiscz/utils/agent-sessions/provider"; import { logger } from "@genesiscz/utils/logger"; +import { isNativeTranscript } from "@genesiscz/utils/providers/session-paths"; import { readDataVersion } from "@genesiscz/utils/workers/sqlite-wake"; import { hubAgents } from "../agents"; import type { AgentsTree } from "../agents/types"; @@ -202,7 +203,8 @@ export function widgetRosterChange({ for (const root of roots.codex) { if (under(root, path) !== undefined) { const name = basename(path); - return name.startsWith("rollout-") && name.endsWith(".jsonl") ? "codex" : undefined; + // Codex 0.164 compresses a closed rollout to `rollout-….jsonl.zst`: the rename changes the index too. + return name.startsWith("rollout-") && isNativeTranscript("codex", name) ? "codex" : undefined; } } diff --git a/src/hub/lib/widget/shelf.ts b/src/hub/lib/widget/shelf.ts index 4a3395649b..ccde8ed282 100644 --- a/src/hub/lib/widget/shelf.ts +++ b/src/hub/lib/widget/shelf.ts @@ -221,16 +221,31 @@ export async function captureShelfImage({ ); } -export async function stageShelfImage({ +export function stageShelfImage(options: { + root?: string; + input: string; + name?: string; + signal?: AbortSignal; +}): Promise<ShelfItem> { + return stageShelfMedia({ ...options, type: "image" }); +} + +/** + * Stages a screenshot or a screen recording as a capture: a widget asset with no recipient yet. A video asset gets the + * same frame review a pasted video gets once it is attached to a draft. + */ +export async function stageShelfMedia({ root, input, name, signal, + type, }: { root?: string; input: string; name?: string; signal?: AbortSignal; + type: "image" | "video"; }): Promise<ShelfItem> { signal?.throwIfAborted(); const digest = await sha256FileAsync(resolve(input), { signal }); @@ -248,11 +263,11 @@ export async function stageShelfImage({ } } - const asset = await importWidgetAsset({ root, input, type: "image" }); + const asset = await importWidgetAsset({ root, input, type }); try { signal?.throwIfAborted(); - if (asset.type !== "image") { - throw new Error("Capture did not produce an image"); + if (asset.type !== type) { + throw new Error(`Capture did not produce ${type === "image" ? "an image" : "a video"}`); } const item: ShelfItem = { @@ -261,7 +276,7 @@ export async function stageShelfImage({ name: name ?? basename(input), path: asset.path, sha256: asset.sha256, - bytes: asset.bytes, + bytes: asset.type === "image" ? asset.bytes : (await stat(asset.path)).size, assetId: asset.id, createdAt: Date.now(), }; @@ -344,14 +359,14 @@ export async function readShelfAttachment({ root, id, key }: { root?: string; id const item = (await listWidgetShelf(root)).items.find((entry) => entry.id === id); if (!item || !(await Bun.file(item.path).exists())) { - throw new Error("The staged item is unavailable; import it again"); + throw new Error("This item is no longer on the shelf. Add it again."); } const state = await readWidgetState(root); const draft = state.drafts[key] ?? { text: "", assetIds: [] }; if (item.kind === "capture") { if (!item.assetId || !state.assets[item.assetId]) { - throw new Error("The captured image is unavailable; capture it again"); + throw new Error("The capture is unavailable; capture it again"); } return { mode: "image" as const, assetId: item.assetId, draft }; @@ -375,14 +390,14 @@ export async function attachShelfItem({ const item = (await listWidgetShelf(root)).items.find((entry) => entry.id === id); if (!item || !(await Bun.file(item.path).exists())) { - throw new Error("The staged item is unavailable; import it again"); + throw new Error("This item is no longer on the shelf. Add it again."); } return mutateWidgetState(root, (state) => { const draft = state.drafts[key] ?? { text: "", assetIds: [] }; if (item.kind === "capture") { if (!item.assetId || !state.assets[item.assetId]) { - throw new Error("The captured image is unavailable; capture it again"); + throw new Error("The capture is unavailable; capture it again"); } const added = !draft.assetIds.includes(item.assetId); diff --git a/src/hub/lib/widget/snapshot.ts b/src/hub/lib/widget/snapshot.ts index e7da1eca7d..ccb72708ef 100644 --- a/src/hub/lib/widget/snapshot.ts +++ b/src/hub/lib/widget/snapshot.ts @@ -7,7 +7,12 @@ import { INBOX_STALE_MS } from "@app/question/lib/inbox/build"; import { renderFormAnswer } from "@app/question/lib/pending/render"; import { listFormsSnapshot, PENDING_MIGRATIONS } from "@app/question/lib/pending/store"; import type { AskForm, AskItem } from "@app/question/lib/pending/types"; -import { type QaRow, queryEntriesSnapshot, readQuestionSnapshot } from "@app/question/lib/read-model"; +import { + type QaRow, + queryEntriesOfSessionsSnapshot, + queryEntriesSnapshot, + readQuestionSnapshot, +} from "@app/question/lib/read-model"; import type { TranscriptAnchor } from "@genesiscz/utils/agent/source-anchor"; import { type ResolvedTranscript, @@ -16,6 +21,7 @@ import { turnsFromLines, } from "@genesiscz/utils/ai/transcripts"; import { readTailBytes } from "@genesiscz/utils/claude/session.utils"; +import { isZstdPath, zstdContentBytes } from "@genesiscz/utils/compress/zstd"; import { runMigrations } from "@genesiscz/utils/database/migrations"; import type { ImageAttachment } from "@genesiscz/utils/image/attachments"; import { SafeJSON } from "@genesiscz/utils/json"; @@ -46,6 +52,29 @@ function timeOf(value: string | null | undefined): number { return Number.isFinite(at) ? at : 0; } +/** The unsent pick and note of a `drafted` decision, each only when present. */ +function stagedOf(row: { state: string; draftOption?: string; draft?: string }): { + draftOption?: string; + draft?: string; +} { + if (row.state !== "drafted") { + return {}; + } + + return { + ...(row.draftOption ? { draftOption: row.draftOption } : {}), + ...(row.draft?.trim() ? { draft: row.draft } : {}), + }; +} + +/** + * When a decision was asked: its last post (a supersede revises it), not its last update. A saved draft or a staged + * pick updates the row, and the widget treats a newer item time as a new arrival that chimes and flashes. + */ +function askedTimeOf(row: { revisedTs?: string; createdTs?: string; updatedTs: string }): number { + return timeOf(row.revisedTs ?? row.createdTs ?? row.updatedTs); +} + export interface WidgetSession { key: string; target: WidgetTarget; @@ -102,6 +131,12 @@ export interface WidgetCard { /** Decision cards: the chosen option letters and the answer text, however they were stored. */ option?: string; answer?: string; + /** + * Decision cards: the user's unsent pick and note (state `drafted`). A staged decision has either; the widget + * sends staged decisions together, one message per session. + */ + draftOption?: string; + draft?: string; /** Decision cards: every comment, oldest first (a supersede reason, progress notes). */ comments?: string[]; /** Decision cards: who stored the answer, the user in an inbox (`inbox`) or an agent copying a chat answer. */ @@ -132,6 +167,8 @@ export interface WidgetSources { decisions(): DecisionRecord[]; forms(session?: string): AskForm[]; answers(session?: string): QaRow[]; + /** The newest `limit` answers of each session in one read (the warm sessions); else `answers` per session. */ + answersOf?(sessions: string[], limit: number): QaRow[]; agents(refresh?: boolean): Promise<AgentsTree>; events?(ids: string[]): WidgetActivityEvent[]; inboxData?(window: WidgetInboxWindow): WidgetInboxData; @@ -195,6 +232,15 @@ export interface WidgetInboxItem { title?: string; /** A decision's number. */ number?: number; + /** How many inbox items this entry stands for: an answers group lists only its newest item. */ + count?: number; + /** A decision: its options, so the inbox of every session can stage a pick without opening the session. */ + options?: string[]; + recommended?: string; + revision?: number; + /** A decision's unsent pick and note (`drafted`); see `WidgetCard.draftOption`. */ + draftOption?: string; + draft?: string; } export interface WidgetInboxSession { key: string; @@ -203,6 +249,18 @@ export interface WidgetInboxSession { latest?: WidgetInboxItem; unreadItem?: WidgetInboxItem; pendingItem?: WidgetInboxItem; + /** The session's listed entries, the ones waiting for an answer first, then newest first; at most `INBOX_ITEMS_PER_SESSION`. */ + items?: WidgetInboxItem[]; +} + +/** The entries one session lists in the widget's inbox overview; its counts still cover every item. */ +export const INBOX_ITEMS_PER_SESSION = 8; + +/** A session's entries for the overview: the ones waiting for an answer first, then newest first, capped. */ +export function listedInboxItems(items: WidgetInboxItem[]): WidgetInboxItem[] { + return [...items] + .sort((a, b) => Number(b.needsAnswer) - Number(a.needsAnswer) || b.at - a.at) + .slice(0, INBOX_ITEMS_PER_SESSION); } export interface WidgetInboxSummary { unread: number; @@ -210,6 +268,8 @@ export interface WidgetInboxSummary { complete: boolean; truncated: boolean; sessions: WidgetInboxSession[]; + /** Every staged decision, uncapped: the widget sends from this list, not from a session's capped `items`. */ + staged: WidgetInboxItem[]; profile?: { hostId: "local" }; } @@ -451,13 +511,16 @@ async function widgetResult(node: AgentNode): Promise<string> { if (cached?.mtime === stat.mtimeMs) { return cached.text; } + // A `.jsonl.zst` rollout's size on disk is not its text's: the gate and the tail windows count decompressed bytes, + // or every window is tried when the frame headers do not say. + const size = isZstdPath(node.filePath) ? (zstdContentBytes(node.filePath) ?? Number.POSITIVE_INFINITY) : stat.size; // A large transcript used to show "open Conversation or Hub": the widget must show the result itself. let text: string; - if (stat.size > 8 * 1024 * 1024 && node.harness === "claude") { - text = (await lastClaudeAssistantText(node.filePath, stat.size)).slice(0, 32_000); - } else if (stat.size > 8 * 1024 * 1024) { + if (size > 8 * 1024 * 1024 && node.harness === "claude") { + text = (await lastClaudeAssistantText(node.filePath, size)).slice(0, 32_000); + } else if (size > 8 * 1024 * 1024) { const resolved = await resolveTranscript(node.filePath, {}, node.harness); - text = (await lastAssistantTextOf(resolved, stat.size)).slice(0, 32_000); + text = (await lastAssistantTextOf(resolved, size)).slice(0, 32_000); } else { const resolved = await resolveTranscript(node.filePath, {}, node.harness); const envelope = await transcriptEnvelope(resolved, { limit: 20 }); @@ -498,6 +561,8 @@ export const realWidgetSources: WidgetSources = { // The notified answer can sit behind 80 newer read ones; opening it must still find its card. opts: sessionId ? { sessionId, limit: 80, includeNewestUnread: true } : { limit: 100 }, }), + answersOf: (sessionIds, limit) => + queryEntriesOfSessionsSnapshot({ dbPath: toolDataDir("question", "qa.db"), sessionIds, limit }), agents: (refresh) => widgetAgents({ refresh }), inboxData: readInboxData, events: (ids) => readWidgetDecisionEvents({ ids }), @@ -690,6 +755,7 @@ export async function widgetSnapshot({ sources = realWidgetSources, now = Date.now(), onImagesDeferred, + warm = 0, }: { root?: string; selectedKey?: string; @@ -697,6 +763,11 @@ export async function widgetSnapshot({ sources?: WidgetSources; /** The clock the inbox ages items against; tests pass a fixed one. */ now?: number; + /** + * Up to this many other inbox sessions (waiting for an answer first, then unread, newest first) get their newest + * cards in the snapshot too, named in `warmKeys`, so the widget opens one of them with its cards at once. + */ + warm?: number; /** This snapshot left card images unread (its read budget was spent); the watch loop takes another one. */ onImagesDeferred?: () => void; }) { @@ -1000,6 +1071,13 @@ export async function widgetSnapshot({ if (!entry[target] || item.at > entry[target].at) { entry[target] = item; } + + const listed = count > 1 ? { ...item, count } : item; + if (entry.items) { + entry.items.push(listed); + } else { + entry.items = [listed]; + } inboxSessions.set(session.key, entry); }; // Counted per item: each SQL group carries its session's newest item and how many of its items fall after the @@ -1053,29 +1131,39 @@ export async function widgetSnapshot({ const inboxCount = (kind: "answer" | "form", groups: WidgetInboxGroup[]) => counted[kind] + Math.max(0, (groups[0]?.recentAfterClearTotal ?? 0) - listedRecent[kind]); let pendingDecisions = 0; + const staged: WidgetInboxItem[] = []; for (const row of decisions) { if (kindOf(row) !== "decision" || !["open", "drafted"].includes(row.state) || row.delivery?.uncertain) { continue; } const session = findSession(row.sessionId, row.provider); - if (!session || !inboxCounted(session, timeOf(row.updatedTs))) { + if (!session) { continue; } - pendingDecisions += 1; - putInbox( - session, - { - id: `decision:${row.id}`, - sourceId: row.id, - kind: "decision", - key: session.key, - at: timeOf(row.updatedTs), - needsAnswer: true, - number: row.number, - ...decisionTitle(row), - }, - 1 - ); + const item: WidgetInboxItem = { + id: `decision:${row.id}`, + sourceId: row.id, + kind: "decision", + key: session.key, + at: askedTimeOf(row), + needsAnswer: true, + number: row.number, + ...decisionTitle(row), + options: row.options, + ...(row.recommended ? { recommended: row.recommended } : {}), + revision: row.revision ?? 1, + ...stagedOf(row), + }; + // A staged pick is unsent input: it stays in `staged` after "Mark all read" and in a quiet session. + if (item.draftOption || item.draft) { + staged.push(item); + } + // "Mark all read" compares the time it was asked: staging an older decision updates the row, and must not + // count it as new again. A recent update still keeps the decision fresh in a quiet session. + if (item.at > (state.inboxClearedAt ?? 0) && inboxFresh(session, timeOf(row.updatedTs))) { + pendingDecisions += 1; + putInbox(session, item, 1); + } } let unreadResults = 0; const resultNodes = new Map<string, AgentNode>(); @@ -1125,16 +1213,39 @@ export async function widgetSnapshot({ truncated: inboxData.truncated || inboxSessions.size > 512, sessions: [...inboxSessions.values()] .sort((a, b) => b.needsAnswer - a.needsAnswer || b.unread - a.unread) - .slice(0, 512), + .slice(0, 512) + .map((entry) => ({ ...entry, items: listedInboxItems(entry.items ?? []) })), + staged, ...(prof.enabled ? { profile: { hostId: "local" as const } } : {}), }; const inboxMetadata: { notifications?: WidgetInboxSummary } = { notifications }; const selected = selectedKey ? sessions.get(selectedKey) : undefined; const persistedTarget = selectedKey ? parseWidgetSessionKey(selectedKey) : undefined; const selectedId = selected?.target.sessionId ?? persistedTarget?.sessionId; - const [answers, forms] = await Promise.all([ + // A switch to a session used to show "Loading this session's inbox…" until a snapshot named it, every time + // (Martin, 2026-10-24: "can't it just be cached and be prepared at all times"). The sessions that need you come + // along, so the hover list and the rail open them with their cards. + const warmKeys = + selectedKey && warm > 0 ? warmInboxKeys({ notifications, sessions, selectedKey, limit: warm }) : []; + const cardKeys = selectedKey ? new Set([selectedKey, ...warmKeys]) : undefined; + const warmIds = [...new Set(warmKeys.flatMap((key) => sessions.get(key)?.target.sessionId ?? []))].filter( + (id) => id !== selectedId + ); + // The warm sessions read images from their own small budget, so the selected session's never wait for theirs. + const warmImageBudget = new ImageLiftBudget({ files: 2, paths: 32 }); + const budgetFor = (key: string) => (key === selectedKey ? imageBudget : warmImageBudget); + const [answers, forms, warmAnswers, warmForms] = await Promise.all([ selectedId ? read("answers", () => sources.answers(selectedId), []) : Promise.resolve(answerRoster), selectedId ? read("question timeline", () => sources.forms(selectedId), []) : Promise.resolve(waitingForms), + read( + "warm answers", + () => + sources.answersOf + ? sources.answersOf(warmIds, WARM_CARD_WINDOW) + : warmIds.flatMap((id) => sources.answers(id)), + [] + ), + read("warm questions", () => warmIds.flatMap((id) => sources.forms(id)), []), ]); for (const form of forms) { const id = form.sessionHint || form.id; @@ -1155,13 +1266,21 @@ export async function widgetSnapshot({ } } const cards: WidgetCard[] = []; - const formEntries = new Set(forms.flatMap((form) => (form.entryId ? [form.entryId] : []))); - for (const row of answers) { + const selectedAnswers = new Set(answers.map((row) => row.id)); + const selectedForms = new Set(forms.map((form) => form.id)); + const cardForms = [...forms, ...warmForms.filter((form) => !selectedForms.has(form.id))]; + const formEntries = new Set(cardForms.flatMap((form) => (form.entryId ? [form.entryId] : []))); + for (const row of [...answers, ...warmAnswers.filter((entry) => !selectedAnswers.has(entry.id))]) { if (formEntries.has(row.id)) { continue; } + const known = findSession(row.sessionId, row.agent === "unknown" ? undefined : row.agent); + // A warm session's answer joins a session already listed; only the selected session's may add one. + if (!known && !selectedAnswers.has(row.id)) { + continue; + } const session = - findSession(row.sessionId, row.agent === "unknown" ? undefined : row.agent) ?? + known ?? addSession( targetOf({ sessionId: row.sessionId, provider: row.agent, cwd: row.cwd }, row.id), row.sessionTitle ?? row.sessionId, @@ -1170,7 +1289,7 @@ export async function widgetSnapshot({ ); const answerImages = await liftCardImages([row.question, cleanVisibleContext(row.answerMd)], { existing: row.attachments ?? [], - budget: imageBudget, + budget: budgetFor(session.key), }); const answerTitle = liftedTitle(answerImages.texts[0], answerImages.attachments); const answerBody = answerImages.texts[1]; @@ -1226,7 +1345,7 @@ export async function widgetSnapshot({ }; for (const row of decisions) { const session = findSession(row.sessionId, row.provider); - if (!session || (selectedKey && session.key !== selectedKey)) { + if (!session || (cardKeys && !cardKeys.has(session.key))) { continue; } // The prompt is lifted too: an item's attachments are embedded there. With a title (a task's short name) the @@ -1240,7 +1359,7 @@ export async function widgetSnapshot({ ), ...(row.title ? [cleanVisibleContext(row.prompt)] : []), ], - { budget: imageBudget } + { budget: budgetFor(session.key) } ); const decisionTitle = liftedTitle(decisionImages.texts[0], decisionImages.attachments); const decisionBody = [decisionImages.texts[2], decisionImages.texts[1]] @@ -1264,7 +1383,7 @@ export async function widgetSnapshot({ }, sessionKey: session.key, sourceId: row.id, - at: timeOf(row.updatedTs), + at: askedTimeOf(row), title: decisionTitle, body: decisionBody, status: row.delivery?.uncertain ? "delivery unknown" : row.state, @@ -1280,16 +1399,17 @@ export async function widgetSnapshot({ read: true, ...(row.option ? { option: row.option } : {}), ...(row.answer?.trim() ? { answer: row.answer } : {}), + ...stagedOf(row), ...(row.comments?.length ? { comments: row.comments } : {}), ...(row.answeredVia ? { answeredVia: row.answeredVia } : {}), ...supersededBy(row), }); } - for (const form of forms) { + for (const form of cardForms) { const session = (form.sessionHint ? findSession(form.sessionHint, formProvider(form)) : undefined) ?? [...sessions.values()].find((entry) => entry.target.sessionId === form.id); - if (!session || (selectedKey && session.key !== selectedKey)) { + if (!session || (cardKeys && !cardKeys.has(session.key))) { continue; } // Each item's prompt is drawn by the form view, so the lifted images leave the prompts the widget receives. @@ -1298,7 +1418,7 @@ export async function widgetSnapshot({ form.status === "answered" ? cleanVisibleContext(renderFormAnswer(form, form.answers ?? {})) : "", ...form.items.map((item) => item.promptMarkdown), ], - { budget: imageBudget } + { budget: budgetFor(session.key) } ); const [formAnswer, ...formPrompts] = formImages.texts; const formItems = form.items.map((item, index) => ({ ...item, promptMarkdown: formPrompts[index] ?? "" })); @@ -1337,16 +1457,16 @@ export async function widgetSnapshot({ ...flattenAgents(agents.orphans), ]) { const session = findResultSession(node); - if (!session || !hasResult(node) || (selectedKey && session.key !== selectedKey)) { + if (!session || !hasResult(node) || (cardKeys && !cardKeys.has(session.key))) { continue; } - const result = selectedKey ? await read("agent result", () => widgetResult(node), "") : ""; + const result = cardKeys ? await read("agent result", () => widgetResult(node), "") : ""; const resultImages = await liftCardImages( [ result || `Task context: ${node.spawnPromptPreview ?? node.description ?? "Open the conversation to read the agent's result."}`, ], - { budget: imageBudget } + { budget: budgetFor(session.key) } ); cards.push({ id: `result:${node.harness}:${node.id}`, @@ -1395,15 +1515,24 @@ export async function widgetSnapshot({ files: [], }) : null; - // The window keeps the newest cards plus each card a notification names, so opening it can acknowledge it. + // The window keeps the newest cards plus each card a notification names, so opening it can acknowledge it. A warm + // session keeps fewer, and every card that waits for an answer; opening it brings the rest a moment later. const selectedCards = cards - .filter((card) => !selectedKey || card.sessionKey === selectedKey) + .filter((card) => !cardKeys || cardKeys.has(card.sessionKey)) .sort((a, b) => a.at - b.at); const notified = new Set( notifications.sessions.flatMap((session) => [session.unreadItem?.id, session.pendingItem?.id]) ); - const windowStart = Math.max(0, selectedCards.length - 100); - const visibleCards = selectedCards.filter((card, index) => index >= windowStart || notified.has(card.id)); + const left = new Map<string, number>(); + const visibleCards = selectedCards + .reverse() + .filter((card) => { + const bucket = cardKeys ? card.sessionKey : ""; + const remaining = left.get(bucket) ?? (bucket === "" || bucket === selectedKey ? 100 : WARM_CARD_WINDOW); + left.set(bucket, remaining - 1); + return remaining > 0 || notified.has(card.id) || cardWaits(card); + }) + .reverse(); const activity = selectedId && sources.events ? await read( @@ -1432,5 +1561,38 @@ export async function widgetSnapshot({ changes, errors, selectedKey: selectedKey ?? null, + ...(warmKeys.length > 0 ? { warmKeys } : {}), }; } + +/** The newest cards a warm session brings; those that wait for an answer come on top of these. */ +export const WARM_CARD_WINDOW = 12; + +/** A card that waits for the user: an open or drafted decision, a pending form (GenesisKit `WidgetCard.needsAnswer`). */ +function cardWaits(card: WidgetCard): boolean { + return ( + (card.kind === "decision" && (card.status === "open" || card.status === "drafted")) || + (card.kind === "form" && card.status === "pending") + ); +} + +/** The inbox sessions to keep warm: those waiting for an answer, then those with unread items, each newest first. */ +export function warmInboxKeys({ + notifications, + sessions, + selectedKey, + limit, +}: { + notifications: WidgetInboxSummary; + sessions: Map<string, WidgetSession>; + selectedKey: string; + limit: number; +}): string[] { + return notifications.sessions + .filter((entry) => entry.key !== selectedKey && entry.needsAnswer + entry.unread > 0 && sessions.has(entry.key)) + .sort( + (a, b) => Number(b.needsAnswer > 0) - Number(a.needsAnswer > 0) || (b.latest?.at ?? 0) - (a.latest?.at ?? 0) + ) + .slice(0, limit) + .map((entry) => entry.key); +} diff --git a/src/hub/lib/widget/staged.ts b/src/hub/lib/widget/staged.ts new file mode 100644 index 0000000000..608fb34359 --- /dev/null +++ b/src/hub/lib/widget/staged.ts @@ -0,0 +1,183 @@ +import type { DeliverDeps } from "@app/question/lib/decisions/deliver"; +import { decisionFiles } from "@app/question/lib/decisions/read"; +import { sendAnsweredDecisions } from "@app/question/lib/decisions/send"; +import { type DecisionRecord, kindOf, readDecisions, updateDecisions } from "@app/question/lib/decisions/store"; +import { answerInboxDecisions, type InboxAnswerResult, inboxAnswerResult } from "@app/question/lib/inbox/answer"; +import { waitingBlock } from "@app/question/lib/inbox/load"; +import { logger } from "@genesiscz/utils/logger"; + +/** Where a session's answers go: the widget's target for it (the decision row alone has no source home). */ +export interface StagedTarget { + provider?: string; + sourceHome?: string; + cwd?: string; +} + +export interface StagedSendResult { + /** One entry per session, in the order the sessions first appear in `ids`. */ + sessions: Array<{ session: string; numbers: number[]; result?: InboxAnswerResult; error?: string }>; + /** Delivered to a live receiver. */ + sent: number; + /** Answered, but no receiver took them (`result.detail` says why): they wait for the session's next prompt. */ + queued: number; + failed: number; +} + +/** + * A staged decision: the user picked an option or wrote a note in an inbox and has not sent it. It is the decision + * log's own unsent state (`drafted` with `draftOption` or `draft`), so the hub, the widget and the CLI see one list. + */ +export function isStaged(row: DecisionRecord): boolean { + return kindOf(row) === "decision" && row.state === "drafted" && Boolean(row.draftOption || row.draft?.trim()); +} + +/** + * Sends the staged decisions `ids` as their answers: one message per session, through `answerInboxDecisions`, the + * path the inbox and the hub's Decisions pane send with. A session that fails keeps its decisions staged and is + * reported; the other sessions still go. An id that is not staged (sent meanwhile, unstaged) is skipped. + */ +export async function sendStagedDecisions({ + ids, + targets = {}, + files = decisionFiles(), + deliver = {}, +}: { + ids: readonly string[]; + targets?: Record<string, StagedTarget>; + files?: { file: string; events: string }; + deliver?: DeliverDeps; +}): Promise<StagedSendResult> { + const wanted = new Set(ids); + const staged = readDecisions(files.file).filter((row) => wanted.has(row.id) && isStaged(row)); + const bySession = new Map<string, DecisionRecord[]>(); + + for (const id of ids) { + const row = staged.find((entry) => entry.id === id); + + if (!row) { + // Sent, discarded or answered meanwhile: nothing to send for it, and the reply must not read as a send. + logger.warn({ id }, "widget send-staged: decision is no longer staged"); + continue; + } + + bySession.set(row.sessionId, [...(bySession.get(row.sessionId) ?? []), row]); + } + + const result: StagedSendResult = { sessions: [], sent: 0, queued: 0, failed: 0 }; + + for (const [session, rows] of bySession) { + const target = targets[session] ?? {}; + const numbers = rows.map((row) => row.number); + const provider = target.provider ?? rows[0]?.provider; + const cwd = target.cwd ?? rows[0]?.cwd; + + try { + const sent = await answerInboxDecisions( + { + session, + ...(provider ? { provider } : {}), + ...(target.sourceHome ? { sourceHome: target.sourceHome } : {}), + ...(cwd ? { cwd } : {}), + answers: rows.map((row) => ({ + number: row.number, + expectedRevision: row.revision ?? 1, + ...(row.draftOption ? { option: row.draftOption } : {}), + ...(row.draft?.trim() ? { text: row.draft.trim() } : {}), + })), + }, + { ...files, block: waitingBlock, deliver } + ); + result.sessions.push({ session, numbers, result: sent }); + logger.info( + { session, numbers, delivered: sent.delivered, provider }, + sent.delivered ? "widget send-staged: delivered" : "widget send-staged: queued for the next prompt" + ); + if (sent.delivered) { + result.sent += rows.length; + } else { + result.queued += rows.length; + } + } catch (error) { + logger.warn({ session, numbers, error }, "widget send-staged: failed"); + result.sessions.push({ session, numbers, error: error instanceof Error ? error.message : String(error) }); + result.failed += rows.length; + } + } + + return result; +} + +/** + * Sends again the answered decisions `ids` that no send delivered (the widget's Send again after a queued send): + * one message per session, through the same delivery as the first send. + */ +export async function resendAnsweredDecisions({ + ids, + targets = {}, + files = decisionFiles(), + deliver = {}, +}: { + ids: readonly string[]; + targets?: Record<string, StagedTarget>; + files?: { file: string; events: string }; + deliver?: DeliverDeps; +}): Promise<StagedSendResult> { + const wanted = new Set(ids); + const answered = readDecisions(files.file).filter( + (row) => wanted.has(row.id) && kindOf(row) === "decision" && row.state === "answered" + ); + const bySession = new Map<string, DecisionRecord[]>(); + for (const row of answered) { + bySession.set(row.sessionId, [...(bySession.get(row.sessionId) ?? []), row]); + } + + const result: StagedSendResult = { sessions: [], sent: 0, queued: 0, failed: 0 }; + + for (const [session, rows] of bySession) { + const target = targets[session] ?? {}; + const numbers = rows.map((row) => row.number); + const provider = target.provider ?? rows[0]?.provider; + + try { + const sent = await sendAnsweredDecisions({ + session, + ...(provider ? { provider } : {}), + ...(target.sourceHome ? { sourceHome: target.sourceHome } : {}), + ids: rows.map((row) => row.id), + files, + deps: deliver, + }); + result.sessions.push({ session, numbers, result: inboxAnswerResult(session, sent) }); + if (sent.delivered) { + result.sent += rows.length; + } else { + result.queued += rows.length; + } + } catch (error) { + result.sessions.push({ session, numbers, error: error instanceof Error ? error.message : String(error) }); + result.failed += rows.length; + } + } + + return result; +} + +/** Discards the unsent pick and note of each staged decision in `ids`; the decision still waits for an answer. */ +export async function unstageDecisions({ + ids, + files = decisionFiles(), +}: { + ids: readonly string[]; + files?: { file: string; events: string }; +}): Promise<number> { + const wanted = new Set(ids); + const updates = readDecisions(files.file) + .filter((row) => wanted.has(row.id) && isStaged(row)) + .map((row) => ({ id: row.id, draft: "", draftOption: "" })); + + if (updates.length > 0) { + await updateDecisions(files.file, files.events, { updates }); + } + + return updates.length; +} diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index 7f19052541..0367127d78 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -52,6 +52,10 @@ export const widgetPreferencesSchema = z.object({ sidePosition: z.number().min(0).max(1).default(0.5), hoverPreviews: z.boolean().default(true), glassEffect: z.boolean().default(true), + /** The widget panel's own background: "dark" (solid near-black), "glass", or "theme" (the app-wide theme). */ + panelBackground: z.enum(["dark", "glass", "theme"]).default("dark"), + /** The hover list's rows show their waiting question under the session. */ + previewQuestion: z.boolean().default(true), display: z.string().default(""), providers: z.array(z.enum(["claude", "codex", "grok", "unknown"])).default([]), quietSeconds: z.number().int().min(3).max(300).default(15), @@ -67,6 +71,45 @@ export const widgetPreferencesSchema = z.object({ alertSound: z.string().trim().min(1).max(40).default("Glass"), /** Show the widget's own popup with an alert. */ alertPopup: z.boolean().default(true), + /** The agents hover list: only sessions that need you, or every active session too. */ + previewFilter: z.enum(["needs-you", "active"]).default("needs-you"), + /** The agents hover list's order: waiting for you first, newest activity first, or by project. */ + previewSort: z.enum(["attention", "recent", "project"]).default("attention"), + /** The sound an answer or message plays; "same" uses `alertSound`, "none" plays no sound. */ + alertAnswerSound: z.string().trim().min(1).max(40).default("same"), + /** Seconds the session that raised an alert flashes in the widget; 0 does not flash it. */ + alertFlashSeconds: z.number().int().min(0).max(30).default(5), + /** Where a session inbox puts its reply composer: under the tabs, or below the list. */ + inboxComposer: z.enum(["top", "bottom"]).default("top"), + /** A rail session bubble's tooltip: only while hover previews are off ("auto"), always, or never. */ + bubbleTooltip: z.enum(["auto", "always", "never"]).default("auto"), + /** The rail's session bubbles: in the hover list's order ("list"), or each keeps its place ("stable"). */ + railOrder: z.enum(["list", "stable"]).default("list"), + /** A click on a decision option: stages the pick to send later with others ("stage"), or sends it at once. */ + decisionClick: z.enum(["stage", "send"]).default("stage"), + /** Leaving the inbox with staged decisions: ask what to do ("ask"), keep them staged, or send them. */ + stagedOnLeave: z.enum(["ask", "keep", "send"]).default("ask"), + /** The inbox of every session: entries grouped under their session ("session"), or one list. */ + inboxGrouping: z.enum(["session", "list"]).default("session"), + /** The filter a session's inbox opens with: every entry ("all"), the inbox entries, or the decisions. */ + inboxFilter: z.enum(["all", "inbox", "decisions"]).default("all"), + /** The filter the inbox of every session opens with: every entry ("all"), the ones that need you, or unread. */ + overviewFilter: z.enum(["all", "needs", "unread"]).default("all"), + /** The composer's microphone: records a voice note and puts its transcript in the field ("note"), or live dictation. */ + composerVoice: z.enum(["ask", "note", "live"]).default("ask"), + /** Seconds the alert popup stays while the pointer is not on it. */ + alertPopupSeconds: z.number().int().min(3).max(60).default(7), + /** Seconds a widget note stays before it fades; 0 keeps it until it is closed. A note with a button stays 10 s or more. */ + noteSeconds: z.number().int().min(0).max(60).default(6), + /** A global shortcut opens and closes the widget from any app. */ + openShortcutEnabled: z.boolean().default(true), + /** That shortcut: a Carbon virtual key code and modifier mask (GenesisKit `HotkeyChord`); ⌃⌥⌘W by default. */ + openShortcut: z + .object({ + keyCode: z.number().int().min(0).max(255), + modifiers: z.number().int().min(0).max(0xffff), + }) + .default({ keyCode: 0x0d, modifiers: 0x1900 }), }); // Zod defaults also run inside partial objects; a patch must retain only supplied keys. diff --git a/src/hub/lib/widget/voice-notes.ts b/src/hub/lib/widget/voice-notes.ts index 40d8197012..519bfef235 100644 --- a/src/hub/lib/widget/voice-notes.ts +++ b/src/hub/lib/widget/voice-notes.ts @@ -1,9 +1,10 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, realpathSync, unlinkSync } from "node:fs"; -import { mkdir, unlink } from "node:fs/promises"; +import { mkdir, rename, stat, unlink } from "node:fs/promises"; import { join, resolve } from "node:path"; import { parseSttProvider } from "@genesiscz/utils/ai/stt/resolve"; import type { LiveTranscriptEvent } from "@genesiscz/utils/ai/stt/types"; +import { pcmRms } from "@genesiscz/utils/ai/stt/vad"; import { type RecordingEvent, recordPcmClip } from "@genesiscz/utils/ai/voice/record"; import { createVoiceSession, type VoiceEvent } from "@genesiscz/utils/ai/voice/session"; import { SafeJSON } from "@genesiscz/utils/json"; @@ -13,17 +14,30 @@ import { withFileLock } from "@genesiscz/utils/storage/file-lock"; import { z } from "zod"; import { widgetRoot } from "./storage"; +const MAX_CLIP_BYTES = 9_600_000; + const clipSchema = z.object({ path: z.string(), - bytes: z.number().int().positive().max(960_000), + // A recorded note stops at 30 s; one kept from live dictation may run up to five minutes. + bytes: z.number().int().positive().max(MAX_CLIP_BYTES), sampleRateHz: z.literal(16000), channels: z.literal(1), encoding: z.literal("s16le"), - durationMs: z.number().positive().max(30_000), + durationMs: z.number().positive().max(300_000), peakRms: z.number(), endedBy: z.enum(["eof", "stop", "limit"]), sha256: z.string().length(64), }); +/** Where a note was recorded: the widget's reply box of a session, for one decision or a follow-up. */ +export const voiceNoteContextSchema = z.object({ + sessionKey: z.string().max(1_000), + sessionTitle: z.string().max(500).optional(), + decisionId: z.string().max(500).optional(), + decisionNumber: z.number().int().positive().optional(), + title: z.string().max(500).optional(), +}); +export type VoiceNoteContext = z.infer<typeof voiceNoteContextSchema>; + export const voiceNoteSchema = z.object({ id: z.string().uuid(), revision: z.number().int().positive(), @@ -38,6 +52,8 @@ export const voiceNoteSchema = z.object({ language: z.string().optional(), /** The newest transcription attempt; only that attempt may write its outcome. */ operationId: z.string().uuid().optional(), + /** Recorded from a reply box: its session and decision. Absent for a note recorded in Voice Notes itself. */ + context: voiceNoteContextSchema.optional(), }); export type VoiceNote = z.infer<typeof voiceNoteSchema>; const indexSchema = z.object({ revision: z.number().int().nonnegative(), notes: z.array(voiceNoteSchema).max(100) }); @@ -148,11 +164,13 @@ export async function recordVoiceNote({ signal, stopSignal, onEvent, + context, }: { root?: string; id?: string; input?: string; micLauncher?: string; + context?: VoiceNoteContext; signal?: AbortSignal; stopSignal?: AbortSignal; onEvent?: (event: RecordingEvent) => void; @@ -186,6 +204,7 @@ export async function recordVoiceNote({ text: "", recognizedText: "", transcription: "none", + ...(context ? { context } : {}), }); let duplicate = false; try { @@ -223,6 +242,108 @@ export async function recordVoiceNote({ throw new Error(`The recording was discarded because it could not be saved: ${reason}`, { cause: error }); } } +/** Where `voice listen --save-audio` writes a reply box dictation before `addLiveVoiceNote` files it. */ +export function voiceNotesIncomingDirectory(root?: string): string { + return join(voiceNotesDirectory(root), "incoming"); +} + +/** A new file name in the incoming folder; the folder exists afterwards. */ +export async function incomingClipPath(root?: string): Promise<string> { + await mkdir(voiceNotesIncomingDirectory(root), { recursive: true }); + return join(voiceNotesIncomingDirectory(root), `${randomUUID()}.pcm`); +} + +/** + * Files the audio of a live dictation from the reply box as a voice note with its text, already transcribed, and + * where it was spoken (Martin, 2026-10-23: the dictation "can go into the voice notes place (with context of which + * session, which decision etc was it recorded in)"). The clip must be raw s16le 16 kHz mono in the incoming folder; + * it moves into the note's clip path. + */ +export async function addLiveVoiceNote({ + root, + clip, + text, + context, + provider, + model, + language, + signal, +}: { + root?: string; + clip: string; + text: string; + context?: VoiceNoteContext; + provider?: string; + model?: string; + language?: string; + signal?: AbortSignal; +}): Promise<VoiceNote> { + const incoming = resolve(voiceNotesIncomingDirectory(root)); + if (resolve(clip, "..") !== incoming) { + throw new Error("A live dictation clip must be in the voice notes incoming folder"); + } + + const clipStat = await stat(clip); + if (!clipStat.isFile() || clipStat.size > MAX_CLIP_BYTES) { + throw new Error("The dictation clip is not a file within the voice note size limit"); + } + + const audio = new Uint8Array(await Bun.file(clip).arrayBuffer()); + if (audio.byteLength < 2 || audio.byteLength % 2 !== 0) { + throw new Error("The dictation recorded no audio"); + } + + let peakRms = 0; + for (let offset = 0; offset < audio.byteLength; offset += 3_200) { + peakRms = Math.max(peakRms, pcmRms(audio.subarray(offset, Math.min(audio.byteLength, offset + 3_200)))); + } + + const id = randomUUID(); + const output = join(voiceNotesDirectory(root), "clips", `${id}.pcm`); + await mkdir(join(voiceNotesDirectory(root), "clips"), { recursive: true }); + const note = voiceNoteSchema.parse({ + id, + revision: 1, + createdAt: Date.now(), + clip: { + path: output, + bytes: audio.byteLength, + sampleRateHz: 16000, + channels: 1, + encoding: "s16le", + durationMs: audio.byteLength / 32, + peakRms, + endedBy: "stop", + sha256: createHash("sha256").update(audio).digest("hex"), + }, + text, + recognizedText: text, + transcription: "ready", + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + ...(language ? { language } : {}), + ...(context ? { context } : {}), + }); + try { + await rename(clip, output); + return await mutate({ + root, + signal, + change: (index) => { + if (index.notes.length >= 100) { + throw new Error("The local voice notebook is full. Discard a note before recording another."); + } + + index.notes.push(note); + return note; + }, + }); + } catch (error) { + await unlink(output).catch((cleanup) => logger.debug({ error: cleanup, id }, "Unsaved dictation clip cleanup")); + throw error; + } +} + export async function editVoiceNote({ root, id, diff --git a/src/hub/lib/widget/watch.ts b/src/hub/lib/widget/watch.ts index bc538f0cba..3552d986f1 100644 --- a/src/hub/lib/widget/watch.ts +++ b/src/hub/lib/widget/watch.ts @@ -34,18 +34,26 @@ const SAFETY_MS = 30_000; const DEGRADED_SAFETY_MS = 5000; /** Every this many safety ticks the whole catalog is refreshed, for changes outside the watched session roots. */ const FULL_REFRESH_TICKS = 4; +/** Inbox sessions whose cards every snapshot carries beside the selected one's (`widgetSnapshot({ warm })`). */ +export const WARM_SESSIONS = 16; export async function watchWidget({ root, selectedKey, signal, emit, + selections, dependencies = {}, }: { root?: string; selectedKey?: string; signal: AbortSignal; emit: (snapshot: Awaited<ReturnType<typeof widgetSnapshot>>) => void; + /** + * The widget's picks as they happen (`{"select": key}` lines on stdin): the next snapshot names that session at + * once, without waiting for the `selection` action to reach `state.json` (a process and a debounce, about 0.5 s). + */ + selections?: (listener: (key: string) => void) => () => void; dependencies?: { prepare?: typeof prepareWidgetAsset; snapshot?: typeof widgetSnapshot; @@ -93,8 +101,13 @@ export async function watchWidget({ const startedAt = Date.now(); let emitted = false; const profiledInbox = new Map<string, { at: number; id: string }>(); + /** The widget's latest pick, until `state.json` names it too. */ + let picked: string | undefined; const refresh = async () => { const state = await readWidgetState(directory); + if (picked && state.selectedKey === picked) { + picked = undefined; + } const referenced = new Set([ ...Object.values(state.drafts).flatMap((draft) => draft.assetIds), ...state.outgoing @@ -150,8 +163,9 @@ export async function watchWidget({ const snapshot = await prof.measureAsync("watch snapshot", () => readSnapshot({ root: directory, - selectedKey, + selectedKey: selectedKey ?? picked, refresh: false, + warm: WARM_SESSIONS, onImagesDeferred: () => void requestRefresh(), }) ); @@ -253,6 +267,10 @@ export async function watchWidget({ roster.refresh(); } const subscription = watchPath(join(directory, "state.json"), requestRefresh, { debounceMs: 180 }); + const stopSelections = selections?.((key) => { + picked = key; + void requestRefresh(); + }); let queueSubscription: WatcherSubscription | undefined; const inboxSubscriptions: WatcherSubscription[] = []; const sessionSubscriptions: WatcherSubscription[] = []; @@ -359,6 +377,7 @@ export async function watchWidget({ } }); } finally { + stopSelections?.(); cancelSafety?.(); roster?.stop(); await subscription.unsubscribe(); @@ -452,3 +471,41 @@ async function watchSessionRoots({ logger.debug({ roots: all.length, complete }, "Watching Widget session roots"); return complete; } + +/** Splits stdin chunks into lines; a partial line waits for the rest, and a runaway one is dropped. */ +export class StdinLines { + private rest = ""; + + constructor(private readonly onLine: (line: string) => void) {} + + readonly push = (chunk: Buffer | string): void => { + const lines = (this.rest + chunk.toString()).split("\n"); + const rest = lines.pop() ?? ""; + this.rest = rest.length > 4096 ? "" : rest; + for (const line of lines) { + if (line.trim()) { + this.onLine(line); + } + } + }; +} + +/** The session key of a `{"select": "<key>"}` control line from the widget, else undefined. */ +export function widgetSelectionLine(line: string): string | undefined { + try { + const value: unknown = SafeJSON.parse(line, { strict: true }); + if ( + value && + typeof value === "object" && + "select" in value && + typeof value.select === "string" && + value.select + ) { + return value.select; + } + } catch (error) { + logger.debug({ error }, "Widget control line ignored"); + } + + return undefined; +} diff --git a/src/hub/widget-cli.ts b/src/hub/widget-cli.ts index 194d9cbda3..6ccb121029 100644 --- a/src/hub/widget-cli.ts +++ b/src/hub/widget-cli.ts @@ -18,9 +18,10 @@ import { readShelfAttachment, removeShelfItem, stageShelfImage, + stageShelfMedia, } from "./lib/widget/shelf"; import { discoverWidgetCatalog, widgetSnapshot } from "./lib/widget/snapshot"; -import { watchWidget } from "./lib/widget/watch"; +import { StdinLines, watchWidget, widgetSelectionLine } from "./lib/widget/watch"; import { registerWidgetTasks } from "./widget-tasks-cli"; import { registerWidgetVoiceNotes } from "./widget-voice-notes-cli"; @@ -132,8 +133,20 @@ widget const owner = new AbortController(); const abort = () => owner.abort(); signal.addEventListener("abort", abort, { once: true }); + const selections = new Set<(key: string) => void>(); + const lines = new StdinLines((line) => { + const key = widgetSelectionLine(line); + if (!key) { + return; + } + + for (const listener of selections) { + listener(key); + } + }); if (options.stopOnStdin) { process.stdin.on("end", abort); + process.stdin.on("data", lines.push); process.stdin.resume(); if (process.stdin.readableEnded) { owner.abort(); @@ -145,10 +158,15 @@ widget selectedKey: options.selected, signal: owner.signal, emit: (snapshot) => out.print(`${SafeJSON.stringify(snapshot)}\n`), + selections: (listener) => { + selections.add(listener); + return () => selections.delete(listener); + }, }); } finally { signal.removeEventListener("abort", abort); process.stdin.removeListener("end", abort); + process.stdin.removeListener("data", lines.push); if (options.stopOnStdin) { process.stdin.pause(); } @@ -195,6 +213,28 @@ shelf { handleTermination: true } ); }); +shelf + .command("video <input>") + .description( + "Stage a screen recording for later attachment; attached, it gets the frame review a pasted video gets" + ) + .option("--name <name>", "The name the shelf shows") + .action(async (input: string, options: { name?: string }, command) => { + await withInterrupt( + async (signal) => { + out.result( + await stageShelfMedia({ + root: command.optsWithGlobals().stateRoot, + input, + name: options.name, + signal, + type: "video", + }) + ); + }, + { handleTermination: true } + ); + }); shelf.command("capture").action(async (_options, command) => { await withInterrupt( async (signal) => { diff --git a/src/hub/widget-voice-notes-cli.ts b/src/hub/widget-voice-notes-cli.ts index e6de2dc961..5c83ba4daa 100644 --- a/src/hub/widget-voice-notes-cli.ts +++ b/src/hub/widget-voice-notes-cli.ts @@ -7,12 +7,15 @@ import { out } from "@genesiscz/utils/logger"; import type { Command } from "commander"; import { z } from "zod"; import { + addLiveVoiceNote, discardVoiceNote, editVoiceNote, + incomingClipPath, listVoiceNotes, readVoiceNote, recordVoiceNote, transcribeVoiceNote, + voiceNoteContextSchema, } from "./lib/widget/voice-notes"; export function registerWidgetVoiceNotes(widget: Command): void { @@ -23,6 +26,41 @@ export function registerWidgetVoiceNotes(widget: Command): void { .action(async (_options, command) => { out.result(await listVoiceNotes(command.optsWithGlobals().stateRoot)); }); + notes + .command("add") + .description("File a live dictation's audio and text as a voice note") + .requiredOption("--clip <path>", "raw s16le 16 kHz mono audio in the voice notes incoming folder") + .requiredOption("--text <text>", "the dictated text") + .option( + "--context <json>", + "where it was spoken: {sessionKey, sessionTitle?, decisionId?, decisionNumber?, title?}" + ) + .option("--provider <provider>") + .option("--model <model>") + .option("--language <codes>") + .option("--json") + .action(async (options, command) => { + const context = options.context + ? voiceNoteContextSchema.parse(SafeJSON.parse(options.context, { strict: true })) + : undefined; + const note = await addLiveVoiceNote({ + root: command.optsWithGlobals().stateRoot, + clip: options.clip, + text: options.text, + context, + provider: options.provider, + model: options.model, + language: options.language, + }); + out.result({ kind: "added", note }); + }); + notes + .command("incoming-path") + .description("A fresh path in the incoming folder for voice listen --save-audio") + .option("--json") + .action(async (_options, command) => { + out.result({ path: await incomingClipPath(command.optsWithGlobals().stateRoot) }); + }); notes .command("record") .option("--id <uuid>") @@ -30,8 +68,15 @@ export function registerWidgetVoiceNotes(widget: Command): void { .option("--mic-launcher <path>") .option("--wait-for-start") .option("--stop-on-stdin") + .option( + "--context <json>", + "where it was recorded: {sessionKey, sessionTitle?, decisionId?, decisionNumber?, title?}" + ) .option("--json") .action(async (options, command) => { + const context = options.context + ? voiceNoteContextSchema.parse(SafeJSON.parse(options.context, { strict: true })) + : undefined; if (options.input === "-" && (options.waitForStart || options.stopOnStdin)) { throw new Error("PCM stdin cannot also control recording"); } @@ -58,6 +103,7 @@ export function registerWidgetVoiceNotes(widget: Command): void { signal, stopSignal: control?.stopSignal, onEvent: (event) => out.print(`${SafeJSON.stringify(event)}\n`), + context, }); out.result({ kind: "recorded", note }); } catch (error) { diff --git a/src/jev/commands/compact.ts b/src/jev/commands/compact.ts index 51b05d308d..5a0cfa0e76 100644 --- a/src/jev/commands/compact.ts +++ b/src/jev/commands/compact.ts @@ -11,6 +11,7 @@ import { selectedProvider } from "@genesiscz/utils/ai/evaluation/cli"; import { createEvaluator, type Evaluator } from "@genesiscz/utils/ai/evaluation/service"; import { isInteractive, suggestEnumFlag } from "@genesiscz/utils/cli"; import { ui } from "@genesiscz/utils/cli/ui"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; import { profiler } from "@genesiscz/utils/profile"; import type { Command } from "commander"; @@ -136,7 +137,7 @@ export function registerCompact(program: Command): void { return; } - const text = file === "-" ? await Bun.stdin.text() : await Bun.file(file).text(); + const text = file === "-" ? await Bun.stdin.text() : readWholeTextSync(file); if (!text.trim()) { throw new Error("compact needs a transcript on stdin or a file path."); } diff --git a/src/jev/lib/browser/writer.ts b/src/jev/lib/browser/writer.ts index 623d3a0bcf..7228804316 100644 --- a/src/jev/lib/browser/writer.ts +++ b/src/jev/lib/browser/writer.ts @@ -35,7 +35,9 @@ export interface WriterAnswer { } /** The one model call the writer makes; `callLLMStructured` in production, a fake in tests. */ -export type WriterCall = (options: CallLLMStructuredOptions<WriterAnswer>) => Promise<{ object: WriterAnswer }>; +export type WriterCall = ( + options: CallLLMStructuredOptions<z.ZodType<WriterAnswer>> +) => Promise<{ object: WriterAnswer }>; const answerSchema = z .object({ diff --git a/src/jev/mcp/tools/compact.ts b/src/jev/mcp/tools/compact.ts index 7197760a73..5109afd666 100644 --- a/src/jev/mcp/tools/compact.ts +++ b/src/jev/mcp/tools/compact.ts @@ -1,5 +1,6 @@ import { type CompactResult, compactSession, parseCompactSource } from "@genesiscz/utils/ai/compact"; import { createEvaluator, type Evaluator } from "@genesiscz/utils/ai/evaluation/service"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; import { z } from "zod"; @@ -36,7 +37,7 @@ export async function handleJevCompact( throw new Error("jev_compact needs either `text` or `file`."); } - const text = parsed.text ?? (await Bun.file(parsed.file ?? "").text()); + const text = parsed.text ?? readWholeTextSync(parsed.file ?? ""); log.info({ file: parsed.file, chars: text.length, llm: parsed.llm === true }, "jev_compact called"); return compactSession({ text, diff --git a/src/macos/GenesisKit/Package.swift b/src/macos/GenesisKit/Package.swift index 11be8b017d..cdd4c1dbb3 100644 --- a/src/macos/GenesisKit/Package.swift +++ b/src/macos/GenesisKit/Package.swift @@ -35,6 +35,7 @@ let kitProducts: [Product] = [ // Test targets only: the one copy of the `tools` disk fixtures (KitFixture). The GenesisTools bun tests // read the same files by path. An app target never links it. .library(name: "GenesisKitFixtures", targets: ["GenesisKitFixtures"]), + .library(name: "GenesisKitTestHooks", targets: ["GenesisKitTestHooks"]), ] // The markdown viewer's "Native+" renderer: diagrams and highlighted code without a web view. @@ -61,12 +62,15 @@ let kitTargets: [Target] = [ .target(name: "GenesisKitPosture", dependencies: ["GenesisKit"], path: "Sources/GenesisKitPosture"), .target(name: "GenesisKitDisk", dependencies: ["GenesisKit"], path: "Sources/GenesisKitDisk"), // Disk/: the frozen CLI contracts and real `tools` JSON output the disk decoders are tested against. - .target(name: "GenesisKitFixtures", path: "Sources/GenesisKitFixtures", resources: [.copy("Disk")]), + .target(name: "GenesisKitFixtures", path: "Sources/GenesisKitFixtures", resources: [.copy("Disk"), .copy("Git")]), + // Tests only: a window asked to become key is only ordered front, and the app never activates, so a test run + // never takes the user's keyboard focus. + .target(name: "GenesisKitTestHooks", path: "Sources/GenesisKitTestHooks", linkerSettings: [.linkedFramework("AppKit")]), .testTarget( name: "GenesisKitTests", dependencies: [ "GenesisKitBase", "GenesisKit", "GenesisKitCharts", "GenesisKitOverlay", "GenesisKitPosture", "GenesisKitDisk", - "GenesisKitFixtures", + "GenesisKitFixtures", "GenesisKitTestHooks", ], path: "Tests/GenesisKitTests" ), diff --git a/src/macos/GenesisKit/README.md b/src/macos/GenesisKit/README.md index 7af9c305bc..e81ad76ae3 100644 --- a/src/macos/GenesisKit/README.md +++ b/src/macos/GenesisKit/README.md @@ -53,6 +53,7 @@ A fixture an app test needs goes there under its own name, never into a test tar | `Paths/` | `PathOpener` (folder in Finder by bundle id, file in Cursor at a line), `PathLabel`, `PathActionsMenu`, `Clipboard` + `CopyToast`, `DefaultApp` (a file's default app by name, opened at a line through `cursor://` / `vscode://`) | | `Diff/` | git and diffs for any host: `GenesisKitGTProvider` (the data source, swappable), `GitWorkingTreeSource` + `DiffScope`, `GitDiffView` + `GitScopePicker` + `GitCommitPicker` (popover choosers built when opened, `GitPickerChip`), `GitFileDiffModel`, `GitRepos` (one status per repository, refreshed by git's own folder events), the file state badge and marks, the gutter (`GitGutterModel`, `GitHunks`, `GitHunkPopover`), history and commits (`GitHistoryModel`, `GitCommitList`, `GitRepoPanel`, `GitCommitView`), tree marks, agent blame | | `Time/` | `LiveTime`, `LiveAgo`, `LiveTimeFormat` | +| `Navigation/` | `NavigationHistory` (a log of every place a window showed, who took it there: user, agent or link, and why; Back / Forward / hop to any entry; visits never replace), `NavigationHistoryStore` (the host persists it: the hub through `tools hub nav`), `NavigationHistoryList` (the list, newest first, a click hops) | | `Cmux/` | `CmuxTree`, `CmuxTarget`, `CmuxTargetPicker` (Tree / Layout), `CmuxSessionPanel` | | `Settings/` | the settings window (`FeatureSettingsWindowController`, `NativeSettingsStore`, pages and controls, `NativeSettingsTilePicker`), the plug-in contract for a window that several parts of an app add pages to (`FeatureSettingsPlugin`, `FeatureSettingsPluginHost`, `FeatureSettingsMode`) with the two pages every such window has (`FeatureSettingsPages.general`, `.about(additionalInfo:)`), its look (`NativeSettingsAppearance`, `NativeSettingsBackdrop`, `NativePaneFill` / `NativePaneBackground` for a host that draws its panes in that look), and the hub's preferences with their Hub pages (`HubPreferences`, `HubSettingsPages`) | | `Alerts/` | Turning measured values into alert events (`AlertPolicy`: tiers, hysteresis, snooze, quiet hours), and the channels that show them (`AlertDispatcher`, `AlertChannelMatrix`) | @@ -66,6 +67,7 @@ A fixture an app test needs goes there under its own name, never into a test tar | `Perf/` | `PerfLog`, `HangWatch`, `MainStackSampler`, `MonitorPerf`, `RenderProbe`, `PerfConfiguration`, `MainBusy` (main-thread busy time after an event, `measureUntilSettled`, and a `Meter` for benches), `SessionOpenBench` (times one open of a session screen for the bench tests of both apps) | | `Tools/` | `ToolsBridge` (runs `tools`), `ToolsLineStream` (one long-running `tools` child, stdout as whole lines on the main queue, stdin held open so the child ends with the app), `MonitorJSON`, `TitleFormatter`, `SessionTranscriptClient` (the `tools ai sessions tail` envelope), `TranscriptPromptPart`, `DiskCache` (last answers on disk; `load` / `loadData` read off the main thread), `DirectoryWatcher` (one FSEvents stream over several folders, a path filter, main-queue callback) | | `Providers/` | `AIProviderMeta`, `AIProviders`, `AIProviderGlyph` | +| `Recording/` | screen recording for any host: `ScreenRecorder` (picker, grant, session, floating controls) with `ScreenRecordingHost`, `RecordingTargetPicker`, `RecordingControlsView`, `RecordingSessionController` (one `tools control capture session`), `RecordingTarget(s)`, `RecordingSettings` + `RecordingSettingsView` | | `Sessions/` | `SessionPalette`, `SessionFormat` | | `Sessions/Transcript/` | `SessionTranscriptList`, `TranscriptDocument`, prompt parts, `TranscriptScrollAnchor`, `TranscriptBus` / `TranscriptFilters`, `TranscriptMarkdownStyle`, `TranscriptLiveTail` (one `tools ai sessions tail --live` child, or a server subscription, per open screen; its `TranscriptLiveActivity` says what the agent does now, the list's last row through a `TranscriptActivityFeed` only that row observes) | | `Sessions/ToolCalls/` | `SessionToolCallView` + `TranscriptServices`, `SessionNativeLog` + `SessionNativeLogStore`, `SessionToolChanges`, `BatchedToolChangeSource` + `ToolChangeBatcher` (rows that ask within 100 ms share one `tools agents changes` run) | @@ -101,6 +103,7 @@ count of the section. - [Pose](#pose) (8) - [Posture](#posture) (9) - [Providers](#providers) (3) +- [Recording](#recording) (7) - [Sessions](#sessions) (65) - [Settings](#settings) (49) - [Style](#style) (32) @@ -1538,16 +1541,23 @@ InfoStrip( #### `NoticePill` -A short status line: an icon, a message and an optional dim detail line. A close icon is always shown. A success notice calls `dismiss` six seconds after it appears (the timer restarts when `text` changes); an error stays until the person dismisses it. The text wraps rather than ending in an ellipsis, and it can be selected and copied. +A status line: an icon, a message, an optional dim detail line, the ways to fix what it reports, and the full message behind "Details". A success notice without fixes calls `dismiss` six seconds after it appears (the timer restarts when `text` changes); an error, a warning, or a notice with a fix stays until the person dismisses it or it no longer applies. The text wraps rather than ending in an ellipsis, and it can be selected and copied. - `text: String`: the message. - `detail: String? = nil`: a dimmer second line. -- `isError: Bool = false`: shows a red warning icon and keeps the pill on screen. -- `dismiss: () -> Void`: called by the close icon, and by the timer for a success notice. The host removes the pill in it. +- `more: String? = nil`: the full message (stderr, a stack, a path list), shown behind a "Details" disclosure. +- `tone: NoticePill.Tone`: `.success` (green check), `.warning` (amber) or `.error` (red). The older `isError: Bool` initializer maps to `.error` or `.success`. +- `fixes: [NoticeFix] = []`: one row per fix. + - `NoticeFix.button(_ label:symbol:confirm:perform:)`: the label says what a press does ("Quit the older Genesis (pid 1234)"). With `confirm:` (a `NoticeFix.Confirmation` with the question, an optional consequence and the confirming label) the notice asks inside itself before it acts, so the question also works in a panel that never becomes key. + - `NoticeFix.command(_ label:, _ command:)`: a command for the person's terminal; the notice shows it and copies it. +- `dismiss: (() -> Void)? = nil`: called by the close icon, and by the timer for a success notice. The host removes the pill in it. ```swift if let notice { - NoticePill(text: notice.text, detail: notice.detail, isError: notice.isError) { + NoticePill(text: notice.text, detail: notice.detail, more: notice.stderr, tone: .warning, fixes: [ + .button("Quit the older Genesis (pid \(pid))", confirm: .init("Quit it?", confirmLabel: "Quit")) { quit(pid) }, + .command("Or from a terminal", "kill \(pid)"), + ]) { self.notice = nil } } @@ -6686,6 +6696,102 @@ HStack(spacing: 6) { } ``` +### Recording + +Screen recording for any host. `ScreenRecorder` is the entry point: it shows `RecordingTargetPicker` in a floating panel (a window, an app or a display), checks the Screen Recording grant, runs `tools control capture session` through `RecordingSessionController`, and floats `RecordingControlsView` next to what is recorded: pause or resume, switch what is recorded, restart, the host's "stop and use" action and stop and discard. The host says what "use" means through `ScreenRecordingHost` (the widget pastes the movie into a session's draft as "Stop and paste", the Capture shelf stages it as "Stop and save"). The recorder records in segments and joins them into one H.264 MP4 of at most ten minutes; the controls panel is left out of every movie by its window id, because ScreenCaptureKit ignores `sharingType = .none` on macOS 15 and later. One recording runs at a time in a process. `RecordingSettings` holds the preferences and `RecordingSettingsView` is their settings page. + +#### `ScreenRecorder` + +The whole flow for one host. It is `@MainActor` and an `ObservableObject`. + +- `bridge: ToolsBridge`: runs `tools control capture targets --json` and `tools control capture session`. +- `settings: RecordingSettings? = nil`: the preferences; `RecordingSettings.shared` when nil. +- `permissionGate: ((PermissionNeed) async -> Bool)? = nil`: the Screen Recording check; `PermissionCenter.shared.ensure` when nil. + +Properties and methods: + +- `isActive: Bool`: true from the picker's first appearance until the recording ended. +- `begin(host: ScreenRecordingHost, anchor: CGPoint? = nil, preferredPid: Int32? = nil)`: opens the picker near `anchor` (a Cocoa screen point, the pointer when nil), starting on the front window of `preferredPid` (else the frontmost app that is not this process), then records the choice. While another recording runs it brings that one's controls forward instead. +- `stopAndUse()`, `discard()`: what the controls' buttons do, for a host control (the widget's video button stops and pastes on a second press). +- `showControls()`: brings the controls forward. + +Switching target pauses the recording while the picker is open, so the movie never shows the picker; a cancel resumes. + +```swift +let recorder = ScreenRecorder(bridge: bridge) +recorder.begin(host: ScreenRecordingHost( + useTitle: "Stop and save", + permissionReason: "Records the window, app or display you choose.", + onUse: { movie in try await library.add(movie) }, + onError: { message in notice = message })) +``` + +#### `ScreenRecordingHost` + +What a host does with a recording. + +- `useTitle: String`: the name of "stop and keep" in the controls, such as "Stop and paste". +- `useSymbol: String = "stop.fill"`: its symbol. +- `permissionReason: String`: why the recording needs Screen Recording, shown in the permission dialog. +- `onUse: (URL) async throws -> Void`: gets the finished movie; the file is the host's from then on. A throw is reported through `onError` with the movie's path. +- `onDiscard: () -> Void = {}`: the person discarded the recording. +- `onError: (String) -> Void`: the recording could not start or failed, or `onUse` threw. +- `onStart: () -> Void = {}`: the controls are up and the recorder starts; a host can step aside (the widget collapses). + +#### `RecordingTargetPicker` + +Picks what to record: tabs for windows (grouped under their app, with titles and sizes), apps and displays, a filter field, and the rows as buttons. It starts on `initial`. ↑ and ↓ move, Return records, Esc cancels, a click on a row records it. Values and closures only. + +- `load: RecordingTargetPicker.Load`: `.loading`, `.failed(String)` (with a Try again button) or `.loaded(RecordingTargets)`. +- `title: String = "Choose what to record"` +- `initial: RecordingTarget?`: the highlighted row and the first tab. +- `onChoose: (RecordingTarget) -> Void`, `onCancel: () -> Void`, `onRetry: () -> Void = {}` + +```swift +RecordingTargetPicker(load: .loaded(targets), initial: targets.defaultTarget(preferredPid: pid), + onChoose: { start($0) }, onCancel: { close() }) +``` + +#### `RecordingControlsView` + +The floating controls of a running recording: a grip, the recorded time with a red dot (a spinner while starting or saving, a pause glyph while paused, the time left in the last minute), what is recorded as a chip that switches it, pause or resume, restart, the use button and stop and discard. The grip, dot and time drag the window. With `confirmsDiscard`, discard and restart ask inside the controls once something was recorded ("Discard 1:23 of recording?" with Discard and Keep). + +- `state: RecordingControlsState`: `phase`, `elapsedMs`, `limitMs`, `target`, `pauseReason` (`user`, `limit`, `recorder`), `notice`, `useTitle`, `useSymbol`, `confirmsDiscard`. +- `actions: RecordingControlsActions`: `pause`, `resume`, `switchTarget`, `restart`, `use`, `discard`, `dismissNotice`, `moved`. + +```swift +RecordingControlsView( + state: RecordingControlsState(phase: .recording, elapsedMs: 83_000, target: target, useTitle: "Stop and paste"), + actions: RecordingControlsActions(pause: pause, resume: resume, switchTarget: pick, restart: restart, + use: stop, discard: discard)) +``` + +#### `RecordingSessionController` + +One `tools control capture session --json --stop-on-stdin` child on `ToolsLineStream`. It publishes `phase` (`RecordingPhase`: idle, starting, recording, paused, finishing, done, discarded, failed), `elapsedMs` (time in the movie: no arming, no paused time), `limitMs`, `target`, `pauseReason`, `segments` and `notice`, and calls `onDone(URL, durationMs)`, `onDiscarded()` or `onFailed(message, code)` (`screen-recording-denied` for a missing grant). + +- `start(target:excludeWindowIDs:frameRate:showsBorder:) throws`: `excludeWindowIDs` are windows no movie may show. +- `pause()`, `resume()`, `switchTo(_:)`, `restart()`, `stop()` (keeps the movie), `discard()`, `terminate()` (SIGTERM: the session stops and keeps what it recorded). + +#### `RecordingTarget`, `RecordingTargets` + +`RecordingTarget` is `.window(ids:app:title:pid:)`, `.app(app:name:)` (a pid, name or bundle id) or `.display(index:name:)` (NSScreen order); `json` is the CLI's form, and `title`, `subtitle` and `symbol` describe it. `RecordingTargets` decodes `tools control capture targets --json`: `windows` (`RecordingWindow`: id, pid, app, title, bounds in CG points), `apps` (`RecordingApp`), `displays` (`RecordingDisplay`) and `frontmostPid`; `excluding(pids:)` drops a host's own process and `defaultTarget(preferredPid:)` is the front window of that app, else of the frontmost app, else the main display. + +#### `RecordingSettings`, `RecordingSettingsView` + +The recorder's preferences in the app's defaults (`recording.*`), with their page (the widget shows it as Widgets › Screen recording): + +- `placement: RecordingPanelPlacement = .nearTarget`: where the controls appear (under the recorded window, or at the bottom of a recorded display), or a corner of that display. A drag moves them for the rest of the recording. +- `frameRate: Int = 15`: frames per second while something moves (5, 10, 15, 24 or 30); a still screen records at 2. +- `showsBorder: Bool = true`: the pulsing red border around what is recorded. It is never in the movie. +- `confirmsDiscard: Bool = true`: discard and restart ask first. + +```swift +NativeSettingsPage(id: "app.recording", title: "Screen recording", symbol: "record.circle", tint: .red) { + RecordingSettingsView() +} +``` + ### Sessions Sessions shows one AI coding session (Claude, Codex or Grok) as a screen. `SessionDetailScreen` is the main entry point: a header, a transcript (`SessionTranscriptList`) and a 300 pt sidebar of usage, files, commits and sub-agents, and `SessionSidebarSplit` lets the sidebar cover the transcript in a narrow pane. A host turns `tools ai sessions tail` turns into a `TranscriptDocument` and a `SessionActivityDigest`, fills `SessionDetailInfo` for the header and passes its buttons as closures in `SessionDetailActions`. The host hooks are `TranscriptServices` (the session file, change log, row actions, notice buttons and the search callback for the tool rows), `SessionDetailActions`, `TranscriptBus` (jump and reveal messages from the sidebar to the transcript) and `TranscriptFilters` (the tool filter of each session). `TranscriptLiveTail` follows a running session, `TranscriptActivityFeed` carries its live activity row, and `SessionPalette` and `SessionFormat` hold the shared look and number formats. The entries below start with the transcript, its document model, the live tail and the shared tokens. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift b/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift new file mode 100644 index 0000000000..bf36ac5fe2 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift @@ -0,0 +1,164 @@ +import AVFoundation +import AudioToolbox +import CoreAudio +import Foundation + +/// One audio output of this Mac, with the delay macOS reports for it. +public struct AudioOutputDevice: Identifiable, Hashable, Sendable { + public let id: AudioDeviceID + public let uid: String + public let name: String + public let transport: UInt32 + /// Device latency + safety offset + stream latency + one IO buffer, as CoreAudio reports them, in seconds. An AirPlay + /// output (a HomePod) reports about 2 s; built-in speakers about 0.03 s (measured 2026-10-25). + public let delay: TimeInterval + + public init(id: AudioDeviceID, uid: String, name: String, transport: UInt32, delay: TimeInterval) { + self.id = id + self.uid = uid + self.name = name + self.transport = transport + self.delay = delay + } + + public var isVirtual: Bool { + transport == kAudioDeviceTransportTypeVirtual || transport == kAudioDeviceTransportTypeAggregate + || transport == kAudioDeviceTransportTypeAutoAggregate + } + + public var isBuiltIn: Bool { transport == kAudioDeviceTransportTypeBuiltIn } + + /// "2.0 s late" or "27 ms late". + public var delayText: String { Self.delayText(delay) } + + public static func delayText(_ seconds: TimeInterval) -> String { + seconds >= 1 ? String(format: "%.1f s late", seconds) : "\(Int((seconds * 1000).rounded())) ms late" + } +} + +/// The Mac's audio outputs, read from CoreAudio: the list, the system output, a faster alternative, and pinning an +/// `AVAudioEngine` to one device. Clicky plays through it; any part that plays sound can. +public enum AudioOutputs { + /// Every device with at least one output stream. + public static func outputs() -> [AudioOutputDevice] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + let system = AudioObjectID(kAudioObjectSystemObject) + guard AudioObjectGetPropertyDataSize(system, &address, 0, nil, &size) == noErr else { return [] } + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size) + guard AudioObjectGetPropertyData(system, &address, 0, nil, &size, &ids) == noErr else { return [] } + return ids.compactMap(device(for:)) + } + + public static func systemDefaultID() -> AudioDeviceID? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var id = AudioDeviceID(0) + var size = UInt32(MemoryLayout<AudioDeviceID>.size) + guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr, + id != 0 else { return nil } + return id + } + + /// An output to offer instead of a late one: the built-in speakers, else the connected output with the least delay. + /// Virtual and aggregate devices are never offered: they may not reach a speaker. + public static func suggestion(instead late: AudioOutputDevice, outputs: [AudioOutputDevice], + limit: TimeInterval) -> AudioOutputDevice? { + let candidates = outputs.filter { $0.uid != late.uid && !$0.isVirtual && $0.delay < limit } + return candidates.first(where: \.isBuiltIn) ?? candidates.min { $0.delay < $1.delay } + } + + /// Makes `engine` play on `device` from its next start (call it while the engine is stopped). Returns false when + /// CoreAudio refused or the engine renders offline. + @discardableResult + public static func pin(_ engine: AVAudioEngine, to device: AudioDeviceID) -> Bool { + guard !engine.isInManualRenderingMode, let unit = engine.outputNode.audioUnit else { return false } + var device = device + return AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, + UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr + } + + // MARK: - CoreAudio + + private static func device(for id: AudioDeviceID) -> AudioOutputDevice? { + let streams = outputStreams(id) + guard !streams.isEmpty, let uid = string(id, kAudioDevicePropertyDeviceUID) else { return nil } + let output = kAudioObjectPropertyScopeOutput + let global = kAudioObjectPropertyScopeGlobal + let rate = value(id, kAudioDevicePropertyNominalSampleRate, global, Float64(0)) ?? 0 + let frames = (value(id, kAudioDevicePropertyLatency, output, UInt32(0)) ?? 0) + + (value(id, kAudioDevicePropertySafetyOffset, output, UInt32(0)) ?? 0) + + (value(id, kAudioDevicePropertyBufferFrameSize, global, UInt32(0)) ?? 0) + + (value(streams[0], kAudioStreamPropertyLatency, global, UInt32(0)) ?? 0) + return AudioOutputDevice( + id: id, uid: uid, name: string(id, kAudioDevicePropertyDeviceNameCFString) ?? "Output", + transport: value(id, kAudioDevicePropertyTransportType, global, UInt32(0)) ?? 0, + delay: rate > 0 ? Double(frames) / rate : 0) + } + + private static func outputStreams(_ id: AudioDeviceID) -> [AudioStreamID] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, mScope: kAudioObjectPropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0 else { return [] } + var streams = [AudioStreamID](repeating: 0, count: Int(size) / MemoryLayout<AudioStreamID>.size) + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &streams) == noErr else { return [] } + return streams + } + + private static func value<T: BinaryInteger & Sendable>(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector, + _ scope: AudioObjectPropertyScope, _ initial: T) -> T? { + var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain) + var result = initial + var size = UInt32(MemoryLayout<T>.size) + return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &result) == noErr ? result : nil + } + + private static func value(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector, + _ scope: AudioObjectPropertyScope, _ initial: Float64) -> Float64? { + var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain) + var result = initial + var size = UInt32(MemoryLayout<Float64>.size) + return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &result) == noErr ? result : nil + } + + private static func string(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + var text: Unmanaged<CFString>? + var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size) + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &text) == noErr, let text else { return nil } + return text.takeRetainedValue() as String + } +} + +/// Calls `changed` on the main queue when the system output or the list of devices changes. +@MainActor +public final class AudioOutputMonitor { + private var listeners: [(AudioObjectPropertyAddress, AudioObjectPropertyListenerBlock)] = [] + + public init(changed: @escaping @MainActor () -> Void) { + for selector in [kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDevices] { + var address = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + let block: AudioObjectPropertyListenerBlock = { _, _ in + MainActor.assumeIsolated { changed() } + } + if AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block) == noErr { + listeners.append((address, block)) + } + } + } + + public func stop() { + for (address, block) in listeners { + var address = address + AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block) + } + listeners.removeAll() + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceLevelBars.swift b/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceLevelBars.swift new file mode 100644 index 0000000000..6e57888dee --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceLevelBars.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// Seven bars that move with the voice level while someone talks: the Flow pill's bars, shared so the widget's reply +/// box shows the same waves (Martin, 2026-10-23: "you should show me the "waves" as i am talking"). +/// +/// The recognisers expose one RMS level, not a spectrum, so these are one amplitude shaped by fixed per-bar weights: +/// a motion cue, not a frequency display. +public struct VoiceLevelBars: View { + /// The level, 0 to 1. + public var level: Double + /// Listening: full height range and opacity. Otherwise the bars rest at their base height, dimmed. + public var active: Bool + public var tint: Color + public var reduceMotion: Bool + + public init(level: Double, active: Bool = true, tint: Color, reduceMotion: Bool = false) { + self.level = level + self.active = active + self.tint = tint + self.reduceMotion = reduceMotion + } + + static let weights: [Double] = [0.45, 0.7, 0.95, 1.0, 0.9, 0.65, 0.4] + + public var body: some View { + HStack(spacing: 3) { + ForEach(0..<Self.weights.count, id: \.self) { index in + Capsule() + .fill(tint) + .frame(width: 3, height: height(index)) + .opacity(active ? 1 : 0.35) + } + } + .frame(width: 44, height: 22, alignment: .center) + .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: level) + .accessibilityHidden(true) + } + + func height(_ index: Int) -> CGFloat { + let base: CGFloat = 4 + guard active else { return base } + return base + CGFloat(max(0, min(1, level)) * Self.weights[index]) * 16 + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift b/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift index 7c03bd4bf2..2b2eec3dc5 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift @@ -19,7 +19,9 @@ public enum VoiceMicrophonePermission: Equatable, Sendable { switch self { case .authorized: return nil case .notDetermined: return "Microphone access is needed. Press Record to review the macOS permission prompt." - case .denied: return "Microphone access is off. Enable this app in System Settings → Privacy & Security → Microphone." + case .denied: + let app = PermissionKind.appName + return "Microphone access is off. In System Settings, open Privacy & Security, then Microphone and turn on \(app)." case .restricted: return "Microphone access is restricted by this Mac's settings or administrator." case .unknown: return "Microphone permission could not be checked. Review Microphone settings before recording." } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift b/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift index d8f1e2c20e..3cc79e3294 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift @@ -33,29 +33,74 @@ public enum NativeChartSampling { return .dateTime.hour().minute() } + /// How a bin combines the points that fall into it: counts add up, rates and shares average. + public enum Combine: Sendable { case sum, average } + + /// A calendar bin (a day or a week) follows the calendar: weeks start on the user's first weekday and a day + /// can be 23 or 25 hours long. Every other step is a fixed grid. + static func calendarComponent(for step: TimeInterval) -> Calendar.Component? { + step == 86400 ? .day : step == 604800 ? .weekOfYear : nil + } + + /// The width of one drawn bin: `step`, or a multiple of it when the span would need more than 1,200 bins. + public static func binInterval(start: Date, end: Date, step: TimeInterval) -> TimeInterval { + if calendarComponent(for: step) != nil { return step } + return max(step, ceil(end.timeIntervalSince(start) / (step * 1200)) * step) + } + + /// The span a bin covers, as a reader names it: "Mon, Oct 19 to Sun, Oct 25", "Mon, Oct 19, 2026", + /// "Oct 19, 14:00 to 15:00". + public static func spanText(_ date: Date, step: TimeInterval, calendar: Calendar = .current) -> String { + let day = Date.FormatStyle.dateTime.weekday(.abbreviated).month(.abbreviated).day() + if step >= 604800 { + let days = max(1, Int((step / 86400).rounded())) + let last = calendar.date(byAdding: .day, value: days - 1, to: date) ?? date + return "\(date.formatted(day)) to \(last.formatted(day))" + } + if step >= 86400 { return date.formatted(day.year()) } + let end = date.addingTimeInterval(step) + return "\(date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) to \(end.formatted(.dateTime.hour().minute()))" + } + public static func bins(points: [NativeTimePoint], start: Date, end: Date, step: TimeInterval, - calendar: Calendar = .current) -> [NativeTimePoint] { + calendar: Calendar = .current, combine: Combine = .sum) -> [NativeTimePoint] { guard let first = points.first, end > start, step > 0 else { return [] } - if step == 86400 { - var day = calendar.startOfDay(for: max(first.date, start)) - let values = Dictionary(points.filter { $0.date >= day && $0.date < end }.map { - (calendar.startOfDay(for: $0.date), $0.value) - }, uniquingKeysWith: +) + if let component = calendarComponent(for: step) { + func key(_ date: Date) -> Date { + component == .day ? calendar.startOfDay(for: date) + : calendar.dateInterval(of: .weekOfYear, for: date)?.start ?? calendar.startOfDay(for: date) + } + var cursor = key(max(first.date, start)) + var sums: [Date: Double] = [:] + var counts: [Date: Int] = [:] + for point in points where point.date >= cursor && point.date < end { + let bin = key(point.date) + sums[bin, default: 0] += point.value + counts[bin, default: 0] += 1 + } var result: [NativeTimePoint] = [] - while day < end && result.count < 1201 { - result.append(NativeTimePoint(date: day, value: values[day, default: 0])) - guard let next = calendar.date(byAdding: .day, value: 1, to: day), next > day else { break } - day = next + var visited = 0 + while cursor < end && visited < 1201 { + visited += 1 + if let count = counts[cursor] { + let sum = sums[cursor, default: 0] + result.append(NativeTimePoint(date: cursor, value: combine == .sum ? sum : sum / Double(count))) + } else if combine == .sum { + result.append(NativeTimePoint(date: cursor, value: 0)) + } + guard let next = calendar.date(byAdding: component, value: 1, to: cursor), next > cursor else { break } + cursor = next } return result } - let interval = max(step, ceil(end.timeIntervalSince(start) / (step * 1200)) * step) + let interval = binInterval(start: start, end: end, step: step) // Both bounds on the same interval grid, so a bin's time and count do not depend on where the view starts. let lower = max(floor(first.date.timeIntervalSince1970 / interval), floor(start.timeIntervalSince1970 / interval)) * interval let upper = end.timeIntervalSince1970 guard lower < upper else { return [] } let count = min(1201, Int(ceil((upper - lower) / interval))) var values = Array(repeating: 0.0, count: count) + var counts = Array(repeating: 0, count: count) var low = 0 var high = points.count while low < high { @@ -66,15 +111,28 @@ public enum NativeChartSampling { let time = point.date.timeIntervalSince1970 if time >= upper { break } let index = Int((time - lower) / interval) - if values.indices.contains(index) { values[index] += point.value } + if values.indices.contains(index) { + values[index] += point.value + counts[index] += 1 + } + } + return values.indices.compactMap { index in + let date = Date(timeIntervalSince1970: lower + Double(index) * interval) + switch combine { + case .sum: return NativeTimePoint(date: date, value: values[index]) + case .average: + return counts[index] > 0 ? NativeTimePoint(date: date, value: values[index] / Double(counts[index])) : nil + } } - return values.enumerated().map { NativeTimePoint( - date: Date(timeIntervalSince1970: lower + Double($0.offset) * interval), value: $0.element) } } } +/// A pannable, zoomable time series. Two ways to open: +/// - `rangeStart: nil`: on the latest `initialWindow`, zoomable from a quarter to 8 times that window. +/// - `rangeStart` set: fitted to the whole `rangeStart...end`; zoom goes from that whole range down to 5 bins, and +/// "Whole range" returns to it. public struct NativeTimeSeriesChart: View { - public enum Style: String, CaseIterable, Identifiable { + public enum Style: String, CaseIterable, Identifiable, Sendable { case line = "Line", area = "Area", bars = "Bars" public var id: Self { self } } @@ -83,7 +141,11 @@ public struct NativeTimeSeriesChart: View { private let initialWindow: TimeInterval private let valueLabel: String private let end: Date - @State private var style: Style = .area + private let rangeStart: Date? + private let combine: NativeChartSampling.Combine + private let valueFormat: ((Double) -> String)? + private let styleBinding: Binding<Style>? + @State private var ownStyle: Style = .area @State private var zoom = 1.0 @State private var position: Date @State private var selected: Date? @@ -91,49 +153,108 @@ public struct NativeTimeSeriesChart: View { @State private var plotWidth: CGFloat = 1 @GestureState private var dragging = false + /// - Parameters: + /// - rangeStart: the start of the period the page shows; the chart opens fitted to `rangeStart...end`. + /// - combine: how wide views merge bins; `.average` for rates and shares, which must not add up. + /// - valueFormat: how a value reads in the hover text and on the Y axis ("1h 5m", "12.5%"). + /// - style: where the Line, Area or Bars choice is kept; without it the choice lives as long as the view. public init(points: [NativeTimePoint], interval: TimeInterval, initialWindow: TimeInterval, - end: Date = Date(), valueLabel: String = "Events") { + end: Date = Date(), valueLabel: String = "Events", rangeStart: Date? = nil, + combine: NativeChartSampling.Combine = .sum, valueFormat: ((Double) -> String)? = nil, + style: Binding<Style>? = nil) { self.points = points self.interval = interval self.initialWindow = initialWindow self.valueLabel = valueLabel self.end = end - _position = State(initialValue: end.addingTimeInterval(-initialWindow)) + self.rangeStart = rangeStart + self.combine = combine + self.valueFormat = valueFormat + styleBinding = style + let start = rangeStart.map { min($0, end.addingTimeInterval(-interval * 5)) } + _position = State(initialValue: start ?? end.addingTimeInterval(-initialWindow)) + } + + private var style: Binding<Style> { styleBinding ?? $ownStyle } + private var fitted: Bool { rangeStart != nil } + private var minimumWindow: TimeInterval { interval * 5 } + /// The whole range in fitted mode. + private var span: TimeInterval { + guard let rangeStart else { return initialWindow } + return max(minimumWindow, end.timeIntervalSince(rangeStart)) + } + private var maxZoom: Double { fitted ? max(1, span / minimumWindow) : 8 } + private var minZoom: Double { fitted ? 1 : 0.25 } + private var window: TimeInterval { + fitted ? min(span, max(minimumWindow, span / zoom)) : max(minimumWindow, initialWindow / zoom) } - private var window: TimeInterval { max(interval * 5, initialWindow / zoom) } private var domain: ClosedRange<Date> { + if fitted { return end.addingTimeInterval(-span)...end } let first = points.first?.date ?? end.addingTimeInterval(-initialWindow) return min(first, end.addingTimeInterval(-window))...end } + private var visibleStart: Date { max(domain.lowerBound, position.addingTimeInterval(-window / 4)) } + private var visibleEnd: Date { min(end, position.addingTimeInterval(window * 1.25)) } private var visible: [NativeTimePoint] { - NativeChartSampling.bins(points: points, start: position.addingTimeInterval(-window / 4), - end: min(end, position.addingTimeInterval(window * 1.25)), step: interval) + NativeChartSampling.bins(points: points, start: visibleStart, end: visibleEnd, step: interval, combine: combine) + } + private var binWidth: TimeInterval { + NativeChartSampling.binInterval(start: visibleStart, end: visibleEnd, step: interval) } private var selectedPoint: NativeTimePoint? { guard let selected else { return nil } return visible.min { abs($0.date.timeIntervalSince(selected)) < abs($1.date.timeIntervalSince(selected)) } } + private func format(_ value: Double) -> String { valueFormat?(value) ?? NativeChartSampling.valueText(value) } + /// The zoom on a log scale, so one slider spans "whole two years" to "five bins" evenly. + private var zoomSlider: Binding<Double> { + Binding(get: { log(max(1, zoom / minZoom)) / log(maxZoom / minZoom) }, + set: { zoom = minZoom * pow(maxZoom / minZoom, $0) }) + } + private func pan(by fraction: Double) { + position = NativeChartSampling.clampedPosition(position.addingTimeInterval(window * fraction), window: window, domain: domain) + selected = nil + } + private var shownSpan: String { + let last = min(end, position.addingTimeInterval(window)) + let days = window >= 86400 * 2 + let first: Date.FormatStyle = days ? .dateTime.year().month(.abbreviated).day() + : .dateTime.year().month(.abbreviated).day().hour().minute() + let second: Date.FormatStyle = days ? .dateTime.year().month(.abbreviated).day() + : .dateTime.month(.abbreviated).day().hour().minute() + return "\(position.formatted(first)) to \(last.formatted(second))" + } + public var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack { - Picker("Chart style", selection: $style) { + HStack(spacing: 6) { + Picker("Chart style", selection: style) { ForEach(Style.allCases) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented).labelsHidden().frame(width: 210, alignment: .leading) Spacer() - Button { zoom = max(0.25, zoom / 2) } label: { Image(systemName: "minus.magnifyingglass").frame(width: 26, height: 26).contentShape(Rectangle()) } - .accessibilityLabel("Zoom out chart") - Slider(value: $zoom, in: 0.25...8).frame(width: 90).accessibilityLabel("Chart zoom") - Button { zoom = min(8, zoom * 2) } label: { Image(systemName: "plus.magnifyingglass").frame(width: 26, height: 26).contentShape(Rectangle()) } - .accessibilityLabel("Zoom in chart") + IconButton(systemName: "chevron.left", tooltip: "Earlier (Left Arrow)") { pan(by: -0.5) } + IconButton(systemName: "chevron.right", tooltip: "Later (Right Arrow)") { pan(by: 0.5) } + IconButton(systemName: "minus.magnifyingglass", tooltip: "Zoom out chart") { zoom = max(minZoom, zoom / 2) } + Slider(value: zoomSlider, in: 0...1).frame(width: 90).accessibilityLabel("Chart zoom") + .help("Chart zoom") + IconButton(systemName: "plus.magnifyingglass", tooltip: "Zoom in chart") { zoom = min(maxZoom, zoom * 2) } + if fitted { + Button("Whole range") { + zoom = 1 + position = domain.lowerBound + }.buttonStyle(.genHover()).help("Show the whole selected range") + .disabled(zoom <= 1.0001) + } Button("Latest") { position = max(domain.lowerBound, end.addingTimeInterval(-window)) } - }.buttonStyle(.borderless) + .buttonStyle(.genHover()).help("Show the most recent part") + } plot.frame(height: 235) HStack { if let point = selectedPoint { - Text("\(point.date.formatted(date: .abbreviated, time: .shortened)) · \(NativeChartSampling.valueText(point.value)) \(valueLabel.lowercased())") - } else { Text("Drag or scroll to explore. Hover to inspect a value.") } + Text("\(NativeChartSampling.spanText(point.date, step: binWidth)): \(format(point.value)) \(valueLabel.lowercased())") + } else { Text("Drag, scroll or use the arrow keys to explore. Hover to inspect a value.") } Spacer() - Text(position.formatted(date: .abbreviated, time: .shortened)).monospacedDigit() + Text(shownSpan).monospacedDigit() }.font(.caption).foregroundStyle(.secondary) } // A new period (custom dates, a refreshed report) or zoom keeps the view on data: a position left over from @@ -141,10 +262,13 @@ public struct NativeTimeSeriesChart: View { .onChange(of: zoom) { _, _ in position = NativeChartSampling.clampedPosition(position, window: window, domain: domain) } .onChange(of: domain) { _, value in position = NativeChartSampling.clampedPosition(position, window: window, domain: value) } } + private var plot: some View { - Chart { - ForEach(visible) { point in - switch style { + let shown = visible + let peak = shown.map(\.value).max() ?? 0 + return Chart { + ForEach(shown) { point in + switch style.wrappedValue { case .line: LineMark(x: .value("Time", point.date), y: .value(valueLabel, point.value)) .foregroundStyle(.mint).interpolationMethod(.linear) @@ -157,16 +281,22 @@ public struct NativeTimeSeriesChart: View { BarMark(x: .value("Time", point.date), y: .value(valueLabel, point.value)).foregroundStyle(.mint) } } - if visible.count == 1, let point = visible.first { + if shown.count == 1, let point = shown.first { PointMark(x: .value("Time", point.date), y: .value(valueLabel, point.value)) .foregroundStyle(.mint).symbolSize(36) } if let point = selectedPoint { RuleMark(x: .value("Selected time", point.date)).foregroundStyle(.secondary).lineStyle(StrokeStyle(dash: [3])) + .annotation(position: .top, spacing: 0, + overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))) { + Text(format(point.value)).font(.caption.weight(.semibold)).monospacedDigit() + .padding(.horizontal, 6).padding(.vertical, 2) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 5)) + } } } .chartXScale(domain: domain) - .chartYScale(domain: 0...max(1, (visible.map(\.value).max() ?? 0) * 1.12)) + .chartYScale(domain: 0...max(1, peak * 1.12)) .chartScrollableAxes(.horizontal) .chartXVisibleDomain(length: window) .chartScrollPosition(x: $position) @@ -188,7 +318,17 @@ public struct NativeTimeSeriesChart: View { selected = nil }) .onChange(of: dragging) { _, active in if !active { panOrigin = nil } } - .chartYAxis { AxisMarks(position: .leading) } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisTick() + if let valueFormat { + AxisValueLabel { if let number = value.as(Double.self) { Text(valueFormat(number)) } } + } else { + AxisValueLabel() + } + } + } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) { AxisGridLine() @@ -196,7 +336,11 @@ public struct NativeTimeSeriesChart: View { AxisValueLabel(format: NativeChartSampling.axisLabelFormat(interval: interval, window: window)) } } - .accessibilityLabel("\(valueLabel) timeline. Scroll horizontally to pan; use the zoom controls to change the visible range.") + .focusable() + .onKeyPress(.leftArrow) { pan(by: -0.25); return .handled } + .onKeyPress(.rightArrow) { pan(by: 0.25); return .handled } + .accessibilityLabel("\(valueLabel) timeline") + .accessibilityValue("\(shownSpan). \(shown.count) bars, highest \(format(peak)). Use the arrow keys to move earlier or later.") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalytics.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalytics.swift index 8bc2de367c..4a7e417ee8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalytics.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalytics.swift @@ -112,6 +112,40 @@ extension ClickyStatistics { result.presses += presses result.releases += presses } + // Before the minute detail: two years of hourly and daily totals, as the retention keeps them, so the long + // ranges have something to show. + let oldest = calendar.date(byAdding: .day, value: -730, to: start) ?? start + let hourlyKeysFrom = calendar.date(byAdding: .day, value: -366, to: now) ?? start + result.startedAt = oldest + result.historyStartedAt = oldest + result.performanceStartedAt = oldest + var hourDate = oldest + var index = 0 + while hourDate < start { + let hourOfDay = calendar.component(.hour, from: hourDate) + let weekday = calendar.component(.weekday, from: hourDate) + let season = 0.75 + 0.25 * sin(Double(index) / 900) + let factor = (weekday == 1 || weekday == 7 ? 0.3 : 1.0) * ((8..<22).contains(hourOfDay) ? 1.0 : 0.05) * season + let presses = Int((900 + 2400 * abs(sin(Double(index) / 29))) * factor) + index += 1 + defer { hourDate = hourDate.addingTimeInterval(3600) } + guard presses > 0 else { continue } + let hour = Int(hourDate.timeIntervalSince1970 / 3600) + let day = Int(calendar.startOfDay(for: hourDate).timeIntervalSince1970) + if hourDate >= hourlyKeysFrom { result.hours[hour] = ClickyActivityBucket(presses: presses, releases: presses) } + result.days[day, default: ClickyActivityBucket()].presses += presses + result.days[day, default: ClickyActivityBucket()].releases += presses + var performance = ClickyPerformanceBucket() + performance.presses = presses + performance.characters = Int(Double(presses) * 0.8) + performance.corrections = Int(Double(presses) * (0.04 + 0.02 * season)) + performance.activeSeconds = Double(performance.characters) / (3.6 + 0.6 * season) + performance.bursts = max(1, presses / 40) + performance.sessions = max(1, presses / 300) + result.performanceHours[hour] = performance + result.presses += presses + result.releases += presses + } let positions = ClickyKeyLayout.rows.flatMap { $0 } let weights = positions.enumerated().map { ($0.element.code, 1 + ($0.offset * 13) % 31) } let total = weights.reduce(0) { $0 + $1.1 } @@ -135,3 +169,110 @@ public enum ClickyKeyLayout { rows.lazy.flatMap { $0 }.first { $0.code == code }?.label ?? "Key \(code)" } } + +/// Everything the Stats page shows for one range, built off the main thread once per refresh instead of in the view +/// body on every published second. +struct ClickyStatsReport: Sendable { + var interval = DateInterval() + var total = 0 + var previousTotal: Int? + var totalDays = 0 + var coveredDays = 0 + var activeDays = 0 + var records = ClickyRecords(days: [:], today: Date()) + var busiestHour: Date? + var busiestHourValue = 0 + var peakMinute: Date? + var peakMinuteValue = 0 + var timeline: [NativeTimePoint] = [] + var dayTimeline: [NativeTimePoint] = [] + /// Hour-of-day by weekday, rows in the calendar's weekday order. + var heatmap: [NativeHeatmapCell] = [] + /// Average presses per occurrence of each weekday in the range, Monday-based. + var weekdayAverages = Array(repeating: 0.0, count: 7) + var loaded = false + + init() {} + + var dailyAverage: Double { coveredDays > 0 ? Double(total) / Double(coveredDays) : 0 } + + init(statistics: ClickyStatistics, interval: DateInterval, previous: DateInterval?, + resolution: ClickyTimeResolution, releases: Bool, now: Date, calendar: Calendar = .current) { + self.interval = interval + loaded = true + let earliest = statistics.historyStartedAt + ?? statistics.days.keys.min().map { Date(timeIntervalSince1970: Double($0)) } + totalDays = ClickyRanges.dayCount(interval, calendar: calendar) + coveredDays = ClickyRanges.coveredDays(interval, earliest: earliest, calendar: calendar) + let oldestHour = statistics.hours.keys.min().map { Date(timeIntervalSince1970: Double($0) * 3600) } + func presses(in range: DateInterval) -> Int { + // Hours give the exact span (Today against yesterday until the same time); past the hourly retention, + // whole days. + if let oldestHour, range.start >= oldestHour { + let first = Int(floor(range.start.timeIntervalSince1970 / 3600)) + let last = Int(ceil(range.end.timeIntervalSince1970 / 3600)) + return statistics.hours.reduce(0) { $1.key >= first && $1.key < last ? $0 + $1.value.presses : $0 } + } + return statistics.days.reduce(0) { sum, entry in + let day = Date(timeIntervalSince1970: Double(entry.key)) + return day >= calendar.startOfDay(for: range.start) && day < range.end ? sum + entry.value.presses : sum + } + } + total = presses(in: interval) + if let previous, let earliest, earliest < previous.end { previousTotal = presses(in: previous) } + + var daily: [Date: Double] = [:] + for (key, bucket) in statistics.days { + let day = Date(timeIntervalSince1970: Double(key)) + guard day >= calendar.startOfDay(for: interval.start), day < interval.end else { continue } + daily[day] = Double(releases ? bucket.releases : bucket.presses) + } + activeDays = daily.values.filter { $0 > 0 }.count + records = ClickyRecords(days: daily, today: now, calendar: calendar) + dayTimeline = daily.map { NativeTimePoint(date: $0.key, value: $0.value) }.sorted { $0.date < $1.date } + + var cells: [Int: Double] = [:] + for (key, bucket) in statistics.hours { + let date = Date(timeIntervalSince1970: Double(key) * 3600) + guard interval.contains(date), date < interval.end else { continue } + if bucket.presses > busiestHourValue { busiestHourValue = bucket.presses; busiestHour = date } + cells[ClickyWeekdays.index(of: date, calendar: calendar) * 24 + calendar.component(.hour, from: date), + default: 0] += Double(bucket.presses) + } + let order = ClickyWeekdays.order(calendar) + heatmap = order.enumerated().flatMap { row, weekday in + (0..<24).map { NativeHeatmapCell(row: row, column: $0, value: cells[weekday * 24 + $0, default: 0]) } + } + var occurrences = Array(repeating: 0, count: 7) + if let earliest, coveredDays > 0 { + var day = max(calendar.startOfDay(for: interval.start), calendar.startOfDay(for: earliest)) + while day < interval.end { + occurrences[ClickyWeekdays.index(of: day, calendar: calendar)] += 1 + guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } + } + var weekdayTotals = Array(repeating: 0.0, count: 7) + for (day, value) in daily { weekdayTotals[ClickyWeekdays.index(of: day, calendar: calendar)] += value } + weekdayAverages = (0..<7).map { occurrences[$0] > 0 ? weekdayTotals[$0] / Double(occurrences[$0]) : 0 } + + let firstMinute = Int(floor(interval.start.timeIntervalSince1970 / 60)) + for (minute, bucket) in statistics.minutes where minute >= firstMinute && bucket.presses > peakMinuteValue { + peakMinuteValue = bucket.presses + peakMinute = Date(timeIntervalSince1970: Double(minute) * 60) + } + timeline = statistics.timeline(resolution, releases: releases).filter { $0.date >= interval.start } + } + + static func prepare(statistics: ClickyStatistics, interval: DateInterval, previous: DateInterval?, + resolution: ClickyTimeResolution, releases: Bool, now: Date) async -> Self? { + let worker = Task.detached(priority: .userInitiated) { + PerfLog.span("clicky.stats.report", over: PerfLog.frameMs) { + Self(statistics: statistics, interval: interval, previous: previous, resolution: resolution, + releases: releases, now: now) + } + } + let report = await withTaskCancellationHandler { await worker.value } onCancel: { worker.cancel() } + return Task.isCancelled ? nil : report + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalyticsView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalyticsView.swift index ba12bf38ff..90bb9e026d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalyticsView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAnalyticsView.swift @@ -3,61 +3,87 @@ import SwiftUI @MainActor public struct ClickyAnalyticsView: View { @ObservedObject private var store: ClickyAnalyticsStore - @State private var tab = "Activity" - @State private var resolution: ClickyTimeResolution = .minute + private let status: ClickyRecordingStatus + private let fixRecording: (() -> Void)? + @AppStorage private var tab: String + @AppStorage private var storedRange: String + @AppStorage private var chartStyle: NativeTimeSeriesChart.Style + @State private var resolution: ClickyTimeResolution @State private var releases = false @State private var example: ClickyStatistics? @State private var exampleTime: Date? @State private var selectedKey: Int? + @State private var report = ClickyStatsReport() + @State private var previousName = "" + @State private var refresh = 0 + @State private var refreshPending = false + + /// - Parameters: + /// - status: whether Clicky records right now; anything but `.recording` shows why under the title. + /// - fixRecording: the action of the button beside that reason. + public init(store: ClickyAnalyticsStore, defaults: UserDefaults = .standard, + status: ClickyRecordingStatus = .recording, fixRecording: (() -> Void)? = nil) { + self.store = store + self.status = status + self.fixRecording = fixRecording + _tab = AppStorage(wrappedValue: "Activity", "clicky.stats.tab", store: defaults) + _storedRange = AppStorage(wrappedValue: ClickyRanges.week, "clicky.stats.range", store: defaults) + _chartStyle = AppStorage(wrappedValue: .area, "clicky.stats.chartStyle", store: defaults) + let range = ClickyRanges.known(defaults.string(forKey: "clicky.stats.range") ?? ClickyRanges.week, + allowed: ClickyRanges.presets) + _resolution = State(initialValue: ClickyRanges.resolution(for: range)) + } - public init(store: ClickyAnalyticsStore) { self.store = store } private var data: ClickyStatistics { example ?? store.snapshot } private var now: Date { exampleTime ?? Date() } - private var today: [ClickyActivityBucket] { - let start = Int(Calendar.current.startOfDay(for: now).timeIntervalSince1970 / 60) - return data.minutes.filter { $0.key >= start }.map(\.value) + private var range: String { ClickyRanges.known(storedRange, allowed: ClickyRanges.presets) } + private var earliest: Date? { + data.historyStartedAt ?? data.days.keys.min().map { Date(timeIntervalSince1970: Double($0)) } } - private var todayPresses: Int { today.reduce(0) { $0 + $1.presses } } - private var activeMinutes: Int { today.filter { $0.presses > 0 }.count } + private var interval: DateInterval { ClickyRanges.interval(range, now: now, earliest: earliest) } private var keyRanking: [(key: Int, value: Int)] { data.keys.sorted { $0.value == $1.value ? $0.key < $1.key : $0.value > $1.value } } + private struct Query: Equatable { + let range: String + let resolution: ClickyTimeResolution + let releases: Bool + let exampleTime: Date? + let refresh: Int + } + + /// New key presses rebuild the page at most every 5 seconds, off the main thread. + private func scheduleRefresh() { + guard example == nil, !refreshPending else { return } + refreshPending = true + Task { @MainActor in + try? await Task.sleep(for: .seconds(5)) + refreshPending = false + refresh &+= 1 + } + } + public var body: some View { VStack(alignment: .leading, spacing: 18) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Your keyboard, over time").font(.system(size: 21, weight: .semibold, design: .rounded)) - Text(example == nil ? "Recorded locally · updates once a second while typing" : "Sample data · never saved to your statistics") - .font(.caption).foregroundStyle(example == nil ? Color.secondary : .orange) - } - Spacer() - Button(example == nil ? "Explore sample data" : "Back to my data") { - if example == nil { - let reference = Date() - exampleTime = reference - example = ClickyStatistics.example(now: reference) - } else { - example = nil - exampleTime = nil - } - }.buttonStyle(.bordered).accessibilityIdentifier("clicky.stats.example") + header + if example == nil, let message = status.message { + ClickyRecordingBanner(message: message, actionTitle: status.actionTitle, action: fixRecording) } - HStack(spacing: 12) { - summary(example == nil ? "Today" : "Sample day", value: todayPresses.formatted(), symbol: "keyboard") - summary("Active minutes", value: activeMinutes.formatted(), symbol: "clock") - summary("Peak / minute", value: (today.map(\.presses).max() ?? 0).formatted(), symbol: "bolt") - } - Picker("Statistics view", selection: $tab) { - ForEach(["Activity", "Keys", "Rhythm"], id: \.self) { Text($0).tag($0) } - }.pickerStyle(.segmented).labelsHidden().accessibilityIdentifier("clicky.stats.view") - if data.historyStartedAt == nil { + if data.historyStartedAt == nil && example == nil { NativeSettingsCard { - Label("Detailed history starts with your next recorded key press.", systemImage: "chart.xyaxis.line") - Text("Your existing \(data.presses.formatted()) lifetime presses are preserved. There is no historical timeline to reconstruct from those totals.") - .font(.caption).foregroundStyle(.secondary) + EmptyState(symbol: "chart.xyaxis.line", + text: status == .recording ? "Detailed history starts with your next recorded key press." + : "No detailed history yet.", + detail: "Your existing \(data.presses.formatted()) lifetime presses are preserved. There is no historical timeline to reconstruct from those totals.", + actionTitle: "Explore Sample Data", action: showExample) } } else { + rangeRow + summary.redacted(reason: report.loaded ? [] : .placeholder) + Picker("Statistics view", selection: $tab) { + ForEach(["Activity", "Keys", "Rhythm"], id: \.self) { Text($0).tag($0) } + }.pickerStyle(.segmented).labelsHidden().accessibilityIdentifier("clicky.stats.view") switch tab { case "Keys": keys case "Rhythm": rhythm @@ -65,27 +91,133 @@ public struct ClickyAnalyticsView: View { } } HStack { - Text("\(data.presses.formatted()) lifetime presses · \(data.releases.formatted()) releases · \(data.sessions.formatted()) activations") + Text("\(data.presses.formatted()) lifetime presses, \(data.releases.formatted()) key releases, Clicky turned on \(data.sessions.formatted()) times") Spacer() if let since = data.historyStartedAt { Text("History since \(since.formatted(date: .abbreviated, time: .shortened))") } - }.font(.caption2).foregroundStyle(.secondary) + }.font(.caption).foregroundStyle(.secondary) } - // On the whole view, so every tab (Activity is the default) gets the once-a-second updates. + // On the whole view, so every tab (Activity is the default) gets the updates. .onAppear { store.attach() } .onDisappear { store.detach() } + .onChange(of: store.revision) { _, _ in scheduleRefresh() } + .task(id: Query(range: range, resolution: resolution, releases: releases, exampleTime: exampleTime, refresh: refresh)) { + let interval = interval + let previous = ClickyRanges.previous(interval, range: range) + let name = ClickyRanges.previousName(interval, range: range) + let built = await ClickyStatsReport.prepare(statistics: data, interval: interval, previous: previous, + resolution: resolution, releases: releases, now: now) + guard let built, !Task.isCancelled else { return } + report = built + previousName = name + } + } + + private func showExample() { + let reference = Date() + exampleTime = reference + example = ClickyStatistics.example(now: reference) + AccessibilityNotification.Announcement("Showing sample data. It is never saved.").post() + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Your keyboard, over time").font(.system(size: 21, weight: .semibold, design: .rounded)) + Text(example == nil + ? (status == .recording ? "Recorded locally. Updates every few seconds while you type." : "Recorded locally. Recording is paused.") + : "Sample data, never saved to your statistics") + .font(.caption).foregroundStyle(example == nil ? Color.secondary : .orange) + } + Spacer() + Button(example == nil ? "Explore Sample Data" : "Back to My Data") { + if example == nil { showExample() } else { + example = nil + exampleTime = nil + AccessibilityNotification.Announcement("Showing your data.").post() + } + }.buttonStyle(.bordered).accessibilityIdentifier("clicky.stats.example") + } } - private func summary(_ title: String, value: String, symbol: String) -> some View { + private var rangeRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Date range").font(.system(size: 13, weight: .medium)) + Spacer() + Picker("Date range", selection: Binding(get: { range }, set: { storedRange = $0 })) { + ForEach(ClickyRanges.presets, id: \.self) { Text($0).tag($0) } + }.labelsHidden().frame(width: 150, alignment: .trailing) + .accessibilityIdentifier("clicky.stats.range") + .onChange(of: storedRange) { _, next in + resolution = ClickyRanges.resolution(for: ClickyRanges.known(next, allowed: ClickyRanges.presets)) + } + } + if let coverage = ClickyRanges.coverageText(interval, earliest: earliest) { + Label(coverage, systemImage: "calendar.badge.clock").font(.caption).foregroundStyle(.secondary) + } + } + } + + private var summary: some View { + let day = Date.FormatStyle.dateTime.weekday(.abbreviated).month(.abbreviated).day() + let previousAverage = report.previousTotal.map { Double($0) / Double(max(1, report.totalDays)) } + return Grid(horizontalSpacing: 12, verticalSpacing: 12) { + GridRow { + card("Presses", report.total.formatted(), "keyboard", rangeDetail, + ClickyFormat.change(Double(report.total), report.previousTotal.map(Double.init), against: previousName)) + card("Daily average", report.dailyAverage.formatted(.number.precision(.fractionLength(0))), "chart.bar", + "Per recorded day", ClickyFormat.change(report.dailyAverage, previousAverage, against: previousName)) + card("Active days", "\(report.activeDays) of \(report.coveredDays)", "calendar", "Days with at least one press", nil) + } + GridRow { + card("Busiest day", report.records.busiestDay.map { $0.formatted(day) } ?? ClickyFormat.missing, "flame", + report.records.busiestDay == nil ? "No presses yet" : "\(Int(report.records.busiestDayValue).formatted()) presses", nil) + card("Busiest hour", report.busiestHour.map { $0.formatted(.dateTime.month(.abbreviated).day().hour()) } ?? ClickyFormat.missing, + "clock", report.busiestHour == nil ? "Hourly counts cover 1 year" : "\(report.busiestHourValue.formatted()) presses", nil) + card("Peak minute", report.peakMinuteValue.formatted(), "bolt", + report.peakMinute.map { "At \($0.formatted(.dateTime.month(.abbreviated).day().hour().minute()))" } ?? "Minute detail covers 30 days", nil) + } + GridRow { + card("Current streak", "\(report.records.currentStreak) \(report.records.currentStreak == 1 ? "day" : "days")", + "flame.fill", "Days in a row with typing", nil) + card("Longest streak", "\(report.records.longestStreak) \(report.records.longestStreak == 1 ? "day" : "days")", + "trophy", "In this range", nil) + card("Today", todayPresses.formatted(), "sun.max", "Presses since midnight", nil) + } + } + } + + private var rangeDetail: String { + switch range { + case ClickyRanges.today: return "Today so far" + case ClickyRanges.year: return "In the last year" + case ClickyRanges.all: return "In all kept data" + default: return "In the last \(range)" + } + } + + private var todayPresses: Int { + let start = Int(Calendar.current.startOfDay(for: now).timeIntervalSince1970) + return data.days[start]?.presses ?? 0 + } + + private func card(_ title: String, _ value: String, _ symbol: String, _ detail: String, _ change: String?) -> some View { NativeSettingsCard { Label(title, systemImage: symbol).font(.caption).foregroundStyle(.secondary) - Text(value).font(.system(size: 26, weight: .semibold, design: .rounded)).monospacedDigit() + Text(value).font(.system(size: 22, weight: .semibold, design: .rounded)).monospacedDigit() + .lineLimit(1).minimumScaleFactor(0.7) + Text(detail).font(.caption).foregroundStyle(.secondary) + Text(change ?? " ").font(.caption).foregroundStyle(.secondary).accessibilityHidden(change == nil) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) } private var activity: some View { - NativeSettingsCard { + let resolutions = ClickyRanges.resolutions(for: interval, now: now) + return NativeSettingsCard { HStack { Text("Activity timeline").font(.headline) Spacer() @@ -94,43 +226,49 @@ public struct ClickyAnalyticsView: View { Text("Releases").tag(true) }.labelsHidden().frame(width: 105, alignment: .trailing) Picker("Time resolution", selection: $resolution) { - ForEach(ClickyTimeResolution.allCases) { Text($0.rawValue).tag($0) } + ForEach(resolutions) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width: 110, alignment: .trailing) .accessibilityIdentifier("clicky.stats.resolution") } - NativeTimeSeriesChart(points: data.timeline(resolution, releases: releases), - interval: resolution.seconds, initialWindow: resolution.initialWindow, - end: now, valueLabel: releases ? "Releases" : "Presses") - .id("\(resolution.rawValue).\(example != nil)") - Text("\(resolution.rawValue)-by-\(resolution.rawValue.lowercased()) recorded counts. Wide views combine nearby bins to keep scrolling responsive.") - .font(.caption2).foregroundStyle(.secondary) + NativeTimeSeriesChart(points: report.timeline, interval: resolution.seconds, + initialWindow: resolution.initialWindow, end: now, valueLabel: releases ? "Releases" : "Presses", + rangeStart: interval.start, style: $chartStyle) + .id("\(resolution.rawValue).\(range).\(example != nil)") + Text(resolutions.count < ClickyTimeResolution.allCases.count + ? "Minute detail is kept for 30 days, so this range shows hours or days. Wide views combine nearby bars." + : "\(resolution.rawValue)-by-\(resolution.rawValue.lowercased()) recorded counts. Wide views combine nearby bars.") + .font(.caption).foregroundStyle(.secondary) } } private var keys: some View { VStack(spacing: 18) { - NativeSettingsCard("Physical key heat map", subtitle: "Aggregate presses by key position · US labels · no text or sequence retained") { + NativeSettingsCard("Physical key heat map", subtitle: "Aggregate presses by key position since detailed statistics began. US labels; no text or sequence retained.") { let peak = max(1, data.keys.values.max() ?? 0) + let total = max(1, data.keys.values.reduce(0, +)) VStack(spacing: 5) { ForEach(ClickyKeyLayout.rows.indices, id: \.self) { row in HStack(spacing: 4) { ForEach(ClickyKeyLayout.rows[row]) { key in let count = data.keys[key.code, default: 0] Button { selectedKey = key.code } label: { - Text(key.label).font(.system(size: 10, weight: .medium)) + Text(key.label).font(.system(size: 11, weight: .medium)) .frame(maxWidth: .infinity).frame(height: 31) .background(.mint.opacity(count == 0 ? 0.05 : 0.12 + 0.65 * pow(Double(count) / Double(peak), 0.5)), in: RoundedRectangle(cornerRadius: 5)) .overlay(RoundedRectangle(cornerRadius: 5).stroke(selectedKey == key.code ? .mint : .clear, lineWidth: 1)) - }.buttonStyle(.plain) + .contentShape(Rectangle()) + }.buttonStyle(.genHoverPlain()) .help("\(key.label): \(count.formatted()) presses") .accessibilityLabel("\(key.label), \(count) presses") + .accessibilityAddTraits(selectedKey == key.code ? .isSelected : []) } }.padding(.horizontal, row == 3 ? 8 : row == 4 ? 15 : 0) } } if let key = selectedKey { - Text("\(ClickyKeyLayout.label(key)) · \(data.keys[key, default: 0].formatted()) presses") + let count = data.keys[key, default: 0] + Text("\(ClickyKeyLayout.label(key)): \(count.formatted()) presses, \(ClickyFormat.percent(Double(count) / Double(total))) of all") .font(.caption).foregroundStyle(.mint) } else { Text("Select a key to inspect its total.").font(.caption).foregroundStyle(.secondary) } } @@ -144,23 +282,22 @@ public struct ClickyAnalyticsView: View { } private var rhythm: some View { - VStack(spacing: 18) { - NativeSettingsCard("When you type", subtitle: "Hour of day × day of week · local time · brighter means more recorded presses") { - NativeMatrixHeatmap(rows: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], - columns: (0..<24).map { String(format: "%02d", $0) }, cells: data.weekdayHeatmap()) - Text("Hover a cell for its count. Based on the retained year of hourly history.") + let order = ClickyWeekdays.order() + return VStack(spacing: 18) { + NativeSettingsCard("When you type", subtitle: "Hour of day by day of week in the selected range, local time. Brighter means more presses.") { + NativeMatrixHeatmap(rows: order.map { ClickyWeekdays.shortName($0) }, + columns: (0..<24).map { String(format: "%02d", $0) }, cells: report.heatmap) + Text("Hover a cell for its count. Hourly counts are kept for 1 year, so a longer range shows its last year here.") .font(.caption).foregroundStyle(.secondary) } NativeSettingsCard("Day by day") { - NativeTimeSeriesChart(points: data.timeline(.day), interval: 86400, - initialWindow: 7 * 86400, end: now, valueLabel: "Presses") - .id("days.\(example != nil)") + NativeTimeSeriesChart(points: report.dayTimeline, interval: 86400, + initialWindow: 7 * 86400, end: now, valueLabel: "Presses", rangeStart: interval.start, style: $chartStyle) + .id("days.\(range).\(example != nil)") } - NativeSettingsCard("Weekday distribution") { - let cells = data.weekdayHeatmap() - let labels = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] - NativeCategoryChart(values: labels.indices.map { row in - NativeCategoryValue(label: labels[row], value: cells.filter { $0.row == row }.reduce(0) { $0 + $1.value }) + NativeSettingsCard("Average day of the week", subtitle: "Presses per weekday, divided by how often that weekday occurs in the recorded range") { + NativeCategoryChart(values: order.map { weekday in + NativeCategoryValue(label: ClickyWeekdays.name(weekday), value: report.weekdayAverages[weekday].rounded()) }) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift index 6837d0bb06..42193916df 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift @@ -32,10 +32,68 @@ public enum ClickySynthesis { final class ClickyAudio { private let engine: AVAudioEngine private let format = AVAudioFormat(standardFormatWithSampleRate: ClickySynthesis.sampleRate, channels: 1)! - private var voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)] = [] + private let voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)] private var buffers: [String: AVAudioPCMBuffer] = [:] private var cursor = 0 private var idleStop: DispatchWorkItem? + /// The engine starts here, never on the main thread: the start ran inside the keyboard event tap and took 0.4 to + /// 1 s after each 3 s pause on 2026-10-23 (HangWatch stalls in the settings face), and a tap that waits delays + /// every key the user types. + private let startQueue = DispatchQueue(label: "genesiskit.clicky.audio-start", qos: .userInteractive) + private var starting = false + /// Whether the engine runs, as this class started and stopped it. `AVAudioEngine.isRunning` takes the engine's + /// lock, which a start on the start queue holds for its whole length: reading it there blocked the key tap again + /// (HangWatch, 2026-10-23 21:14). + private var running = false + /// The click that arrived while the engine starts; it plays once the engine runs (a newer one replaces it). + private var pendingClick: (() -> Void)? + private var stopAfterStart = false + /// A hardware change stopped the engine while it was starting; the start then finished on a stopped engine. + private var configChangedWhileStarting = false + /// The engine and its voices, used only on the start queue (or, rendering offline, on the caller's thread). + private final class EngineBox: @unchecked Sendable { + let engine: AVAudioEngine + let voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)] + let format: AVAudioFormat + private var wired = false + /// The output device the next start plays on; nil keeps the engine's own choice (the system output). + var device: AudioDeviceID? + + init(engine: AVAudioEngine, voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)], format: AVAudioFormat) { + self.engine = engine + self.voices = voices + self.format = format + } + + /// Wires the voices once, then starts. The first `mainMixerNode` read creates the output unit, which waits for + /// CoreAudio: 0.5 s on the main thread when the settings face launched (HangWatch, 2026-10-23 21:30). + func start() throws { + if let device, !AudioOutputs.pin(engine, to: device) { + PerfLog.mark("clicky.audio.output \(device) refused") + } + if !wired { + for (player, rate) in voices { + engine.attach(player) + engine.attach(rate) + engine.connect(player, to: rate, format: format) + engine.connect(rate, to: engine.mainMixerNode, format: format) + } + wired = true + } + try engine.start() + } + } + private let box: EngineBox + private var outputDevice: AudioDeviceID? + + /// Plays on `device` from the next click on. A change stops the engine, so the next click starts it on the new device. + func setOutput(_ device: AudioDeviceID?) { + guard device != outputDevice else { return } + outputDevice = device + let box = box + startQueue.async { box.device = device } + stopVoices() + } private struct PackBank { let reference: ClickyPackReference let manifestHash: String @@ -110,15 +168,9 @@ final class ClickyAudio { init(engine: AVAudioEngine = AVAudioEngine()) { self.engine = engine - for _ in 0..<12 { - let player = AVAudioPlayerNode() - let rate = AVAudioUnitVarispeed() - engine.attach(player) - engine.attach(rate) - engine.connect(player, to: rate, format: format) - engine.connect(rate, to: engine.mainMixerNode, format: format) - voices.append((player, rate)) - } + let voices = (0..<12).map { _ in (AVAudioPlayerNode(), AVAudioUnitVarispeed()) } + self.voices = voices + box = EngineBox(engine: engine, voices: voices, format: format) for profile in ClickySwitch.allCases { for release in [false, true] { for variant in 0..<3 { @@ -132,7 +184,13 @@ final class ClickyAudio { } } } - engine.prepare() + // A hardware change stops the engine without telling `running`; the next click must start it again. + NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + if self.starting { self.configChangedWhileStarting = true } else { self.running = false } + } + } } func play(profile: ClickySwitch, release: Bool, preferences: ClickyPreferences, pan: Float) throws { @@ -144,9 +202,63 @@ final class ClickyAudio { private func schedule(_ buffer: AVAudioPCMBuffer, preferences: ClickyPreferences, pan: Float, gain: Float, pitchVariation: Float) throws { guard preferences.volume > 0 else { return } idleStop?.cancel() - if !engine.isRunning { - try PerfLog.span("clicky.audio.start") { try engine.start() } + // Offline rendering (tests) starts at once: no hardware to wake. + if !running, !starting, engine.isInManualRenderingMode { + try box.start() + running = true + } + guard running else { + pendingClick = { [weak self] in + try? self?.play(buffer, preferences: preferences, pan: pan, gain: gain, pitchVariation: pitchVariation) + } + startEngine() + return } + try play(buffer, preferences: preferences, pan: pan, gain: gain, pitchVariation: pitchVariation) + } + + private func startEngine() { + // A click after a stop request cancels the stop, also while a start is already in flight. + stopAfterStart = false + guard !starting else { return } + starting = true + let box = box + startQueue.async { + let begun = Date() + var failure: Error? + do { try box.start() } catch { failure = error } + let ms = Date().timeIntervalSince(begun) * 1000 + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.starting = false + self.running = failure == nil + PerfLog.mark(String(format: "clicky.audio.start %.1fms off-main", ms) + + (failure.map { " failed: \($0.localizedDescription)" } ?? "")) + if self.configChangedWhileStarting { + // The engine stopped during the start: start it again before the waiting click plays. + self.configChangedWhileStarting = false + if !self.stopAfterStart { + self.running = false + self.startEngine() + return + } + } + if self.stopAfterStart { + self.pendingClick = nil + self.stopVoices() + return + } + let click = self.pendingClick + self.pendingClick = nil + if failure == nil { click?() } + } + } + } + } + + private func play(_ buffer: AVAudioPCMBuffer, preferences: ClickyPreferences, pan: Float, gain: Float, + pitchVariation: Float) throws { let voice = voices[cursor] cursor = (cursor + 1) % voices.count voice.0.volume = min(1, max(0, Float(preferences.volume) * gain)) @@ -169,6 +281,14 @@ final class ClickyAudio { private func stopVoices() { idleStop?.cancel() idleStop = nil + // A start in flight finishes first; the engine stops right after it. + if starting { + stopAfterStart = true + return + } + // Never started: the voices are not wired yet, and a player outside an engine raises on stop. + guard running else { return } + running = false for voice in voices { voice.0.stop() } engine.stop() } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyDelayPrompt.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyDelayPrompt.swift new file mode 100644 index 0000000000..7e1ac8fe3c --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyDelayPrompt.swift @@ -0,0 +1,104 @@ +import AppKit +import SwiftUI + +/// The prompt Clicky shows when its output is late (an AirPlay HomePod is about 2 s late): a small panel at the top +/// right of the screen under the pointer. It never takes the keyboard (a non-activating panel ordered front without +/// becoming key), and it closes by itself after `holdSeconds`. +@MainActor +final class ClickyDelayPrompt { + static let shared = ClickyDelayPrompt() + static let holdSeconds: TimeInterval = 45 + + private var panel: NSPanel? + private var generation = 0 + + func show(late: AudioOutputDevice, suggestion: AudioOutputDevice, reduceMotion: Bool, + onSwitch: @escaping () -> Void, onKeep: @escaping () -> Void) { + generation += 1 + let shown = generation + let view = ClickyDelayPromptView( + late: late, suggestion: suggestion, + onSwitch: { [weak self] in onSwitch(); self?.close(shown) }, + onKeep: { [weak self] in onKeep(); self?.close(shown) }, + onClose: { [weak self] in self?.close(shown) }) + let host = NSHostingView(rootView: view) + let panel = self.panel ?? Self.makePanel() + self.panel = panel + panel.contentView = host + let size = host.fittingSize + let pointer = NSEvent.mouseLocation + let screen = (NSScreen.screens.first { $0.frame.contains(pointer) } ?? NSScreen.main)?.visibleFrame + ?? CGRect(x: 0, y: 0, width: 1440, height: 900) + panel.setFrame(CGRect(x: screen.maxX - size.width - 16, y: screen.maxY - size.height - 12, + width: size.width, height: size.height), display: true) + panel.alphaValue = reduceMotion ? 1 : 0 + panel.orderFrontRegardless() + if !reduceMotion { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + panel.animator().alphaValue = 1 + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + Self.holdSeconds) { [weak self] in + MainActor.assumeIsolated { self?.close(shown) } + } + } + + private func close(_ shown: Int) { + guard shown == generation, let panel else { return } + panel.orderOut(nil) + panel.contentView = nil + } + + private static func makePanel() -> NSPanel { + let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, + defer: true) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.becomesKeyOnlyIfNeeded = true + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.collectionBehavior = [.transient, .ignoresCycle, .fullScreenAuxiliary, .canJoinAllSpaces] + return panel + } +} + +struct ClickyDelayPromptView: View { + let late: AudioOutputDevice + let suggestion: AudioOutputDevice + let onSwitch: () -> Void + let onKeep: () -> Void + let onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "speaker.wave.2.bubble.left.fill").foregroundStyle(.orange) + Text("Clicky sounds \(late.delayText) on \(late.name)") + .font(.system(size: 13, weight: .semibold)) + Spacer(minLength: 8) + IconButton(systemName: "xmark", tooltip: "Close; Clicky asks again after the next restart", action: onClose) + } + Text("Every key sound reaches you \(late.delayText). \(suggestion.name) is \(suggestion.delayText). Play Clicky there while the system plays on \(late.name)?") + .font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button("Keep \(late.name)", action: onKeep) + .accessibilityIdentifier("clicky.delay.keep") + Button("Play there", action: onSwitch) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("clicky.delay.switch") + } + .controlSize(.regular) + } + .padding(14) + .frame(width: 360) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.primary.opacity(0.1))) + .accessibilityElement(children: .contain) + .accessibilityLabel("Clicky output is late") + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift index fee7be179a..5012ce2168 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift @@ -54,6 +54,12 @@ public final class ClickyModel: ObservableObject { private var syncingAppearance = false private let log = Logger(subsystem: "dev.genesis.tools", category: "Clicky") private var audio: ClickyAudio? + /// The connected outputs and where Clicky plays, refreshed when the system output or the device list changes. + @Published public private(set) var outputs: [AudioOutputDevice] = [] + @Published public private(set) var output: ClickyOutputResolution? + private var outputMonitor: AudioOutputMonitor? + /// Late outputs already offered an alternative in this run: the prompt shows once per output and launch. + private var delayPrompted: Set<String> = [] private var previewGeneration = 0 private let inputMonitor: any ClickyInputMonitoring private var observers: [NSObjectProtocol] = [] @@ -111,7 +117,7 @@ public final class ClickyModel: ObservableObject { } soundLibrary.install = { [weak self] reference, pack in guard let self else { throw ClickyPackError.invalid("Clicky is no longer available.") } - if self.audio == nil { self.audio = ClickyAudio() } + if self.audio == nil { self.makeAudio() } try self.audio?.install(reference: reference, pack: pack) self.previewGeneration &+= 1 self.inputState.clear() @@ -122,6 +128,8 @@ public final class ClickyModel: ObservableObject { } soundLibrary.restore(preferences.selectedPack) guard !previewOnly && observeSystemEvents else { return } + outputMonitor = AudioOutputMonitor { [weak self] in self?.refreshOutput() } + refreshOutput() let workspace = NSWorkspace.shared.notificationCenter for name in [ NSWorkspace.didActivateApplicationNotification, NSWorkspace.willSleepNotification, @@ -156,6 +164,23 @@ public final class ClickyModel: ObservableObject { } public var hasInputPermission: Bool { inputMonitor.hasPermission } + + /// Whether key counts are recorded now, and if not, the first reason to fix. + public var recordingStatus: ClickyRecordingStatus { + if !preferences.collectStats { return .statisticsOff } + if enabled { return .recording } + return inputMonitor.hasPermission ? .clickyOff : .inputMonitoringMissing + } + + /// The fix the statistics pages offer for `recordingStatus`: turn statistics on, or turn Clicky on (which asks + /// for Input Monitoring through the permission dialog when it is missing). + public func fixRecording() { + switch recordingStatus { + case .recording: break + case .statisticsOff: preferences.collectStats = true + case .clickyOff, .inputMonitoringMissing: activate() + } + } public var isPaused: Bool { systemSleeping || preferences.isQuiet(at: Date()) || excluded || (sleepingUntil.map { $0 > Date() } ?? false) } @@ -203,7 +228,7 @@ public final class ClickyModel: ObservableObject { return case .started: break } - if audio == nil { audio = ClickyAudio() } + if audio == nil { makeAudio() } enabled = true if !restoring { defaults.set(true, forKey: Self.enabledKey) } if preferences.collectStats && statisticsLoadError == nil { @@ -212,6 +237,7 @@ public final class ClickyModel: ObservableObject { pendingStats = true } refreshContext() + offerFasterOutputIfLate() if !restoring { notify("Clicky enabled", body: "Your selected switch is \(selectedSoundName).") } log.notice("Input feedback activated\(restoring ? " (restored at launch)" : ""); no input content is retained") } @@ -261,6 +287,8 @@ public final class ClickyModel: ObservableObject { } public func shutdown() { + outputMonitor?.stop() + outputMonitor = nil soundLibrary.stop() deactivate(remember: false) boundaryTimer?.invalidate() @@ -446,9 +474,81 @@ public final class ClickyModel: ObservableObject { } } if let data = try? JSONEncoder().encode(preferences) { defaults.set(data, forKey: "clicky.preferences.v1") } + if previous.outputDeviceUID != preferences.outputDeviceUID || previous.outputRules != preferences.outputRules + || previous.delayWarning != preferences.delayWarning + || previous.delayWarningMilliseconds != preferences.delayWarningMilliseconds { + refreshOutput() + } refreshContext() } + // MARK: - Output + + private func makeAudio() { + audio = ClickyAudio() + audio?.setOutput(output?.effective?.id) + } + + /// Reads the outputs again, decides where Clicky plays, and offers a faster output when that one is late. + public func refreshOutput() { + outputs = AudioOutputs.outputs() + let resolved = ClickyOutputRouting.resolve(preferences: preferences, outputs: outputs, + systemID: AudioOutputs.systemDefaultID()) + if resolved != output, let effective = resolved.effective { + log.notice("Output \(effective.name, privacy: .public), \(effective.delayText, privacy: .public): \(resolved.reason, privacy: .public)") + } + output = resolved + audio?.setOutput(resolved.effective?.id) + offerFasterOutputIfLate() + } + + private var delayLimit: TimeInterval { Double(preferences.delayWarningMilliseconds) / 1000 } + + private func offerFasterOutputIfLate() { + guard enabled, !previewOnly, preferences.delayWarning, let late = output?.effective, late.delay > delayLimit, + !preferences.delayAcceptedUIDs.contains(late.uid), !delayPrompted.contains(late.uid), + let faster = AudioOutputs.suggestion(instead: late, outputs: outputs, limit: delayLimit) + else { return } + delayPrompted.insert(late.uid) + ClickyDelayPrompt.shared.show( + late: late, suggestion: faster, reduceMotion: appearance.reduceMotion, + onSwitch: { [weak self] in self?.playLateOutputOn(faster, instead: late) }, + onKeep: { [weak self] in self?.preferences.delayAcceptedUIDs.append(late.uid) }) + } + + /// The prompt's answer: a hand-picked late output is replaced; a late system output gets a rule. + private func playLateOutputOn(_ faster: AudioOutputDevice, instead late: AudioOutputDevice) { + if preferences.outputDeviceUID == late.uid { + pickOutput(faster) + } else { + setRule(when: late, playOn: faster) + } + } + + /// nil follows the system output. + public func pickOutput(_ device: AudioOutputDevice?) { + preferences.outputDeviceUID = device?.uid + preferences.outputDeviceName = device?.name + } + + public func setRule(when system: AudioOutputDevice, playOn target: AudioOutputDevice) { + var rules = preferences.outputRules.filter { $0.whenUID != system.uid } + rules.append(ClickyOutputRule(whenUID: system.uid, whenName: system.name, playOnUID: target.uid, + playOnName: target.name)) + preferences.outputRules = rules + } + + public func removeRule(_ id: ClickyOutputRule.ID) { + preferences.outputRules.removeAll { $0.id == id } + } + + /// Late outputs the user kept are offered an alternative again. + public func askAgainAboutLateOutputs() { + preferences.delayAcceptedUIDs = [] + delayPrompted = [] + offerFasterOutputIfLate() + } + private func flushStatistics() { analytics.flush(statistics) guard statisticsLoadError == nil, pendingStats else { return } @@ -576,7 +676,7 @@ public final class ClickyModel: ObservableObject { } private func playSelected(keyCode: UInt16, release: Bool, preview: Bool, pan: Float) { - if audio == nil { audio = ClickyAudio() } + if audio == nil { makeAudio() } do { let imported = try audio?.playSelected(keyCode: keyCode, release: release, preview: preview, preferences: preferences, pan: pan) ?? false @@ -594,7 +694,7 @@ public final class ClickyModel: ObservableObject { } private func play(profile: ClickySwitch, release: Bool, pan: Float) { - if audio == nil { audio = ClickyAudio() } + if audio == nil { makeAudio() } do { try audio?.play(profile: profile, release: release, preferences: preferences, pan: pan) if preferences.visualizer { @@ -609,14 +709,9 @@ public final class ClickyModel: ObservableObject { private func notify(_ title: String, body: String) { guard preferences.notifications, !previewOnly else { return } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - UNUserNotificationCenter.current().add( - UNNotificationRequest( - identifier: "clicky.status", content: content, - trigger: nil) - ) { [weak self] error in + NotificationEngine.shared.post(EngineNotification( + source: "clicky", title: title, body: body, identifier: "clicky.status" + )) { [weak self] error in if let error { Task { @MainActor in self?.log.error("Notification failed: \(error.localizedDescription, privacy: .public)") diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutput.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutput.swift new file mode 100644 index 0000000000..09e949acc7 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutput.swift @@ -0,0 +1,58 @@ +import CoreAudio +import Foundation + +/// "When the system plays on `whenUID`, Clicky plays on `playOnUID`." The names are kept for the settings list while a +/// device is not connected. +public struct ClickyOutputRule: Codable, Hashable, Identifiable, Sendable { + public var id = UUID() + public var whenUID: String + public var whenName: String + public var playOnUID: String + public var playOnName: String + + public init(whenUID: String, whenName: String, playOnUID: String, playOnName: String) { + self.whenUID = whenUID + self.whenName = whenName + self.playOnUID = playOnUID + self.playOnName = playOnName + } +} + +/// Where Clicky plays and why. `device` nil means the system output. +public struct ClickyOutputResolution: Equatable, Sendable { + public let device: AudioOutputDevice? + public let system: AudioOutputDevice? + public let reason: String + + /// The output the sound really comes out of. + public var effective: AudioOutputDevice? { device ?? system } +} + +public enum ClickyOutputRouting { + /// Which device Clicky plays on. A device picked by hand wins; otherwise the first rule for the current system output; + /// otherwise the system output. A picked or ruled device that is not connected falls back to the system output. + public static func resolve(preferences: ClickyPreferences, outputs: [AudioOutputDevice], + systemID: AudioDeviceID?) -> ClickyOutputResolution { + let system = outputs.first { $0.id == systemID } + if let uid = preferences.outputDeviceUID { + if let picked = outputs.first(where: { $0.uid == uid }) { + return ClickyOutputResolution(device: picked, system: system, reason: "You picked \(picked.name).") + } + return ClickyOutputResolution( + device: nil, system: system, + reason: "\(preferences.outputDeviceName ?? "The picked output") is not connected, so Clicky plays on the system output.") + } + if let system, let rule = preferences.outputRules.first(where: { $0.whenUID == system.uid }) { + if let target = outputs.first(where: { $0.uid == rule.playOnUID }) { + return ClickyOutputResolution( + device: target, system: system, + reason: "A rule plays Clicky on \(target.name) while the system plays on \(system.name).") + } + return ClickyOutputResolution( + device: nil, system: system, + reason: "The rule for \(system.name) names \(rule.playOnName), which is not connected.") + } + return ClickyOutputResolution(device: nil, system: system, reason: "Clicky follows the system output.") + } + +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutputSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutputSettings.swift new file mode 100644 index 0000000000..d471ba595c --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutputSettings.swift @@ -0,0 +1,121 @@ +import SwiftUI + +/// Clicky's "Output" card: the device it plays on, the rules per system output, and the late-output prompt. +struct ClickyOutputCard: View { + @ObservedObject var model: ClickyModel + + var body: some View { + NativeSettingsCard("Output", subtitle: nowPlaying) { + NativeSettingsPicker( + "Play Clicky on", detail: model.output?.reason, identifier: "clicky.output.device", + options: deviceOptions, selection: deviceSelection, width: 260) + if model.preferences.outputDeviceUID == nil { + Divider() + rules + } + Divider() + NativeSettingsToggle( + "Suggest a faster output", + detail: "When Clicky's output is late by more than the limit below, a small prompt offers a faster one. It never takes the keyboard.", + identifier: "clicky.output.delayWarning", isOn: $model.preferences.delayWarning) + if model.preferences.delayWarning { + NativeSettingsSegmented( + "Late after", detail: "AirPlay speakers such as a HomePod are about 2 s late; built-in speakers about 30 ms.", + identifier: "clicky.output.delayLimit", + options: [100, 150, 300, 500].map { NativeSettingsOption($0, "\($0) ms") }, + selection: $model.preferences.delayWarningMilliseconds) + } + if !model.preferences.delayAcceptedUIDs.isEmpty { + NativeSettingsRow("Kept although late", detail: keptNames) { + Button("Ask again", action: model.askAgainAboutLateOutputs) + .accessibilityIdentifier("clicky.output.askAgain") + } + } + } + } + + private var nowPlaying: String? { + guard let effective = model.output?.effective else { return nil } + return "Now playing on \(effective.name), \(effective.delayText)." + } + + private var deviceOptions: [NativeSettingsOption<String>] { + var options = [NativeSettingsOption("", "Same as the system output", detail: nil, symbol: "speaker.wave.2")] + options += model.outputs.filter { !$0.isVirtual || $0.uid == model.preferences.outputDeviceUID }.map { + NativeSettingsOption($0.uid, "\($0.name) (\($0.delayText))") + } + if let uid = model.preferences.outputDeviceUID, !model.outputs.contains(where: { $0.uid == uid }) { + options.append(NativeSettingsOption(uid, "\(model.preferences.outputDeviceName ?? "Output") (not connected)")) + } + return options + } + + private var deviceSelection: Binding<String> { + Binding( + get: { model.preferences.outputDeviceUID ?? "" }, + set: { uid in + if uid.isEmpty { + model.pickOutput(nil) + } else if let device = model.outputs.first(where: { $0.uid == uid }) { + model.pickOutput(device) + } + }) + } + + private var keptNames: String { + model.preferences.delayAcceptedUIDs.map { uid in model.outputs.first { $0.uid == uid }?.name ?? "A disconnected output" } + .joined(separator: ", ") + } + + @ViewBuilder private var rules: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Rules").font(.system(size: 12, weight: .semibold)) + Text("While the system plays on one output, Clicky can play on another one.") + .font(.system(size: 11)).foregroundStyle(.secondary) + ForEach(model.preferences.outputRules) { rule in + ClickyOutputRuleRow(model: model, rule: rule) + } + if let system = model.output?.system, !model.preferences.outputRules.contains(where: { $0.whenUID == system.uid }) { + Button("Add a rule for \(system.name)") { + let target = AudioOutputs.suggestion(instead: system, outputs: model.outputs, limit: .infinity) + model.setRule(when: system, playOn: target ?? system) + } + .accessibilityIdentifier("clicky.output.addRule") + } + } + } +} + +private struct ClickyOutputRuleRow: View { + @ObservedObject var model: ClickyModel + let rule: ClickyOutputRule + + var body: some View { + HStack(spacing: 8) { + Text("When the system plays on \(rule.whenName), play on").font(.system(size: 12)) + Picker("Play on", selection: target) { + ForEach(model.outputs.filter { !$0.isVirtual }) { device in + Text("\(device.name) (\(device.delayText))").tag(device.uid) + } + if !model.outputs.contains(where: { $0.uid == rule.playOnUID }) { + Text("\(rule.playOnName) (not connected)").tag(rule.playOnUID) + } + } + .labelsHidden().fixedSize() + Spacer(minLength: 0) + IconButton(systemName: "trash", tooltip: "Remove this rule") { model.removeRule(rule.id) } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("clicky.output.rule.\(rule.whenUID)") + } + + private var target: Binding<String> { + Binding( + get: { rule.playOnUID }, + set: { uid in + guard let device = model.outputs.first(where: { $0.uid == uid }) else { return } + let system = AudioOutputDevice(id: 0, uid: rule.whenUID, name: rule.whenName, transport: 0, delay: 0) + model.setRule(when: system, playOn: device) + }) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift index 0e667c4523..3a417861f6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift @@ -16,14 +16,34 @@ public struct ClickyPerformanceBucket: Codable, Equatable, Sendable { public var activeSeconds = 0.0 public var bursts = 0 public var carriedBurst = false + /// Typing sessions that start in this bucket: text after a minute or more without text. Kept only in the hourly + /// roll-up; a minute's session is worked out from its neighbour when the report is built. + public var sessions = 0 public init() {} + private enum CodingKeys: String, CodingKey { + case presses, characters, corrections, activeSeconds, bursts, carriedBurst, sessions + } + + /// Every key is optional, so buckets saved before `sessions` existed still load. + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + presses = try values.decodeIfPresent(Int.self, forKey: .presses) ?? 0 + characters = try values.decodeIfPresent(Int.self, forKey: .characters) ?? 0 + corrections = try values.decodeIfPresent(Int.self, forKey: .corrections) ?? 0 + activeSeconds = try values.decodeIfPresent(Double.self, forKey: .activeSeconds) ?? 0 + bursts = try values.decodeIfPresent(Int.self, forKey: .bursts) ?? 0 + carriedBurst = try values.decodeIfPresent(Bool.self, forKey: .carriedBurst) ?? false + sessions = try values.decodeIfPresent(Int.self, forKey: .sessions) ?? 0 + } + mutating func add(_ other: Self) { presses += other.presses characters += other.characters corrections += other.corrections activeSeconds += other.activeSeconds bursts += other.bursts + sessions += other.sessions } var estimatedWords: Double { Double(characters) / 5 } var estimatedRetainedWords: Double { Double(max(0, characters - corrections)) / 5 } @@ -42,6 +62,31 @@ extension ClickyStatistics { public mutating func breakTypingBurst() { lastTypingAt = nil } + /// Minute detail is kept for this long; older minutes are rolled into `performanceHours`. + static let performanceMinuteDays = 30 + /// The hourly roll-up is kept for this long. + static let performanceHourDays = 730 + + /// Whether `minute` starts a typing session: it has text and the minute before it has none. + static func startsSession(_ minute: Int, in minutes: [Int: ClickyPerformanceBucket]) -> Bool { + (minutes[minute]?.characters ?? 0) > 0 && (minutes[minute - 1]?.characters ?? 0) == 0 + } + + /// Rolls the minutes older than `horizon` into hourly buckets, then drops them and hours past their retention. + mutating func rollUpPerformance(olderThan horizon: Int) { + let old = performanceMinutes.filter { $0.key < horizon } + guard !old.isEmpty else { return } + for (minute, source) in old { + var bucket = source + bucket.carriedBurst = false + bucket.sessions = Self.startsSession(minute, in: performanceMinutes) ? 1 : 0 + performanceHours[Int(floor(Double(minute) / 60)), default: ClickyPerformanceBucket()].add(bucket) + } + performanceMinutes = performanceMinutes.filter { $0.key >= horizon } + let oldestHour = horizon / 60 - Self.performanceHourDays * 24 + performanceHours = performanceHours.filter { $0.key >= oldestHour } + } + mutating func recordPerformance(keyCode: UInt16, shortcut: Bool, at date: Date) { let minute = Int(floor(date.timeIntervalSince1970 / 60)) performanceMinutes[minute, default: ClickyPerformanceBucket()].presses += 1 @@ -100,7 +145,7 @@ struct ClickyPerformanceFilter: Equatable, Sendable { } enum ClickyPerformanceGrouping: Int, CaseIterable, Identifiable, Sendable { - case minute = 60, fiveMinutes = 300, fifteenMinutes = 900, hour = 3600, day = 86400 + case minute = 60, fiveMinutes = 300, fifteenMinutes = 900, hour = 3600, day = 86400, week = 604800 var id: Self { self } var label: String { switch self { @@ -109,6 +154,7 @@ enum ClickyPerformanceGrouping: Int, CaseIterable, Identifiable, Sendable { case .fifteenMinutes: return "15 minutes" case .hour: return "Hour" case .day: return "Day" + case .week: return "Week" } } } @@ -124,9 +170,14 @@ struct ClickyPerformanceReport: Sendable { var timeline: [ClickyPerformancePoint] = [] var hours = Array(repeating: ClickyPerformanceBucket(), count: 24) var weekdays = Array(repeating: ClickyPerformanceBucket(), count: 7) + /// Totals per local calendar day, for active days, records and per-day averages. + var days: [Date: ClickyPerformanceBucket] = [:] init() {} + /// Days with any timed typing. + var activeDays: Int { days.values.filter { $0.activeSeconds > 0 || $0.characters > 0 }.count } + /// `beforeBuild` runs on the worker before the report is built; tests use it to cancel while the worker runs. static func prepare(statistics: ClickyStatistics, filter: ClickyPerformanceFilter, grouping: ClickyPerformanceGrouping, calendar: Calendar = .current, @@ -149,8 +200,14 @@ struct ClickyPerformanceReport: Sendable { init(statistics: ClickyStatistics, filter: ClickyPerformanceFilter, grouping: ClickyPerformanceGrouping, calendar: Calendar = .current) { var groups: [Date: ClickyPerformanceBucket] = [:] - for (minute, source) in statistics.performanceMinutes { - let date = Date(timeIntervalSince1970: Double(minute) * 60) + let minuteBuckets = statistics.performanceMinutes.map { minute, source -> (Date, ClickyPerformanceBucket) in + var bucket = source + bucket.sessions = ClickyStatistics.startsSession(minute, in: statistics.performanceMinutes) ? 1 : 0 + return (Date(timeIntervalSince1970: Double(minute) * 60), bucket) + } + // Rolled-up hours hold only minutes that are no longer kept, so the two never count the same press twice. + let hourBuckets = statistics.performanceHours.map { (Date(timeIntervalSince1970: Double($0.key) * 3600), $0.value) } + for (date, source) in minuteBuckets + hourBuckets { guard filter.includes(date, calendar: calendar) else { continue } var bucket = source if bucket.carriedBurst && !filter.includes(date.addingTimeInterval(-60), calendar: calendar) { @@ -159,7 +216,10 @@ struct ClickyPerformanceReport: Sendable { total.add(bucket) hours[calendar.component(.hour, from: date)].add(bucket) weekdays[(calendar.component(.weekday, from: date) + 5) % 7].add(bucket) - let key = grouping == .day ? calendar.startOfDay(for: date) + let day = calendar.startOfDay(for: date) + days[day, default: ClickyPerformanceBucket()].add(bucket) + let key = grouping == .week ? (calendar.dateInterval(of: .weekOfYear, for: date)?.start ?? day) + : grouping == .day ? day : Date(timeIntervalSince1970: floor(date.timeIntervalSince1970 / Double(grouping.rawValue)) * Double(grouping.rawValue)) groups[key, default: ClickyPerformanceBucket()].add(bucket) } @@ -171,12 +231,25 @@ struct ClickyDictationEstimate { let typingSeconds: Double let speakingSeconds: Double let setupSeconds: Double - var dictationSeconds: Double { speakingSeconds + setupSeconds } + let reviewSeconds: Double + let retainedWords: Double + var dictationSeconds: Double { speakingSeconds + setupSeconds + reviewSeconds } var differenceSeconds: Double { typingSeconds - dictationSeconds } - init(totals: ClickyPerformanceBucket, wordsPerMinute: Int, setupPerBurst: Int) { - typingSeconds = totals.activeSeconds - speakingSeconds = totals.estimatedRetainedWords * 60 / Double(max(1, wordsPerMinute)) - setupSeconds = Double(totals.bursts * max(0, setupPerBurst)) + /// One dictation covers a whole typing session, so its setup counts once per session, not per 5-second burst: + /// with setup per burst, 8,042 bursts in a week cost 6.7 h of setup and the estimate said dictation saved nothing + /// (Martin's data, 2026-10-25: 718 sessions in the same week). + /// + /// `dictatedShare` (0...1) is the part of the typing the user would dictate at all: code, commands and passwords + /// stay typed, and Clicky cannot tell them apart without reading text. It scales both sides and the sessions + /// alike. `reviewShare` adds reading and fixing the transcript, as a share of the speaking time. + init(totals: ClickyPerformanceBucket, wordsPerMinute: Int, setupPerSession: Int, + dictatedShare: Double = 1, reviewShare: Double = 0) { + let share = min(1, max(0, dictatedShare)) + retainedWords = totals.estimatedRetainedWords * share + typingSeconds = totals.activeSeconds * share + speakingSeconds = retainedWords * 60 / Double(max(1, wordsPerMinute)) + setupSeconds = Double(totals.sessions * max(0, setupPerSession)) * share + reviewSeconds = speakingSeconds * max(0, reviewShare) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift index f0c94e85f9..6602dca5f7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift @@ -4,46 +4,96 @@ import SwiftUI @MainActor public struct ClickyPerformanceView: View { @ObservedObject private var store: ClickyAnalyticsStore - @State private var range = "7 days" + private let status: ClickyRecordingStatus + private let fixRecording: (() -> Void)? + @AppStorage private var storedRange: String @State private var start = Calendar.current.startOfDay(for: Date()) @State private var end = Date() - @State private var grouping: ClickyPerformanceGrouping = .fifteenMinutes + @State private var grouping: ClickyPerformanceGrouping @State private var weekdays = Set(0..<7) @State private var fromHour = 0 @State private var untilHour = 24 - @State private var metric = "Presses" - @State private var comparison = "Time of day" + @AppStorage private var storedMetric: String + @AppStorage private var comparison: String + @AppStorage private var chartStyle: NativeTimeSeriesChart.Style + @AppStorage private var showFun: Bool @State private var example: ClickyStatistics? @State private var exampleTime: Date? @State private var preparedReport = ClickyPerformanceReport() + @State private var previousReport: ClickyPerformanceReport? + @State private var loaded = false @State private var preparing = false + @State private var refresh = 0 + @State private var refreshPending = false + @State private var lastUserQuery: Query? @AppStorage private var spokenWPM: Int @AppStorage private var setupSeconds: Int - private let weekdayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + @AppStorage private var dictatedPercent: Int + @AppStorage private var reviewPercent: Int - public init(store: ClickyAnalyticsStore, defaults: UserDefaults) { + /// - Parameters: + /// - status: whether Clicky records right now; anything but `.recording` shows why under the title. + /// - fixRecording: the action of the button beside that reason. + public init(store: ClickyAnalyticsStore, defaults: UserDefaults, status: ClickyRecordingStatus = .recording, + fixRecording: (() -> Void)? = nil) { self.store = store + self.status = status + self.fixRecording = fixRecording + let range = ClickyRanges.known(defaults.string(forKey: "clicky.performance.range") ?? ClickyRanges.week, + allowed: Self.ranges) + _storedRange = AppStorage(wrappedValue: ClickyRanges.week, "clicky.performance.range", store: defaults) + _grouping = State(initialValue: Self.grouping(for: range)) + _storedMetric = AppStorage(wrappedValue: Metric.presses.rawValue, "clicky.performance.metric", store: defaults) + _comparison = AppStorage(wrappedValue: "Time of day", "clicky.performance.comparison", store: defaults) + _chartStyle = AppStorage(wrappedValue: .area, "clicky.performance.chartStyle", store: defaults) + _showFun = AppStorage(wrappedValue: true, "clicky.performance.showFun", store: defaults) _spokenWPM = AppStorage(wrappedValue: 140, "clicky.performance.spokenWPM", store: defaults) _setupSeconds = AppStorage(wrappedValue: 3, "clicky.performance.setupSeconds", store: defaults) + _dictatedPercent = AppStorage(wrappedValue: 70, "clicky.performance.dictatedPercent", store: defaults) + _reviewPercent = AppStorage(wrappedValue: 15, "clicky.performance.reviewPercent", store: defaults) } + + enum Metric: String, CaseIterable { + case presses = "Presses", words = "Words typed", time = "Typing time", pace = "Pace" + case corrections = "Correction share", sessions = "Typing sessions" + } + + private var range: String { ClickyRanges.known(storedRange, allowed: Self.ranges) } + private var metric: Metric { Metric(rawValue: storedMetric) ?? .presses } private var data: ClickyStatistics { example ?? store.snapshot } private var now: Date { exampleTime ?? Date() } + private var interval: DateInterval { + ClickyRanges.interval(range, now: now, earliest: earliest, customStart: start, customEnd: end) + } private var filter: ClickyPerformanceFilter { - let calendar = Calendar.current - let first: Date - switch range { - case "Today": first = calendar.startOfDay(for: now) - case "30 days": first = calendar.date(byAdding: .day, value: -29, to: calendar.startOfDay(for: now)) ?? now - case "Custom": first = calendar.startOfDay(for: start) - default: first = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: now)) ?? now - } - let last = range == "Custom" - ? calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: end)) ?? now : now - return ClickyPerformanceFilter(start: first, end: last, weekdays: weekdays, fromHour: fromHour, untilHour: untilHour) + ClickyPerformanceFilter(start: interval.start, end: interval.end, weekdays: weekdays, fromHour: fromHour, + untilHour: untilHour) + } + private var previousFilter: ClickyPerformanceFilter? { + guard let previous = ClickyRanges.previous(interval, range: range), earliest < previous.end else { return nil } + var shifted = filter + shifted.start = previous.start + shifted.end = previous.end + return shifted + } + private var previousName: String { ClickyRanges.previousName(interval, range: range) } + private var filtered: Bool { weekdays.count < 7 || fromHour != 0 || untilHour != 24 } + + /// The oldest timed activity: the first hourly roll-up, else the first kept minute. + private var earliest: Date { + let hour = data.performanceHours.keys.min().map { Date(timeIntervalSince1970: Double($0) * 3600) } + let minute = data.performanceMinutes.keys.min().map { Date(timeIntervalSince1970: Double($0) * 60) } + return [hour, minute, data.performanceStartedAt].compactMap { $0 }.min() ?? now + } + + nonisolated static let ranges = ClickyRanges.presets + [ClickyRanges.custom] + + /// The timeline grouping a range opens with: about 50 to 400 bars, never one bar per minute over a year. + nonisolated static func grouping(for range: String) -> ClickyPerformanceGrouping { + ClickyRanges.grouping(for: range) } private struct Query: Equatable { - let revision: UInt64 let range: String let start: Date let end: Date @@ -52,224 +102,506 @@ public struct ClickyPerformanceView: View { let fromHour: Int let untilHour: Int let exampleTime: Date? + var refresh: Int } private var query: Query { - Query(revision: example == nil ? store.revision : 0, range: range, start: start, end: end, - grouping: grouping, weekdays: weekdays, fromHour: fromHour, untilHour: untilHour, exampleTime: exampleTime) + Query(range: range, start: start, end: end, grouping: grouping, weekdays: weekdays, fromHour: fromHour, + untilHour: untilHour, exampleTime: exampleTime, refresh: refresh) + } + + /// A range that ends now gets new data while the user types; a closed one never changes. + private var live: Bool { example == nil && interval.end >= Date().addingTimeInterval(-60) } + + /// New key presses rebuild the report at most every 5 seconds, not on every published second. + private func scheduleRefresh() { + guard live, !refreshPending else { return } + refreshPending = true + Task { @MainActor in + try? await Task.sleep(for: .seconds(5)) + refreshPending = false + refresh &+= 1 + } } public var body: some View { let report = preparedReport VStack(alignment: .leading, spacing: 18) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Find your typing rhythm").font(.system(size: 21, weight: .semibold, design: .rounded)) - Text(example == nil ? "Local aggregates · pauses over 5 seconds stop the clock" : "Sample performance · never saved") - .font(.caption).foregroundStyle(example == nil ? Color.secondary : .orange) - } - Spacer() - if preparing { ProgressView().controlSize(.small).help("Preparing the selected time range") } - Button(example == nil ? "Explore sample data" : "Back to my data") { - if example == nil { - let reference = Date() - exampleTime = reference - example = ClickyStatistics.example(now: reference) - } else { example = nil; exampleTime = nil } - }.buttonStyle(.bordered).accessibilityIdentifier("clicky.performance.example") - } - filters - if range == "Custom" && end < start { - Label("Choose an end date on or after the start date.", systemImage: "calendar.badge.exclamationmark") - .foregroundStyle(.orange) + header + if example == nil, let message = status.message { + ClickyRecordingBanner(message: message, actionTitle: status.actionTitle, action: fixRecording) } - if data.performanceStartedAt == nil { + if data.performanceStartedAt == nil && example == nil { NativeSettingsCard { - Label("Timing starts with your next recorded key press.", systemImage: "stopwatch") - Text("Earlier totals do not contain pauses or active seconds. Clicky keeps those totals without inventing performance history.") - .font(.caption).foregroundStyle(.secondary) + EmptyState(symbol: "stopwatch", + text: status == .recording ? "Timing starts with your next recorded key press." + : "No timing history yet.", + detail: "Earlier totals do not contain pauses or typing time, so Clicky keeps them without inventing performance history.", + actionTitle: "Explore Sample Data", action: showExample) } - } - HStack(spacing: 12) { - number("Presses", report.total.presses.formatted(), "keyboard", "All recorded key-down events") - number("Estimated words", report.total.estimatedWords.formatted(.number.precision(.fractionLength(1))), "text.word.spacing", "5 text-like presses per word") - number("Active typing", duration(report.total.activeSeconds), "stopwatch", "Long pauses excluded") - } - timeline(report) - dictation(report.total) - HStack(spacing: 12) { - number("Estimated pace", report.total.wordsPerMinute.map { String(format: "%.1f WPM", $0) } ?? "—", - "speedometer", "Weighted by active time") - number("Correction share", report.total.correctionShare.formatted(.percent.precision(.fractionLength(1))), - "delete.left", "Delete presses / text + delete") - number("Average burst", report.total.bursts > 0 ? duration(report.total.activeSeconds / Double(report.total.bursts)) : "—", - "waveform.path", "Active seconds / started bursts") - } - pace(report) - playful(report.total) - NativeSettingsCard("How the estimates work") { - Text("Only intervals between text-like key presses of 5 seconds or less count as active typing. Longer gaps add no time; isolated presses have no measurable duration. Key releases, modifiers and Command/Control shortcuts add no words. Navigation and shortcuts end a typing burst.") - Text("Words are a keystroke convention, not captured text. Code, pasted text, autocomplete, non-Latin input and editing can change the result. Correction share counts delete keys; it is not an error-rate score. No text, key sequence or application history is retained.") - Text("Performance retains 30 days of minute aggregates. Filters use your current local calendar; rates divide combined words by combined active seconds, rather than averaging bucket speeds. Date and hour filters have minute precision.") - if let since = data.performanceStartedAt { - Text("Timing history since \(since.formatted(date: .abbreviated, time: .shortened))") + } else { + filters + if range == ClickyRanges.custom && end < start { + Label("Choose an end date on or after the start date.", systemImage: "calendar.badge.exclamationmark") + .foregroundStyle(.orange) } - }.font(.caption).foregroundStyle(.secondary) + if loaded && report.total.presses == 0 { emptyResult } + numbers(report) + .redacted(reason: loaded ? [] : .placeholder) + .opacity(preparing ? 0.55 : 1) + timeline(report) + dictation(report) + pace(report) + records(report) + if showFun { playful(report.total) } + NativeSettingsCard("How the estimates work") { + Text("Only intervals between text-like key presses of 5 seconds or less count as typing time. Longer gaps add no time; isolated presses have no measurable duration. Key releases, modifiers and Command or Control shortcuts add no words. Navigation and shortcuts end a typing burst.") + Text("Words are a keystroke convention, not captured text: 5 text-like presses make one word typed. Words kept subtract the delete presses. Code, pasted text, autocomplete, non-Latin input and editing can change the result. Correction share counts delete keys; it is not an error-rate score. No text, key sequence or application history is retained.") + Text("A typing session starts at a clock minute with text after a clock minute without text, so the session count is approximate. Filters use your current local calendar; rates divide combined words by combined typing time, rather than averaging bucket speeds. Ranges past 30 days count whole hours.") + Text(ClickyRetention.text) + if let since = data.performanceStartedAt { + Text("Timing history since \(since.formatted(date: .abbreviated, time: .shortened)).") + } + Toggle("Show the just-for-fun cards", isOn: $showFun).toggleStyle(.checkbox) + .accessibilityIdentifier("clicky.performance.showFun") + }.font(.caption).foregroundStyle(.secondary) + } } .onAppear { store.attach() } .onDisappear { store.detach() } + .onChange(of: store.revision) { _, _ in scheduleRefresh() } + .onChange(of: start) { _, _ in if range == ClickyRanges.custom { regroupCustom() } } + .onChange(of: end) { _, _ in if range == ClickyRanges.custom { regroupCustom() } } .task(id: query) { - preparing = true - let result = await ClickyPerformanceReport.prepare(statistics: data, filter: filter, grouping: grouping) + var userQuery = query + userQuery.refresh = 0 + // A refresh while typing updates in place; only a change the user made shows progress, and only when it + // takes longer than 300 ms. + let quiet = userQuery == lastUserQuery + lastUserQuery = userQuery + let spinner = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + if !Task.isCancelled && !quiet { preparing = true } + } + defer { spinner.cancel() } + let statistics = data + let previousFilter = previousFilter + async let current = ClickyPerformanceReport.prepare(statistics: statistics, filter: filter, grouping: grouping) + async let earlier: ClickyPerformanceReport? = previousFilter == nil ? nil + : ClickyPerformanceReport.prepare(statistics: statistics, filter: previousFilter!, grouping: .day) + let (result, previous) = await (current, earlier) guard !Task.isCancelled else { return } - if let result { preparedReport = result } + if let result { + preparedReport = result + previousReport = previous + loaded = true + } preparing = false } } + private func showExample() { + let reference = Date() + exampleTime = reference + example = ClickyStatistics.example(now: reference) + AccessibilityNotification.Announcement("Showing sample data. It is never saved.").post() + } + + private func regroupCustom() { + grouping = ClickyRanges.grouping(for: ClickyRanges.custom, span: interval.duration) + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Find your typing rhythm").font(.system(size: 21, weight: .semibold, design: .rounded)) + Text(example == nil ? "Local aggregates. Pauses over 5 seconds stop the clock." : "Sample performance, never saved") + .font(.caption).foregroundStyle(example == nil ? Color.secondary : .orange) + } + Spacer() + if preparing { ProgressView().controlSize(.small).help("Preparing the selected time range") } + Button(example == nil ? "Explore Sample Data" : "Back to My Data") { + if example == nil { showExample() } else { + example = nil + exampleTime = nil + AccessibilityNotification.Announcement("Showing your data.").post() + } + }.buttonStyle(.bordered).accessibilityIdentifier("clicky.performance.example") + } + } + private var filters: some View { NativeSettingsCard("Time and filters") { NativeSettingsRow("Date range") { - Picker("Date range", selection: $range) { - ForEach(["Today", "7 days", "30 days", "Custom"], id: \.self) { Text($0).tag($0) } + Picker("Date range", selection: Binding(get: { range }, set: { storedRange = $0 })) { + ForEach(Self.ranges, id: \.self) { Text($0).tag($0) } }.labelsHidden().frame(width: 150, alignment: .trailing) .accessibilityIdentifier("clicky.performance.range") + .onChange(of: storedRange) { _, next in + grouping = next == ClickyRanges.custom + ? ClickyRanges.grouping(for: next, span: interval.duration) : Self.grouping(for: next) + } + } + if let coverage = ClickyRanges.coverageText(interval, earliest: data.performanceStartedAt == nil ? nil : earliest) { + Label(coverage, systemImage: "calendar.badge.clock").font(.caption).foregroundStyle(.secondary) } - if range == "Custom" { + if range == ClickyRanges.custom { HStack { DatePicker("From", selection: $start, displayedComponents: .date) DatePicker("Through", selection: $end, displayedComponents: .date) }.datePickerStyle(.field) } - NativeSettingsRow("Group timeline by") { + let groupings = ClickyRanges.groupings(for: interval, now: now) + NativeSettingsRow("Group timeline by", + detail: groupings.count < ClickyPerformanceGrouping.allCases.count + ? "Minute detail is kept for 30 days, so this range groups by hour or wider." : nil) { Picker("Grouping", selection: $grouping) { - ForEach(ClickyPerformanceGrouping.allCases) { Text($0.label).tag($0) } + ForEach(groupings) { Text($0.label).tag($0) } }.labelsHidden().frame(width: 150, alignment: .trailing) .accessibilityIdentifier("clicky.performance.grouping") } HStack { Text("Weekdays").font(.system(size: 13, weight: .medium)) Spacer() - ForEach(0..<7, id: \.self) { day in - Button(weekdayNames[day]) { + ForEach(ClickyWeekdays.order(), id: \.self) { day in + Button(ClickyWeekdays.shortName(day)) { if weekdays.contains(day) { weekdays.remove(day) } else { weekdays.insert(day) } }.buttonStyle(.borderedProminent).tint(weekdays.contains(day) ? .mint : .gray) .accessibilityValue(weekdays.contains(day) ? "Included" : "Excluded") + .accessibilityAddTraits(weekdays.contains(day) ? .isSelected : []) .accessibilityIdentifier("clicky.performance.weekday.\(day)") } } - NativeSettingsRow("Hours", detail: "Start included, end excluded. A later start spans midnight.") { + NativeSettingsRow("Hours", detail: "Start included, end excluded. A later start spans midnight; the same hour twice means all day.") { HStack(spacing: 8) { NativeSettingsNumberPicker("From hour", range: 0..<24, identifier: "clicky.performance.fromHour", value: $fromHour) - Text("→").foregroundStyle(.secondary) + Text("to").foregroundStyle(.secondary) NativeSettingsNumberPicker("Until hour", range: 1..<25, identifier: "clicky.performance.untilHour", value: $untilHour) } } + HStack(spacing: 8) { + Text("Presets").font(.caption).foregroundStyle(.secondary) + Button("Weekdays") { weekdays = Set(0..<5) }.buttonStyle(.genHover()) + Button("Weekend") { weekdays = [5, 6] }.buttonStyle(.genHover()) + Button("Work hours") { fromHour = 9; untilHour = 17 }.buttonStyle(.genHover()) + .help("9:00 to 17:00") + Spacer() + Button("Reset Filters", action: resetFilters).buttonStyle(.genHover()).disabled(!filtered) + .help(filtered ? "Include every day and every hour" : "No filter is set") + } + } + } + + private func resetFilters() { + weekdays = Set(0..<7) + fromHour = 0 + untilHour = 24 + } + + /// One line in words for the filters in force, shown where the results are. + private var filterSummary: String? { + guard filtered else { return nil } + let days = weekdays.count == 7 ? "every day" + : ClickyWeekdays.order().filter { weekdays.contains($0) }.map { ClickyWeekdays.shortName($0) }.joined(separator: ", ") + let hours = fromHour == 0 && untilHour == 24 ? "all day" : String(format: "%02d:00 to %02d:00", fromHour, untilHour % 24) + return "Filters: \(days.isEmpty ? "no day" : days), \(hours)" + } + + private var emptyResult: some View { + let reason: String + if weekdays.isEmpty { reason = "No weekday is selected, so nothing can match." } + else if ClickyRanges.coveredDays(interval, earliest: earliest) == 0 { reason = "Nothing was recorded in this range." } + else if filtered { reason = "Your weekday and hour filters exclude every key press recorded in this range." } + else { reason = "No key press was recorded in this range." } + return HStack { + Label(reason, systemImage: "line.3.horizontal.decrease.circle").foregroundStyle(.orange) + Spacer() + if filtered { Button("Reset Filters", action: resetFilters).buttonStyle(.bordered) } + }.font(.system(size: 12)) + } + + private func numbers(_ report: ClickyPerformanceReport) -> some View { + let total = report.total + let previous = previousReport?.total + let name = previousName + let netPace = total.activeSeconds >= 1 ? total.estimatedRetainedWords * 60 / total.activeSeconds : nil + let days = max(1, ClickyRanges.coveredDays(interval, earliest: earliest)) + return VStack(alignment: .leading, spacing: 8) { + if let summary = filterSummary { + HStack { + Label(summary, systemImage: "line.3.horizontal.decrease.circle").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Reset", action: resetFilters).buttonStyle(.genHover()).font(.caption) + } + } + Grid(horizontalSpacing: 12, verticalSpacing: 12) { + GridRow { + number("Presses", total.presses.formatted(), "keyboard", "Every key-down event", + ClickyFormat.change(Double(total.presses), previous.map { Double($0.presses) }, against: name)) + number("Words typed", total.estimatedWords.formatted(.number.precision(.fractionLength(0))), + "text.word.spacing", "5 text-like presses per word", + ClickyFormat.change(total.estimatedWords, previous?.estimatedWords, against: name)) + number("Words kept", total.estimatedRetainedWords.formatted(.number.precision(.fractionLength(0))), + "text.badge.checkmark", "Words typed minus delete presses", + ClickyFormat.change(total.estimatedRetainedWords, previous?.estimatedRetainedWords, against: name)) + } + GridRow { + number("Typing time", ClickyFormat.duration(total.activeSeconds), "stopwatch", + "Pauses over 5 seconds excluded", + ClickyFormat.change(total.activeSeconds, previous?.activeSeconds, against: name)) + number("Pace", total.wordsPerMinute.map { String(format: "%.1f WPM", $0) } ?? ClickyFormat.missing, + "speedometer", netPace.map { String(format: "%.1f WPM counting words kept", $0) } ?? "Words typed per minute of typing", + total.wordsPerMinute.flatMap { ClickyFormat.change($0, previous?.wordsPerMinute, against: name) }) + number("Correction share", ClickyFormat.percent(total.correctionShare), "delete.left", + "Delete presses of text and delete presses", + ClickyFormat.pointChange(total.correctionShare, previous?.correctionShare, against: name)) + } + GridRow { + number("Typing sessions", total.sessions.formatted(), "rectangle.stack", + "Text after a clock minute without text", + ClickyFormat.change(Double(total.sessions), previous.map { Double($0.sessions) }, against: name)) + number("Average burst", total.bursts > 0 ? ClickyFormat.duration(total.activeSeconds / Double(total.bursts)) : ClickyFormat.missing, + "waveform.path", "Typing time per started burst", nil) + number("Active days", "\(report.activeDays.formatted()) of \(days.formatted())", "calendar", + "Days with typing; \(ClickyFormat.duration(total.activeSeconds / Double(max(1, report.activeDays)))) per active day", + nil) + } + } } } - private func number(_ title: String, _ value: String, _ symbol: String, _ detail: String) -> some View { + private func number(_ title: String, _ value: String, _ symbol: String, _ detail: String, _ change: String?) -> some View { NativeSettingsCard { Label(title, systemImage: symbol).font(.caption).foregroundStyle(.secondary) Text(value).font(.system(size: 22, weight: .semibold, design: .rounded)).monospacedDigit() - Text(detail).font(.caption2).foregroundStyle(.secondary) + .lineLimit(1).minimumScaleFactor(0.7) + Text(detail).font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(change ?? " ").font(.caption).foregroundStyle(.secondary).monospacedDigit() + .accessibilityHidden(change == nil) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) + } + + private func timelineValue(_ totals: ClickyPerformanceBucket) -> Double? { + switch metric { + case .presses: return Double(totals.presses) + case .words: return totals.estimatedWords + case .time: return totals.activeSeconds + case .pace: return totals.activeSeconds >= 30 ? totals.wordsPerMinute : nil + case .corrections: return totals.characters + totals.corrections >= 20 ? totals.correctionShare * 100 : nil + case .sessions: return Double(totals.sessions) + } + } + + private var timelineFormat: ((Double) -> String)? { + switch metric { + case .time: return { ClickyFormat.duration($0) } + case .pace: return { String(format: "%.0f WPM", $0) } + case .corrections: return { String(format: "%.1f%%", $0) } + default: return nil } } private func timeline(_ report: ClickyPerformanceReport) -> some View { - NativeSettingsCard("Performance over time") { - Picker("Timeline metric", selection: $metric) { - ForEach(["Presses", "Estimated words", "Active seconds"], id: \.self) { Text($0).tag($0) } - }.pickerStyle(.segmented).accessibilityIdentifier("clicky.performance.metric") - NativeTimeSeriesChart(points: report.timeline.map { point in - let value = metric == "Presses" ? Double(point.totals.presses) - : metric == "Estimated words" ? point.totals.estimatedWords : point.totals.activeSeconds - return NativeTimePoint(date: point.date, value: value) + NativeSettingsCard("Performance over time", subtitle: "\(range == ClickyRanges.custom ? "Custom range" : range) by \(grouping.label.lowercased())") { + Picker("Timeline metric", selection: Binding(get: { metric.rawValue }, set: { storedMetric = $0 })) { + ForEach(Metric.allCases, id: \.rawValue) { Text($0.rawValue).tag($0.rawValue) } + }.pickerStyle(.segmented).labelsHidden().accessibilityIdentifier("clicky.performance.metric") + NativeTimeSeriesChart(points: report.timeline.compactMap { point in + timelineValue(point.totals).map { NativeTimePoint(date: point.date, value: $0) } }, interval: Double(grouping.rawValue), initialWindow: Double(max(grouping.rawValue * 12, 3600)), - end: min(now, filter.end), valueLabel: metric) - .id("\(grouping.rawValue).\(range).\(example != nil)") - if report.timeline.isEmpty { Text("No recorded activity matches these filters.").font(.caption).foregroundStyle(.secondary) } + end: min(now, interval.end), valueLabel: metric.rawValue, rangeStart: interval.start, + combine: metric == .pace || metric == .corrections ? .average : .sum, valueFormat: timelineFormat, + style: $chartStyle) + .id("\(grouping.rawValue).\(range).\(interval.start.timeIntervalSince1970).\(example != nil)") + if metric == .pace || metric == .corrections { + Text(metric == .pace ? "Bars with less than 30 seconds of typing are left out." + : "Bars with fewer than 20 text and delete presses are left out.") + .font(.caption).foregroundStyle(.secondary) + } } } - private func dictation(_ totals: ClickyPerformanceBucket) -> some View { - let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: spokenWPM, setupPerBurst: setupSeconds) - return NativeSettingsCard("Estimated time lost by writing instead of dictating", - subtitle: "A configurable what-if comparison, not a measurement of actual dictation.") { - HStack(alignment: .firstTextBaseline) { - Text(totals.activeSeconds > 0 ? duration(max(0, estimate.differenceSeconds)) : "—") - .font(.system(size: 32, weight: .semibold, design: .rounded)).foregroundStyle(.mint) - Text("potential time saved").foregroundStyle(.secondary) + private func dictation(_ report: ClickyPerformanceReport) -> some View { + let totals = report.total + let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: spokenWPM, setupPerSession: setupSeconds, + dictatedShare: Double(dictatedPercent) / 100, reviewShare: Double(reviewPercent) / 100) + let difference = estimate.differenceSeconds + let activeDays = max(1, report.activeDays) + let coveredDays = max(1, ClickyRanges.coveredDays(interval, earliest: earliest)) + let parts = [ + DictationPart(method: "Typing", part: "Typing", seconds: estimate.typingSeconds), + DictationPart(method: "Dictation", part: "Speaking", seconds: estimate.speakingSeconds), + DictationPart(method: "Dictation", part: "Setup", seconds: estimate.setupSeconds), + DictationPart(method: "Dictation", part: "Review", seconds: estimate.reviewSeconds), + ] + return NativeSettingsCard("Estimated time saved by dictating", + subtitle: "A what-if comparison under the settings below, not a measurement of real dictation.") { + if totals.activeSeconds <= 0 { + Text("No typing time in this range to compare.").foregroundStyle(.secondary) + } else if difference >= 0 { + HStack(alignment: .firstTextBaseline) { + Text(ClickyFormat.duration(difference)) + .font(.system(size: 32, weight: .semibold, design: .rounded)).foregroundStyle(.mint).monospacedDigit() + Text("saved in this range").foregroundStyle(.secondary) + } + Text("About \(ClickyFormat.duration(difference / Double(activeDays))) per active day, \(ClickyFormat.duration(difference / Double(coveredDays) * 7)) per week.") + .font(.callout).foregroundStyle(.secondary) + } else { + Text("Typing is \(ClickyFormat.duration(-difference)) faster under these settings") + .font(.system(size: 22, weight: .semibold, design: .rounded)).foregroundStyle(.orange) + Text("About \(ClickyFormat.duration(-difference / Double(activeDays))) per active day.") + .font(.callout).foregroundStyle(.secondary) } - if estimate.differenceSeconds < 0 { - Text("Typing was \(duration(-estimate.differenceSeconds)) faster under these assumptions.") - .font(.caption).foregroundStyle(.orange) + Chart(parts, id: \.part) { part in + BarMark(x: .value("Time", part.seconds), y: .value("Method", part.method)) + .foregroundStyle(by: .value("Part", part.part)) + .annotation(position: .overlay) { + if part.seconds >= max(estimate.typingSeconds, estimate.dictationSeconds) * 0.12 && part.seconds > 0 { + Text(ClickyFormat.duration(part.seconds)).font(.caption.weight(.semibold)).foregroundStyle(.black.opacity(0.8)) + } + } } - Chart { - BarMark(x: .value("Seconds", estimate.typingSeconds), y: .value("Method", "Typing")) - .foregroundStyle(by: .value("Component", "Active typing")) - BarMark(x: .value("Seconds", estimate.speakingSeconds), y: .value("Method", "Dictation")) - .foregroundStyle(by: .value("Component", "Speaking")) - BarMark(x: .value("Seconds", estimate.setupSeconds), y: .value("Method", "Dictation")) - .foregroundStyle(by: .value("Component", "Setup")) - }.chartForegroundStyleScale(["Active typing": Color.mint, "Speaking": Color.cyan, "Setup": Color.orange]) - .frame(height: 110).chartXAxisLabel("Seconds") - NativeSettingsRow("Speech speed", detail: "Estimated retained words ÷ words per minute × 60") { + .chartForegroundStyleScale(["Typing": Color.mint, "Speaking": Color.cyan, "Setup": Color.orange, "Review": Color.purple]) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { value in + AxisGridLine() + AxisValueLabel { if let seconds = value.as(Double.self) { Text(ClickyFormat.duration(seconds)) } } + } + } + .frame(height: 120) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Typing \(ClickyFormat.duration(estimate.typingSeconds)); dictation \(ClickyFormat.duration(estimate.dictationSeconds)): speaking \(ClickyFormat.duration(estimate.speakingSeconds)), setup \(ClickyFormat.duration(estimate.setupSeconds)), review \(ClickyFormat.duration(estimate.reviewSeconds))") + Text("\(estimate.retainedWords.formatted(.number.precision(.fractionLength(0)))) of \(totals.estimatedRetainedWords.formatted(.number.precision(.fractionLength(0)))) words kept would be dictated, in \(Int((Double(totals.sessions) * Double(dictatedPercent) / 100).rounded()).formatted()) of \(totals.sessions.formatted()) typing sessions.") + .font(.caption).foregroundStyle(.secondary) + NativeSettingsSlider("Share of your typing you would dictate", + detail: "Code, commands and passwords stay typed. Clicky cannot tell them apart, so you set the share.", + identifier: "clicky.performance.dictatedShare", value: percentBinding($dictatedPercent), + in: 0...100, step: 5, ticks: [0, 50, 100], width: 160, format: { "\(Int($0))%" }) + NativeSettingsSlider("Review after dictation", + detail: "Reading and fixing the transcript, as a share of the speaking time.", + identifier: "clicky.performance.reviewShare", value: percentBinding($reviewPercent), + in: 0...100, step: 5, ticks: [0, 50, 100], width: 160, format: { "\(Int($0))%" }) + NativeSettingsRow("Speech speed", detail: "Words kept divided by this speed give the speaking time.") { HStack { NativeSettingsNumberPicker("Spoken words per minute", range: 60..<241, identifier: "clicky.performance.speech", value: $spokenWPM); Text("WPM").font(.caption) } } - NativeSettingsRow("Setup per burst", detail: "One dictation activation for every typing burst") { - HStack { NativeSettingsNumberPicker("Setup seconds per burst", range: 0..<16, + NativeSettingsRow("Setup per session", detail: "One dictation start per typing session. A session starts at text after a clock minute without text.") { + HStack { NativeSettingsNumberPicker("Setup seconds per session", range: 0..<16, identifier: "clicky.performance.setup", value: $setupSeconds); Text("sec").font(.caption) } } - Text("\(totals.bursts.formatted()) bursts × \(setupSeconds)s setup + \(duration(estimate.speakingSeconds)) speaking. Retained-word estimate: max(0, text-like presses − delete presses) ÷ 5; deleting a selection or undoing cannot be inferred. It excludes thinking time, transcription delay and review/corrections after dictation; it cannot tell prose from code.") + Text("Words kept are text-like presses minus delete presses, divided by 5; deleting a selection or undoing cannot be inferred. The estimate leaves out thinking time and transcription delay.") .font(.caption).foregroundStyle(.secondary) } } + private struct DictationPart { + let method: String + let part: String + let seconds: Double + } + + private func percentBinding(_ value: Binding<Int>) -> Binding<Double> { + Binding(get: { Double(value.wrappedValue) }, set: { value.wrappedValue = Int($0.rounded()) }) + } + private func pace(_ report: ClickyPerformanceReport) -> some View { - let values = comparison == "Time of day" ? report.hours : report.weekdays - let labels = comparison == "Time of day" ? (0..<24).map { String(format: "%02d", $0) } : weekdayNames - return NativeSettingsCard("Typing performance by \(comparison.lowercased())", subtitle: "Estimated words per active minute · filters above apply") { - Picker("Performance comparison", selection: $comparison) { + let byHour = comparison != "Weekday" + let order = ClickyWeekdays.order() + let entries: [(label: String, bucket: ClickyPerformanceBucket)] = byHour + ? report.hours.enumerated().map { (String(format: "%02d", $0.offset), $0.element) } + : order.map { (ClickyWeekdays.shortName($0), report.weekdays[$0]) } + let overall = report.total.wordsPerMinute + return NativeSettingsCard("Typing pace by \(byHour ? "time of day" : "weekday")", subtitle: "Words typed per minute of typing. Filters above apply.") { + Picker("Pace comparison", selection: $comparison) { Text("Time of day").tag("Time of day") Text("Weekday").tag("Weekday") - }.pickerStyle(.segmented).accessibilityIdentifier("clicky.performance.comparison") - Chart(Array(values.enumerated()), id: \.offset) { index, bucket in - if let pace = bucket.wordsPerMinute { - BarMark(x: .value(comparison, labels[index]), y: .value("Estimated WPM", pace)) - .foregroundStyle(.mint.gradient) + }.pickerStyle(.segmented).labelsHidden().accessibilityIdentifier("clicky.performance.comparison") + Chart { + ForEach(entries, id: \.label) { entry in + if entry.bucket.activeSeconds >= 60, let pace = entry.bucket.wordsPerMinute { + BarMark(x: .value(byHour ? "Hour" : "Weekday", entry.label), y: .value("WPM", pace)) + .foregroundStyle(.mint.gradient) + .opacity(entry.bucket.activeSeconds >= 300 ? 1 : 0.4) + .accessibilityValue("\(String(format: "%.0f", pace)) words per minute over \(ClickyFormat.duration(entry.bucket.activeSeconds))") + } + } + if let overall { + RuleMark(y: .value("Overall", overall)).foregroundStyle(.secondary) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .annotation(position: .top, alignment: .trailing) { + Text(String(format: "Overall %.0f WPM", overall)).font(.caption).foregroundStyle(.secondary) + } } - }.frame(height: 200).chartYAxisLabel("Estimated WPM") - Text("Empty categories have insufficient timed input, not zero typing speed. Pace needs at least 5 text-like presses and 1 active second.") - .font(.caption2).foregroundStyle(.secondary) + }.frame(height: 200).chartYAxisLabel("WPM") + Text("A bar needs at least 1 minute of typing; faded bars have less than 5 minutes. An empty slot means too little typing, not a slow speed.") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func records(_ report: ClickyPerformanceReport) -> some View { + let records = ClickyRecords(days: report.days.mapValues { Double($0.presses) }, today: now) + let bestPace = report.days.filter { $0.value.activeSeconds >= 600 } + .compactMap { day in day.value.wordsPerMinute.map { (day.key, $0) } }.max { $0.1 < $1.1 } + let day = Date.FormatStyle.dateTime.weekday(.abbreviated).month(.abbreviated).day() + return NativeSettingsCard("Records in this range") { + Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 6) { + GridRow { + Text("Busiest day").foregroundStyle(.secondary) + Text(records.busiestDay.map { "\($0.formatted(day)): \(Int(records.busiestDayValue).formatted()) presses" } ?? ClickyFormat.missing) + } + GridRow { + Text("Fastest day").foregroundStyle(.secondary) + Text(bestPace.map { "\($0.0.formatted(day)): \(String(format: "%.1f", $0.1)) WPM" } ?? "Needs a day with 10 minutes of typing") + } + GridRow { + Text("Longest streak").foregroundStyle(.secondary) + Text("\(records.longestStreak) \(records.longestStreak == 1 ? "day" : "days") in a row with typing") + } + GridRow { + Text("Current streak").foregroundStyle(.secondary) + Text("\(records.currentStreak) \(records.currentStreak == 1 ? "day" : "days")") + } + }.font(.system(size: 12)).monospacedDigit() } } private func playful(_ totals: ClickyPerformanceBucket) -> some View { HStack(alignment: .top, spacing: 12) { fun("Tiny finger hike", symbol: "figure.walk", value: String(format: "%.1f m", Double(totals.presses) * 0.002), - progress: Double(totals.presses) * 0.002 / 1000, caption: "Toward 1 km · pretend 2 mm per press") + progress: Double(totals.presses) * 0.002 / 1000, caption: "Toward 1 km, pretending 2 mm per press") fun("Accidental novelist", symbol: "book.closed", value: String(format: "%.1f pages", totals.estimatedWords / 250), - progress: totals.estimatedWords / 80000, caption: "Toward a novel · 80k words, 250 per page") - fun("One-key concert", symbol: "music.note", value: duration(Double(totals.presses) / 2), - progress: Double(totals.presses) / (120 * 60), caption: "Toward a 1-hour set · 120 presses per minute") + progress: totals.estimatedWords / 80000, caption: "Toward a novel: 80k words, 250 per page") + fun("One-key concert", symbol: "music.note", value: ClickyFormat.duration(Double(totals.presses) / 2), + progress: Double(totals.presses) / (120 * 60), caption: "Toward a 1-hour set at 120 presses per minute") } } private func fun(_ title: String, symbol: String, value: String, progress: Double, caption: String) -> some View { NativeSettingsCard { - Image(systemName: symbol).font(.title2).foregroundStyle(.purple) + Image(systemName: symbol).font(.title2).foregroundStyle(.purple).accessibilityHidden(true) Text(title).font(.headline) Text(value).font(.system(size: 20, weight: .semibold, design: .rounded)).monospacedDigit() ProgressView(value: min(1, max(0, progress))).tint(.purple) - Text(caption).font(.caption2).foregroundStyle(.secondary) - Text("Just for fun").font(.caption2).foregroundStyle(.purple) - } + Text(caption).font(.caption).foregroundStyle(.secondary) + Text("Just for fun").font(.caption).foregroundStyle(.purple) + }.accessibilityElement(children: .combine) } - private func duration(_ seconds: Double) -> String { - guard seconds.isFinite else { return "—" } - let value = max(0, Int(seconds.rounded())) - if value >= 3600 { return "\(value / 3600)h \((value % 3600) / 60)m" } - if value >= 60 { return "\(value / 60)m \(value % 60)s" } - return "\(value)s" +} + +/// "Not recording: …" with the button that fixes it. +struct ClickyRecordingBanner: View { + let message: String + let actionTitle: String + let action: (() -> Void)? + + var body: some View { + HStack(spacing: 12) { + Label(message, systemImage: "pause.circle.fill").font(.system(size: 12)).foregroundStyle(.orange) + Spacer(minLength: 0) + if let action { Button(actionTitle, action: action).buttonStyle(.bordered) } + } + .padding(12).nativeGlassSurface() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("clicky.recordingStatus") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyRanges.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyRanges.swift new file mode 100644 index 0000000000..b3fa242272 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyRanges.swift @@ -0,0 +1,236 @@ +import Foundation + +/// The date ranges both statistics pages offer, and the arithmetic around them: the interval a range covers, the +/// previous period of the same length, how much of it has recorded data. One place, so the Stats and Performance +/// pages cannot drift apart. +enum ClickyRanges { + static let today = "Today" + static let week = "7 days" + static let month = "30 days" + static let quarter = "90 days" + static let year = "1 year" + static let all = "All kept data" + static let custom = "Custom" + static let presets = [today, week, month, quarter, year, all] + + /// A stored range name, or the default when the stored one is unknown (an older name such as "All time"). + static func known(_ range: String, allowed: [String]) -> String { + if range == "All time" { return all } + return allowed.contains(range) ? range : week + } + + /// The interval a range covers. Presets end now; Custom covers whole days, `customEnd` included. + static func interval(_ range: String, now: Date, earliest: Date?, customStart: Date = Date(), + customEnd: Date = Date(), calendar: Calendar = .current) -> DateInterval { + let today = calendar.startOfDay(for: now) + let start: Date + switch range { + case Self.today: start = today + case month: start = calendar.date(byAdding: .day, value: -29, to: today) ?? today + case quarter: start = calendar.date(byAdding: .day, value: -89, to: today) ?? today + case year: start = calendar.date(byAdding: .year, value: -1, to: today) ?? today + case all: start = calendar.startOfDay(for: min(earliest ?? now, now)) + case custom: start = calendar.startOfDay(for: customStart) + default: start = calendar.date(byAdding: .day, value: -6, to: today) ?? today + } + let end = range == custom + ? calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: customEnd)) ?? now : now + return DateInterval(start: start, end: max(start, end)) + } + + /// Calendar days the interval touches (a range that ends now counts today). + static func dayCount(_ interval: DateInterval, calendar: Calendar = .current) -> Int { + guard interval.end > interval.start else { return 0 } + let first = calendar.startOfDay(for: interval.start) + let last = calendar.startOfDay(for: interval.end.addingTimeInterval(-1)) + return (calendar.dateComponents([.day], from: first, to: last).day ?? 0) + 1 + } + + /// The period of the same length right before: Today compares with yesterday until the same time, 7 days with + /// the 7 days before. "All kept data" has nothing before it. + static func previous(_ interval: DateInterval, range: String, calendar: Calendar = .current) -> DateInterval? { + guard range != all else { return nil } + let days = dayCount(interval, calendar: calendar) + guard days > 0, let start = calendar.date(byAdding: .day, value: -days, to: interval.start), + let end = calendar.date(byAdding: .day, value: -days, to: interval.end) else { return nil } + return DateInterval(start: start, end: end) + } + + /// "yesterday", "the previous 7 days": the words a comparison uses. + static func previousName(_ interval: DateInterval, range: String, calendar: Calendar = .current) -> String { + let days = dayCount(interval, calendar: calendar) + if range == today { return "yesterday" } + if range == year { return "the year before" } + return days == 1 ? "the day before" : "the previous \(days) days" + } + + /// Days of the interval from the first recorded day on; `earliest == nil` means nothing was recorded. + static func coveredDays(_ interval: DateInterval, earliest: Date?, calendar: Calendar = .current) -> Int { + guard let earliest, earliest < interval.end else { return 0 } + let start = max(interval.start, calendar.startOfDay(for: earliest)) + return dayCount(DateInterval(start: start, end: interval.end), calendar: calendar) + } + + /// One line under the range picker when the range reaches before the first recorded day; nil when it is whole. + static func coverageText(_ interval: DateInterval, earliest: Date?, calendar: Calendar = .current) -> String? { + let total = dayCount(interval, calendar: calendar) + let covered = coveredDays(interval, earliest: earliest, calendar: calendar) + guard covered < total else { return nil } + if covered == 0 { return "Nothing was recorded in this range yet." } + return "Recorded data covers \(covered) of the \(total) days in this range." + } + + /// The timeline grouping a range opens with: about 50 to 400 bars, never one bar per minute over a year. + static func grouping(for range: String, span: TimeInterval = 0) -> ClickyPerformanceGrouping { + switch range { + case today: return .fifteenMinutes + case week: return .hour + case month, quarter: return .day + case year, all: return .week + case custom: + if span <= 86400 * 1.5 { return .fifteenMinutes } + if span <= 86400 * 8 { return .hour } + if span <= 86400 * 200 { return .day } + return .week + default: return .day + } + } + + /// Minute detail exists only for the last `performanceMinuteDays`; an older range offers hour bars and wider. + static func hasMinuteDetail(_ interval: DateInterval, now: Date) -> Bool { + interval.start >= now.addingTimeInterval(-Double(ClickyStatistics.performanceMinuteDays) * 86400) + } + + static func groupings(for interval: DateInterval, now: Date) -> [ClickyPerformanceGrouping] { + hasMinuteDetail(interval, now: now) ? ClickyPerformanceGrouping.allCases + : ClickyPerformanceGrouping.allCases.filter { $0.rawValue >= 3600 } + } + + /// The Stats activity chart's opening resolution for a range. + static func resolution(for range: String) -> ClickyTimeResolution { + switch range { + case today: return .minute + case week: return .hour + default: return .day + } + } + + static func resolutions(for interval: DateInterval, now: Date) -> [ClickyTimeResolution] { + hasMinuteDetail(interval, now: now) ? ClickyTimeResolution.allCases : [.hour, .day] + } +} + +/// What Clicky keeps, said once for every page. +enum ClickyRetention { + static let text = "Clicky keeps minute detail for 30 days, hourly key counts for 1 year, hourly typing performance for 2 years and daily key counts for 2 years. Older detail is removed; lifetime totals stay." +} + +/// Weekdays in the user's calendar order. Clicky stores weekday indexes with 0 = Monday. +enum ClickyWeekdays { + /// Monday-based indexes, starting at the calendar's first weekday. + static func order(_ calendar: Calendar = .current) -> [Int] { + let first = (calendar.firstWeekday + 5) % 7 + return (0..<7).map { (first + $0) % 7 } + } + + static func shortName(_ index: Int, calendar: Calendar = .current) -> String { + calendar.shortWeekdaySymbols[(index + 1) % 7] + } + + static func name(_ index: Int, calendar: Calendar = .current) -> String { + calendar.weekdaySymbols[(index + 1) % 7] + } + + /// Monday-based index of a date. + static func index(of date: Date, calendar: Calendar = .current) -> Int { + (calendar.component(.weekday, from: date) + 5) % 7 + } +} + +enum ClickyFormat { + static let missing = "n/a" + + static func duration(_ seconds: Double) -> String { + guard seconds.isFinite else { return missing } + let value = max(0, Int(seconds.rounded())) + if value >= 3600 { return "\(value / 3600)h \((value % 3600) / 60)m" } + if value >= 60 { return "\(value / 60)m \(value % 60)s" } + return "\(value)s" + } + + static func percent(_ share: Double) -> String { + share.formatted(.percent.precision(.fractionLength(1))) + } + + /// "Up 12% from the previous 7 days"; nil when there is nothing to compare with. + static func change(_ current: Double, _ previous: Double?, against name: String) -> String? { + guard let previous, previous > 0, current.isFinite else { return nil } + let ratio = (current - previous) / previous + if abs(ratio) < 0.005 { return "Same as \(name)" } + let amount = abs(ratio).formatted(.percent.precision(.fractionLength(0))) + return "\(ratio > 0 ? "Up" : "Down") \(amount) from \(name)" + } + + /// A change of a share in percentage points: "Down 1.2 pt from yesterday". + static func pointChange(_ current: Double, _ previous: Double?, against name: String) -> String? { + guard let previous, previous > 0 || current > 0 else { return nil } + let points = (current - previous) * 100 + if abs(points) < 0.05 { return "Same as \(name)" } + return "\(points > 0 ? "Up" : "Down") \(String(format: "%.1f", abs(points))) pt from \(name)" + } +} + +/// Busiest day and streaks of days with typing, from daily totals. +struct ClickyRecords: Equatable, Sendable { + var busiestDay: Date? + var busiestDayValue = 0.0 + var longestStreak = 0 + var currentStreak = 0 + + init(days: [Date: Double], today: Date, calendar: Calendar = .current) { + let active = days.filter { $0.value > 0 } + if let best = active.max(by: { $0.value == $1.value ? $0.key > $1.key : $0.value < $1.value }) { + busiestDay = best.key + busiestDayValue = best.value + } + let sorted = Set(active.keys.map { calendar.startOfDay(for: $0) }).sorted() + var run = 0 + var previous: Date? + for day in sorted { + if let previous, calendar.date(byAdding: .day, value: 1, to: previous) == day { run += 1 } else { run = 1 } + longestStreak = max(longestStreak, run) + previous = day + } + let set = Set(sorted) + var cursor = calendar.startOfDay(for: today) + if !set.contains(cursor) { cursor = calendar.date(byAdding: .day, value: -1, to: cursor) ?? cursor } + while set.contains(cursor) { + currentStreak += 1 + guard let next = calendar.date(byAdding: .day, value: -1, to: cursor) else { break } + cursor = next + } + } +} + +/// Whether Clicky records statistics right now, and if not, why. +public enum ClickyRecordingStatus: Equatable, Sendable { + case recording, clickyOff, inputMonitoringMissing, statisticsOff + + var message: String? { + switch self { + case .recording: return nil + case .clickyOff: return "Not recording: Clicky is off, so no key presses are counted." + case .inputMonitoringMissing: return "Not recording: Input Monitoring is not allowed for Clicky." + case .statisticsOff: return "Not recording: Keep local statistics is off." + } + } + + var actionTitle: String { + switch self { + case .recording: return "" + case .clickyOff: return "Turn On Clicky" + case .inputMonitoringMissing: return "Allow Input Monitoring" + case .statisticsOff: return "Turn On Statistics" + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift index a15a036477..83bbdb7596 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift @@ -56,14 +56,55 @@ public struct ClickyPreferences: Codable, Equatable { public var collectStats = true public var reduceMotion = false public var reduceTransparency = false + /// The output Clicky plays on; nil follows the system output (and its rules). + public var outputDeviceUID: String? + public var outputDeviceName: String? + public var outputRules: [ClickyOutputRule] = [] + /// Offer another output when the one Clicky plays on is late by more than `delayWarningMilliseconds`. + public var delayWarning = true + public var delayWarningMilliseconds = 150 + /// Outputs the user chose to keep although they are late: never offered again. + public var delayAcceptedUIDs: [String] = [] public init() {} + /// Every key is optional, so preferences saved by an older build keep their values and take the new defaults. + public init(from decoder: Decoder) throws { + self.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + func read<T: Decodable>(_ key: CodingKeys, into field: inout T) throws { + if let value = try values.decodeIfPresent(T.self, forKey: key) { field = value } + } + try read(.selectedSwitch, into: &selectedSwitch) + selectedPack = try values.decodeIfPresent(ClickyPackReference.self, forKey: .selectedPack) + try read(.volume, into: &volume) + try read(.releaseSounds, into: &releaseSounds) + try read(.randomizedPitch, into: &randomizedPitch) + try read(.spatialAudio, into: &spatialAudio) + try read(.repeatSounds, into: &repeatSounds) + try read(.quietHours, into: &quietHours) + try read(.quietStart, into: &quietStart) + try read(.quietEnd, into: &quietEnd) + try read(.excludedApplications, into: &excludedApplications) + try read(.visualizer, into: &visualizer) + try read(.notifications, into: ¬ifications) + try read(.collectStats, into: &collectStats) + try read(.reduceMotion, into: &reduceMotion) + try read(.reduceTransparency, into: &reduceTransparency) + outputDeviceUID = try values.decodeIfPresent(String.self, forKey: .outputDeviceUID) + outputDeviceName = try values.decodeIfPresent(String.self, forKey: .outputDeviceName) + try read(.outputRules, into: &outputRules) + try read(.delayWarning, into: &delayWarning) + try read(.delayWarningMilliseconds, into: &delayWarningMilliseconds) + try read(.delayAcceptedUIDs, into: &delayAcceptedUIDs) + } + public mutating func normalize() { volume = volume.isFinite ? min(1, max(0, volume)) : 0.45 quietStart = min(1439, max(0, quietStart)) quietEnd = min(1439, max(0, quietEnd)) excludedApplications = Array(Set(excludedApplications.filter { !$0.isEmpty })).sorted() + delayWarningMilliseconds = min(5000, max(20, delayWarningMilliseconds)) } public func isQuiet(at date: Date, calendar: Calendar = .current) -> Bool { @@ -101,6 +142,8 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { public var days: [Int: ClickyActivityBucket] = [:] public var keys: [Int: Int] = [:] public var performanceMinutes: [Int: ClickyPerformanceBucket] = [:] + /// Hourly totals of the minutes older than `performanceMinuteDays`, kept for `performanceHourDays`. + public var performanceHours: [Int: ClickyPerformanceBucket] = [:] public var performanceStartedAt: Date? var lastTypingAt: Date? private var latestMinute = 0 @@ -109,7 +152,7 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case presses, releases, sessions, startedAt, historyStartedAt, minutes, hours, days, keys - case performanceMinutes, performanceStartedAt + case performanceMinutes, performanceHours, performanceStartedAt } public init(from decoder: Decoder) throws { @@ -124,6 +167,7 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { days = try values.decodeIfPresent([Int: ClickyActivityBucket].self, forKey: .days) ?? [:] keys = try values.decodeIfPresent([Int: Int].self, forKey: .keys) ?? [:] performanceMinutes = try values.decodeIfPresent([Int: ClickyPerformanceBucket].self, forKey: .performanceMinutes) ?? [:] + performanceHours = try values.decodeIfPresent([Int: ClickyPerformanceBucket].self, forKey: .performanceHours) ?? [:] performanceStartedAt = try values.decodeIfPresent(Date.self, forKey: .performanceStartedAt) latestMinute = minutes.keys.max() ?? 0 } @@ -132,7 +176,8 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { lhs.presses == rhs.presses && lhs.releases == rhs.releases && lhs.sessions == rhs.sessions && lhs.startedAt == rhs.startedAt && lhs.historyStartedAt == rhs.historyStartedAt && lhs.minutes == rhs.minutes && lhs.hours == rhs.hours && lhs.days == rhs.days && lhs.keys == rhs.keys - && lhs.performanceMinutes == rhs.performanceMinutes && lhs.performanceStartedAt == rhs.performanceStartedAt + && lhs.performanceMinutes == rhs.performanceMinutes && lhs.performanceHours == rhs.performanceHours + && lhs.performanceStartedAt == rhs.performanceStartedAt } public mutating func record(keyCode: UInt16, release: Bool, at date: Date = Date(), calendar: Calendar = .current, @@ -150,7 +195,7 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { let retentionHour = latestMinute / 60 if prunedHour != retentionHour { minutes = minutes.filter { $0.key >= latestMinute - 30 * 24 * 60 } - performanceMinutes = performanceMinutes.filter { $0.key >= latestMinute - 30 * 24 * 60 } + rollUpPerformance(olderThan: latestMinute - Self.performanceMinuteDays * 24 * 60) hours = hours.filter { $0.key >= retentionHour - 366 * 24 } let oldestDay = calendar.date(byAdding: .day, value: -730, to: date) ?? date days = days.filter { $0.key >= Int(oldestDay.timeIntervalSince1970) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift index 30bad7fab2..a1df6d934a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift @@ -127,7 +127,8 @@ private struct ClickySettingsPageContent: View { if model.statisticsLoadError == nil { stats } else { historyRecovery } case .performance: if model.statisticsLoadError == nil { - ClickyPerformanceView(store: model.analytics, defaults: model.settingsDefaults) + ClickyPerformanceView(store: model.analytics, defaults: model.settingsDefaults, + status: model.recordingStatus, fixRecording: model.fixRecording) } else { historyRecovery } @@ -255,6 +256,7 @@ private struct ClickySettingsPageContent: View { } } ClickySoundLibraryView(model: model, library: model.soundLibrary) + ClickyOutputCard(model: model) card("Sound behavior") { setting( "Key release sounds", detail: "A separate sound when each key comes back up.", @@ -375,13 +377,14 @@ private struct ClickySettingsPageContent: View { private var stats: some View { VStack(spacing: 18) { - ClickyAnalyticsView(store: model.analytics) + ClickyAnalyticsView(store: model.analytics, defaults: model.settingsDefaults, + status: model.recordingStatus, fixRecording: model.fixRecording) card("Privacy") { setting( "Keep local statistics", detail: "Minute and hour totals, daily totals and aggregate physical-key counts. No typed text, key sequence or application history.", value: $model.preferences.collectStats) - Text("Minute history: 30 days · Hourly: 1 year · Daily: 2 years. Earlier lifetime totals are preserved without inventing historical detail.") + Text(ClickyRetention.text) .font(.caption).foregroundStyle(.secondary) Text("Counts are recorded while Clicky is listening. Paused or muted applications and Secure Input are not counted.") .font(.caption).foregroundStyle(.secondary) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift b/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift new file mode 100644 index 0000000000..18e6d23cc6 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift @@ -0,0 +1,121 @@ +import Foundation +import GenesisKitBase + +/// The cmux CLI for every part that opens a session or a command in a pane: the hub's `CmuxHost` and the widget's cmux +/// button. One binary lookup, the new workspace / pane / tab / type-into-a-tab moves, and the reading of +/// `tools <agent> cmux focus --json`. Blocking (each call spawns a process): call it off the main thread. +public enum CmuxCommandRunner { + public static var binary: String? { + ["/Applications/cmux.app/Contents/Resources/bin/cmux", "~/.local/bin/cmux", "/usr/local/bin/cmux", "/opt/homebrew/bin/cmux"] + .map { ($0 as NSString).expandingTildeInPath } + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + @discardableResult + public static func cmux(_ args: [String]) -> (ok: Bool, out: String) { + guard let binary else { return (false, "cmux is not installed") } + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = args + var environment = ProcessInfo.processInfo.environment + environment["CMUX_QUIET"] = "1" + process.environment = environment + guard let result = try? process.runCapturing() else { return (false, "cmux failed to start") } + let text = String(decoding: result.stdout, as: UTF8.self) + return (result.status == 0, result.status == 0 ? text : String(decoding: result.stderr, as: UTF8.self) + text) + } + + /// Runs `command` in `cwd` at `target`. An error message, or nil when it opened. Words are shell-quoted here because + /// cmux takes one command string. + public static func run(_ command: [String], cwd: String, name: String, at target: CmuxTarget) -> String? { + let words = command.map(ShellWords.word).joined(separator: " ") + let line = cwd.isEmpty ? words : "cd \(ShellWords.word(cwd)) && \(words)" + switch target { + case .newWorkspace(let window): + let result = cmux(["new-workspace", "--name", name, "--cwd", cwd, "--command", words, "--focus", "true"] + + (window.map { ["--window", $0] } ?? [])) + return result.ok ? nil : result.out.trimmed + case .newPane(let workspace): + return typeInto(cmux(["new-pane", "--type", "terminal", "--direction", "right", "--workspace", workspace, + "--focus", "true"]), workspace: workspace, line: line) + case .newTab(let workspace, let pane): + return typeInto(cmux(["new-surface", "--type", "terminal", "--workspace", workspace, "--pane", pane, + "--focus", "true"]), workspace: workspace, line: line) + case .surface(let workspace, let surface): + return send(line, workspace: workspace, surface: surface) + } + } + + /// new-surface / new-pane print the new surface ref; the command is typed into it, then Enter. + private static func typeInto(_ created: (ok: Bool, out: String), workspace: String, line: String) -> String? { + guard created.ok else { return created.out.trimmed } + guard let range = created.out.range(of: #"surface:\d+"#, options: .regularExpression) else { + return "cmux did not report the new surface: \(created.out.trimmed)" + } + return send(line, workspace: workspace, surface: String(created.out[range])) + } + + private static func send(_ line: String, workspace: String, surface: String) -> String? { + let sent = cmux(["send", "--workspace", workspace, "--surface", surface, line]) + guard sent.ok else { return sent.out.trimmed } + let enter = cmux(["send-key", "--workspace", workspace, "--surface", surface, "Enter"]) + return enter.ok ? nil : enter.out.trimmed + } + + /// The command that resumes an agent session in a terminal, or nil for an agent with none. + public static func resumeCommand(provider: String, sessionId: String) -> [String]? { + switch provider { + case "claude": return ["tools", "claude", "resume", sessionId] + case "codex": return ["codex", "resume", sessionId] + case "grok": return ["grok", "--resume", sessionId] + default: return nil + } + } + + /// The `tools <agent>` that owns a session's cmux resolver: `tools codex cmux` and `tools grok cmux` know their own + /// history (Grok has no journal and its own fallback); anything else goes through Claude's. + public static func tool(provider: String?) -> String { + switch provider { + case "codex", "grok": return provider ?? "claude" + default: return "claude" + } + } + + public static let paneGone = "That cmux pane is gone. Choose a pane to resume the session in." + + /// What `tools <agent> cmux focus --json` said, in words: nil when it focused. A recorded tab that was closed + /// answers `gone`; before, the hub and Genesis showed cmux's raw "[socket] RPC error". + public static func focusFailure(status: Int32, stdout: Data, stderr: Data) -> String? { + guard status != 0 else { return nil } + struct Opaque: Decodable { + init(from decoder: Decoder) throws {} + } + struct Result: Decodable { + let gone: Bool? + let matches: [Opaque]? + } + if let result = try? JSONDecoder().decode(Result.self, from: stdout) { + if result.gone == true { + return paneGone + } + if result.matches?.isEmpty == true { + return "No cmux pane shows this session. Choose a pane to resume it in." + } + } + let lines = String(decoding: stderr, as: UTF8.self).split(whereSeparator: \.isNewline) + let said = lines.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│■▲● ").union(.whitespaces)) } + .last { !$0.isEmpty } + return said.map { String($0) } ?? "tools claude cmux focus exited \(status)" + } + + /// `open-session --json` reports a failure as {ok: false, error} on stdout, with stderr empty. + public static func openSessionFailure(status: Int32, stdout: Data, stderr: Data) -> String? { + guard status != 0 else { return nil } + struct Failure: Decodable { let error: String } + if let failure = try? JSONDecoder().decode(Failure.self, from: stdout) { + return failure.error + } + let text = String(decoding: stderr, as: UTF8.self).trimmed + return text.isEmpty ? "tools claude cmux open-session exited \(status)" : String(text.suffix(300)) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift index 99831d3cba..c7d5783178 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift @@ -106,9 +106,12 @@ public struct AgentRosterGroupLabel: View { public let running: Int public var live: Bool public var lastAt: Date? + /// Inbox items waiting in the session, shown beside its name (the widget's session list). + public var unread: Int public init(title: String, provider: String, project: String? = nil, account: String? = nil, - total: Int, running: Int, live: Bool, lastAt: Date? = nil) { + total: Int, running: Int, live: Bool, lastAt: Date? = nil, unread: Int = 0) { + self.unread = unread self.title = title self.provider = provider self.project = project @@ -129,7 +132,13 @@ public struct AgentRosterGroupLabel: View { } }.padding(.top, 1) VStack(alignment: .leading, spacing: 3) { - Text(title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + if unread > 0 { + Spacer(minLength: 0) + CountBadge(unread, tooltip: "\(Plural.count(unread, "inbox item"))", color: KitPalette.modified) + } + } HStack(spacing: 6) { if let project { Text(project).layoutPriority(-2) } if let account { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift index 13c6a889d9..97bd0a00bb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift @@ -11,14 +11,23 @@ import SwiftUI public struct FittedScroll<Content: View>: View { public var maxHeight: CGFloat public var minHeight: CGFloat + /// The height until the content is first measured, and every measurement goes to `measured`. A view that is + /// built again each time it shows (the widget's hover list) passes its last height here: from 0, the first layout + /// pass reported a list-less height, its window shrank and grew back within 40 ms, and the panel blinked + /// (edge.request log, 2026-10-23: 336, then 295, then 489 pt for one hover). + public var initialHeight: CGFloat + private let measured: ((CGFloat) -> Void)? private let content: () -> Content @State private var height: CGFloat = 0 @State private var expansion = FittedExpansionNote() @GenReduceMotion private var reduceMotion - public init(maxHeight: CGFloat, minHeight: CGFloat = 0, @ViewBuilder content: @escaping () -> Content) { + public init(maxHeight: CGFloat, minHeight: CGFloat = 0, initialHeight: CGFloat = 0, + measured: ((CGFloat) -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) { self.maxHeight = maxHeight self.minHeight = minHeight + self.initialHeight = initialHeight + self.measured = measured self.content = content } @@ -29,11 +38,12 @@ public struct FittedScroll<Content: View>: View { .scrollOverflowContent() } .scrollOverflowHints() - .frame(height: min(maxHeight, max(minHeight, height))) + .frame(height: min(maxHeight, max(minHeight, height > 0 ? height : initialHeight))) .onGenExpansion { expansion.note($0) } } private func apply(_ value: CGFloat) { + measured?(value) guard height > 0, let expanding = expansion.recent() else { height = value return diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift index 5cd3544c3b..7f183692f7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift @@ -96,8 +96,9 @@ public struct ForgeBadge: View { } } -/// The subset of SVG path data the forge marks use: M, L, H, V, C, S and Z, absolute and relative, with -/// implicit repeats and the compact number forms (`.4.07`, `-.17`). Anything else ends the path there. +/// The subset of SVG path data the forge and provider marks use: M, L, H, V, C, S, A and Z, absolute and relative, +/// with implicit repeats and the compact number forms (`.4.07`, `-.17`, arc flags packed as `01-.104`). Anything else +/// ends the path there. enum SVGPath { static func parse(_ data: String) -> Path { var path = Path() @@ -159,6 +160,13 @@ enum SVGPath { path.addCurve(to: target, control1: first, control2: second) current = target lastControl = second + case "A": + guard let rx = tokens.number(), let ry = tokens.number(), let rotation = tokens.number(), + let large = tokens.flag(), let sweep = tokens.flag(), let target = point() else { return path } + addArc(to: &path, from: current, radii: CGSize(width: rx, height: ry), rotation: rotation, + large: large, sweep: sweep, end: target) + current = target + lastControl = nil default: return path } @@ -166,9 +174,68 @@ enum SVGPath { return path } + /// An elliptical arc as cubic curves, at most a quarter turn each (SVG 1.1 implementation notes F.6.5 and F.6.6). + static func addArc( + to path: inout Path, from start: CGPoint, radii: CGSize, rotation: CGFloat, large: Bool, sweep: Bool, + end: CGPoint + ) { + var rx = abs(radii.width) + var ry = abs(radii.height) + guard rx > 0, ry > 0, start != end else { + path.addLine(to: end) + return + } + let phi = rotation * .pi / 180 + let cosPhi = cos(phi), sinPhi = sin(phi) + let dx = (start.x - end.x) / 2, dy = (start.y - end.y) / 2 + let x1 = cosPhi * dx + sinPhi * dy + let y1 = -sinPhi * dx + cosPhi * dy + // Radii too small to reach the end point grow until they just do. + let lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry) + if lambda > 1 { + rx *= sqrt(lambda) + ry *= sqrt(lambda) + } + let numerator = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1 + let denominator = rx * rx * y1 * y1 + ry * ry * x1 * x1 + var factor = sqrt(max(0, numerator / denominator)) + if large == sweep { factor = -factor } + let cx1 = factor * rx * y1 / ry + let cy1 = -factor * ry * x1 / rx + let center = CGPoint(x: cosPhi * cx1 - sinPhi * cy1 + (start.x + end.x) / 2, + y: sinPhi * cx1 + cosPhi * cy1 + (start.y + end.y) / 2) + func angle(_ ux: CGFloat, _ uy: CGFloat, _ vx: CGFloat, _ vy: CGFloat) -> CGFloat { + let value = atan2(ux * vy - uy * vx, ux * vx + uy * vy) + return value + } + let theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry) + var delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry) + if !sweep, delta > 0 { delta -= 2 * .pi } + if sweep, delta < 0 { delta += 2 * .pi } + let segments = max(1, Int(ceil(abs(delta) / (.pi / 2)))) + let step = delta / CGFloat(segments) + let k = 4 / 3 * tan(step / 4) + func onEllipse(_ t: CGFloat) -> CGPoint { + CGPoint(x: center.x + rx * cos(t) * cosPhi - ry * sin(t) * sinPhi, + y: center.y + rx * cos(t) * sinPhi + ry * sin(t) * cosPhi) + } + func tangent(_ t: CGFloat) -> CGPoint { + CGPoint(x: -rx * sin(t) * cosPhi - ry * cos(t) * sinPhi, y: -rx * sin(t) * sinPhi + ry * cos(t) * cosPhi) + } + var t = theta + for index in 0 ..< segments { + let next = t + step + let from = onEllipse(t), to = index == segments - 1 ? end : onEllipse(next) + let d1 = tangent(t), d2 = tangent(next) + path.addCurve(to: to, control1: CGPoint(x: from.x + k * d1.x, y: from.y + k * d1.y), + control2: CGPoint(x: to.x - k * d2.x, y: to.y - k * d2.y)) + t = next + } + } + private enum Token: Equatable { case command(Character) - case number(CGFloat) + case number(CGFloat, raw: String) } private struct Tokens { @@ -178,7 +245,7 @@ enum SVGPath { init(_ text: String) { var number = "" func flush() { - if let value = Double(number) { items.append(.number(CGFloat(value))) } + if let value = Double(number) { items.append(.number(CGFloat(value), raw: number)) } number = "" } for char in text { @@ -206,9 +273,25 @@ enum SVGPath { mutating func advance() { index += 1 } mutating func number() -> CGFloat? { - guard case .number(let value) = peek() else { return nil } + guard case .number(let value, _) = peek() else { return nil } index += 1 return value } + + /// An arc flag: one digit, which may share its token with what follows (`01-.104` is flags 0 and 1). + mutating func flag() -> Bool? { + guard case .number(_, let raw) = peek(), let first = raw.first, first == "0" || first == "1" else { + return nil + } + let rest = String(raw.dropFirst()) + if rest.isEmpty { + index += 1 + } else if let value = Double(rest) { + items[index] = .number(CGFloat(value), raw: rest) + } else { + return nil + } + return first == "1" + } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/FrameProbe.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FrameProbe.swift new file mode 100644 index 0000000000..bd9b0acc8f --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FrameProbe.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// Where a named control sits in its window, for a test that clicks it. A hidden test window has no accessibility tree, +/// so a test cannot find a control by its label; a click scan over points presses whatever lies there (it opened +/// Obsidian dialogs on the user's screen, 2026-10-23). Off unless a test turns it on; then `.frameProbe(name)` records +/// the view's frame in window coordinates (top-left origin, as SwiftUI's global space). +public enum FrameProbe { + @MainActor public static var enabled = false + @MainActor public private(set) static var frames: [String: CGRect] = [:] + + @MainActor static func record(_ name: String, _ frame: CGRect) { + frames[name] = frame + } + + @MainActor public static func reset() { + frames = [:] + } +} + +public extension View { + /// Records this view's window frame under `name` while `FrameProbe.enabled`; nothing at all otherwise. + @MainActor @ViewBuilder func frameProbe(_ name: String) -> some View { + if FrameProbe.enabled { + onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { FrameProbe.record(name, $0) } + } else { + self + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/MouseClick.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/MouseClick.swift new file mode 100644 index 0000000000..991fbf1ccc --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/MouseClick.swift @@ -0,0 +1,21 @@ +import AppKit + +/// The click count of the event that ran an action. `NSEvent.clickCount` raises (an Objective-C exception, not an +/// optional) on any event that is not a mouse event, and a SwiftUI button action can run while the current event is a +/// key, a system-defined or an app-defined event. The exception broke the window's event handling: a group chip of the +/// markdown tab bar read it, and from then on no click in the window did anything (Genesis Markdown, 2026-10-23 16:36). +/// Read a click count only through this. +public enum MouseClick { + static let mouseTypes: Set<NSEvent.EventType> = [ + .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp, + ] + + /// 1 unless `event` is a mouse event; then its own click count. + public static func count(of event: NSEvent?) -> Int { + guard let event, mouseTypes.contains(event.type) else { return 1 } + return event.clickCount + } + + /// The click count of the event AppKit is handling now. + @MainActor public static var current: Int { count(of: NSApp.currentEvent) } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/NoticePill.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/NoticePill.swift index 3b86575263..48bb925fad 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/NoticePill.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/NoticePill.swift @@ -1,23 +1,91 @@ import SwiftUI -/// A short status line that fades after six seconds: icon, message, optional dim detail. An error -/// stays until it is dismissed. +/// One way to fix what a notice reports. A button says in its label what it does; a button that changes something +/// outside the view (quits an app, stops a process) asks first, inside the notice, so the question also works in a +/// panel that never becomes key. A command is for the person's terminal: the notice shows it and copies it. +public struct NoticeFix: Identifiable { + /// The question a button asks before it acts, and what its confirming button says. + public struct Confirmation { + public let question: String + public let consequence: String? + public let confirmLabel: String + + public init(_ question: String, consequence: String? = nil, confirmLabel: String) { + self.question = question + self.consequence = consequence + self.confirmLabel = confirmLabel + } + } + + public enum Kind { + case action(confirm: Confirmation?, perform: @MainActor () -> Void) + case command(String) + } + + public let label: String + public let symbol: String? + public let kind: Kind + public var id: String { + switch kind { + case .action: return "action:" + label + case .command(let command): return "command:" + command + } + } + + /// `label` says what a press does ("Quit the older Genesis (pid 1234)"); `confirm` makes it ask first. + public static func button(_ label: String, symbol: String? = nil, confirm: Confirmation? = nil, + perform: @escaping @MainActor () -> Void) -> NoticeFix { + NoticeFix(label: label, symbol: symbol, kind: .action(confirm: confirm, perform: perform)) + } + + /// `label` says what the command does ("Or quit it from a terminal"). + public static func command(_ label: String, _ command: String) -> NoticeFix { + NoticeFix(label: label, symbol: nil, kind: .command(command)) + } +} + +/// A status line: icon, message, optional dim detail, the fixes, and the full message behind "Details". A success +/// notice without fixes fades after six seconds; an error, a warning or a notice with a fix stays until it is +/// dismissed or no longer applies. public struct NoticePill: View { + public enum Tone: Sendable { + case success, warning, error + + var color: Color { + switch self { + case .success: return KitPalette.added + case .warning: return KitPalette.modified + case .error: return KitPalette.removed + } + } + + var symbol: String { self == .success ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" } + } + let text: String var detail: String? - /// The full message behind a short `text` (a CLI's raw error), shown only after a click on "Details". + /// The full message behind a short `text` (a CLI's raw error, what was found), shown only after a click on "Details". var more: String? - var isError: Bool - let dismiss: () -> Void + var tone: Tone + var fixes: [NoticeFix] + /// Nil: no close icon, for a notice that stays while what it reports is true. + let dismiss: (() -> Void)? @State private var showsMore = false + @State private var confirming: String? @Environment(\.accessibilityReduceMotion) private var reduceMotion public init(text: String, detail: String? = nil, more: String? = nil, isError: Bool = false, - dismiss: @escaping () -> Void) { + fixes: [NoticeFix] = [], dismiss: (() -> Void)? = nil) { + self.init(text: text, detail: detail, more: more, tone: isError ? .error : .success, fixes: fixes, dismiss: dismiss) + } + + public init(text: String, detail: String? = nil, more: String? = nil, tone: Tone, + fixes: [NoticeFix] = [], dismiss: (() -> Void)? = nil) { self.text = text self.detail = detail self.more = more - self.isError = isError + self.tone = tone + self.fixes = fixes self.dismiss = dismiss } @@ -26,8 +94,7 @@ public struct NoticePill: View { // get a rounded box of the same look, and the text can be selected and copied. public var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") - .foregroundColor(isError ? KitPalette.removed : KitPalette.added) + Image(systemName: tone.symbol).foregroundColor(tone.color) VStack(alignment: .leading, spacing: 2) { Text(text).font(.system(size: 12, weight: .medium)) .fixedSize(horizontal: false, vertical: true) @@ -35,6 +102,13 @@ public struct NoticePill: View { Text(detail).font(.system(size: 11.5)).foregroundColor(KitPalette.dim) .fixedSize(horizontal: false, vertical: true) } + if !fixes.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(fixes) { fix in fixRow(fix) } + } + .padding(.top, 5) + .padding(.bottom, 2) + } if let more { Button { GenMotion.perform(expanding: !showsMore, reduceMotion: reduceMotion) { showsMore.toggle() } @@ -56,16 +130,94 @@ public struct NoticePill: View { } .textSelection(.enabled) .multilineTextAlignment(.leading) - IconButton(systemName: "xmark", tooltip: "Dismiss", size: 9, action: dismiss) + if let dismiss { + IconButton(systemName: "xmark", tooltip: "Dismiss", size: 9, action: dismiss) + } } .padding(.leading, 10) - .padding(.trailing, 4) - .padding(.vertical, 3) + .padding(.trailing, dismiss == nil ? 10 : 4) + .padding(.vertical, fixes.isEmpty ? 3 : 7) .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(Color.white.opacity(0.06))) .overlay(RoundedRectangle(cornerRadius: 11, style: .continuous).stroke(Color.white.opacity(0.1))) .task(id: text) { try? await Task.sleep(nanoseconds: 6_000_000_000) - if !isError { dismiss() } + if tone == .success, fixes.isEmpty { dismiss?() } + } + } + + @ViewBuilder + private func fixRow(_ fix: NoticeFix) -> some View { + switch fix.kind { + case .action(let confirm, let perform): + VStack(alignment: .leading, spacing: 5) { + Button { + if confirm == nil { perform() } else { toggleConfirmation(fix.id) } + } label: { + fixLabel(fix.label, symbol: fix.symbol ?? "wrench.and.screwdriver", tint: tone == .success ? KitPalette.renamed : tone.color) + } + .buttonStyle(.genHoverPlain()) + .disabled(confirming == fix.id) + .accessibilityIdentifier("notice-fix-" + fix.label) + if let confirm, confirming == fix.id { + confirmation(confirm, perform: perform) + .transition(.genReveal) + } + } + case .command(let command): + VStack(alignment: .leading, spacing: 3) { + Text(fix.label).font(.system(size: 11)).foregroundColor(KitPalette.dim) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(verbatim: command).font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color.white.opacity(0.85)) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(RoundedRectangle(cornerRadius: 5).fill(Color.black.opacity(0.25))) + IconButton(systemName: "doc.on.doc", tooltip: "Copy the command", size: 10) { + Clipboard.copy(command, what: "command") + } + } + } + .accessibilityIdentifier("notice-command-" + fix.label) } } + + private func confirmation(_ confirm: NoticeFix.Confirmation, perform: @escaping @MainActor () -> Void) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(confirm.question).font(.system(size: 11.5, weight: .medium)) + .fixedSize(horizontal: false, vertical: true) + if let consequence = confirm.consequence { + Text(consequence).font(.system(size: 11)).foregroundColor(KitPalette.dim) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 10) { + Button { + toggleConfirmation(nil) + perform() + } label: { + fixLabel(confirm.confirmLabel, symbol: "checkmark", tint: KitPalette.removed) + } + .buttonStyle(.genHoverPlain()) + .accessibilityIdentifier("notice-confirm-" + confirm.confirmLabel) + Button("Cancel") { toggleConfirmation(nil) } + .font(.system(size: 11)) + .foregroundColor(KitPalette.dim) + .buttonStyle(.genHoverPlain()) + } + } + .padding(8) + .background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(Color.white.opacity(0.05))) + } + + private func fixLabel(_ text: String, symbol: String, tint: Color) -> some View { + Label(text, systemImage: symbol) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(tint) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Capsule().fill(tint.opacity(0.14))) + .contentShape(Capsule()) + } + + private func toggleConfirmation(_ id: String?) { + GenMotion.perform(expanding: id != nil, reduceMotion: reduceMotion) { confirming = id } + } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift index 9e4c3cf14e..28c7b06f8b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ProviderBadge.swift @@ -1,6 +1,7 @@ import SwiftUI -/// An agent's initial in its colour: C (Claude), X (Codex), G (Grok), else the first letter. +/// An agent's mark on a square in its colour (`AIProviderMark`): Claude, Codex, Grok; any other agent keeps its first +/// letter. public struct ProviderBadge: View { let provider: String var size: CGFloat @@ -23,11 +24,7 @@ public struct ProviderBadge: View { public var body: some View { let style = Self.style(for: provider) - Text(verbatim: style.letter) - .font(.system(size: (size * 0.56).rounded(), weight: .bold, design: .rounded)) - .foregroundColor(.black.opacity(0.8)) - .frame(width: size, height: size) - .background(RoundedRectangle(cornerRadius: size * 0.28).fill(style.color)) + AIProviderGlyphBody(provider: provider, letter: style.letter, color: style.color, size: size, style: .filled) .instantTooltip(tooltip ?? provider) .accessibilityLabel(Text(tooltip ?? provider)) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ScrollOverflowHints.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ScrollOverflowHints.swift index cfd23936c5..461e0a483e 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/ScrollOverflowHints.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/ScrollOverflowHints.swift @@ -37,10 +37,17 @@ private struct ScrollOverflowFrameKey: PreferenceKey { public extension View { /// Inside a `ScrollView`, on its content: reports the content frame to `scrollOverflowHints()` on that scroll view. - func scrollOverflowContent() -> some View { - background(GeometryReader { proxy in - Color.clear.preference(key: ScrollOverflowFrameKey.self, value: proxy.frame(in: .named(ScrollOverflowSpace.name))) - }) + /// macOS 15 and later need nothing here: the hints read the scroll view's own geometry. The background reader + /// measured the whole content (every row of a lazy list) on each layout pass, a quarter of the 2026-10-23 widget + /// spins. + @ViewBuilder func scrollOverflowContent() -> some View { + if #available(macOS 15, *) { + self + } else { + background(GeometryReader { proxy in + Color.clear.preference(key: ScrollOverflowFrameKey.self, value: proxy.frame(in: .named(ScrollOverflowSpace.name))) + }) + } } /// On a `ScrollView` whose content carries `scrollOverflowContent()`: a soft fade at each edge that has more @@ -67,6 +74,23 @@ private struct ScrollOverflowHints: ViewModifier { } func body(content: Content) -> some View { + if #available(macOS 15, *) { + masked(content.onScrollGeometryChange(for: ScrollOverflow.self) { geometry in + let visible = geometry.visibleRect + return axis == .vertical + ? ScrollOverflow(above: visible.minY > 0.5, below: visible.maxY < geometry.contentSize.height - 0.5) + : ScrollOverflow(above: visible.minX > 0.5, below: visible.maxX < geometry.contentSize.width - 0.5) + } action: { _, next in + guard next != overflow else { return } + overflow = next + changed?(next) + }) + } else { + masked(measuredByPreference(content)) + } + } + + private func measuredByPreference(_ content: Content) -> some View { content .coordinateSpace(.named(ScrollOverflowSpace.name)) .onPreferenceChange(ScrollOverflowFrameKey.self) { frame in @@ -87,6 +111,10 @@ private struct ScrollOverflowHints: ViewModifier { update() } }) + } + + private func masked(_ view: some View) -> some View { + view .mask { if axis == .vertical { VStack(spacing: 0) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Agents/AgentBlameModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Agents/AgentBlameModel.swift index 5fa5a5f72a..19237e0201 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Agents/AgentBlameModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Agents/AgentBlameModel.swift @@ -31,24 +31,21 @@ public final class AgentBlameModel: ObservableObject { reload() } + /// Asks again. The blame runs once per repository for every file that asked meanwhile (`AgentBlameQueue`). public func reload() { guard let url else { return } ticket += 1 - let ticket = self.ticket - let provider = GenesisKitGT.provider - DispatchQueue.global(qos: .utility).async { - let queries = GitQueries(provider: provider) - guard let root = queries.root(of: url.deletingLastPathComponent()), let path = GitQueries.relative(url, to: root), - let result = try? provider.agentBlame(repo: root.path, paths: [path]) else { return } - let file = DiffFile(id: path, path: path, status: .modified, additions: 0, deletions: 0) - var state = AgentBlameState() - state.merge(result, files: [file], asked: [path]) - DispatchQueue.main.async { [weak self] in - guard let self, self.ticket == ticket, self.url == url else { return } - self.path = path - self.state = state - } - } + AgentBlameQueue.shared.request(.init(model: self, ticket: ticket, url: url)) + } + + /// The part of a shared blame result that is this file's, unless a newer request or another file replaced it. + func apply(_ result: AgentBlameResult, path: String, ticket: Int, url: URL) { + guard self.ticket == ticket, self.url == url else { return } + let file = DiffFile(id: path, path: path, status: .modified, additions: 0, deletions: 0) + var state = AgentBlameState() + state.merge(result, files: [file], asked: [path]) + self.path = path + self.state = state } /// The session that wrote most of these 0-based lines, nil when no agent wrote any of them. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Agents/AgentBlameQueue.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Agents/AgentBlameQueue.swift new file mode 100644 index 0000000000..2b49c7e47f --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Agents/AgentBlameQueue.swift @@ -0,0 +1,109 @@ +import Foundation + +/// One `tools agents blame` per repository at a time, for every file that asked since the last one. Each open note used +/// to start its own blame (and its own `git rev-parse`) whenever its repository refreshed: Genesis Markdown with a day +/// of open notes started 51 to 66 at once, each a full GenesisTools launch, so launchservicesd ran at 69-159% and the +/// load average sat at 380-580 (2026-10-24 19:26, about 9,300 blame launches an hour). +@MainActor +final class AgentBlameQueue { + static let shared = AgentBlameQueue() + + struct Request { + weak var model: AgentBlameModel? + let ticket: Int + let url: URL + } + + /// Requests that arrive within this long of the first one share its run. + var delay: Duration = .milliseconds(300) + /// The least time from one run of a repository to its next. A vault that agents write to changes every few seconds, + /// and each run is a GenesisTools launch that Launch Services checks in. + var minInterval: Duration = .seconds(5) + var provider: () -> GenesisKitGTProvider = { GenesisKitGT.provider } + /// Runs one blame off the main thread; tests replace it. + var run: @Sendable (GenesisKitGTProvider, String, [String]) -> AgentBlameResult? = { provider, repo, paths in + try? provider.agentBlame(repo: repo, paths: paths) + } + /// How many blame runs started, for the tests and the log. + private(set) var runs = 0 + + private var waiting: [String: [String: [Request]]] = [:] + private var running: Set<String> = [] + private var scheduled: Set<String> = [] + private var lastStart: [String: ContinuousClock.Instant] = [:] + /// A folder's repository root, found once: every note of a folder used to run its own `git rev-parse`. + private var roots: [String: URL] = [:] + private var resolving: [String: [Request]] = [:] + + init() {} + + func request(_ request: Request) { + let folder = request.url.deletingLastPathComponent() + let key = folder.standardizedFileURL.path + if let root = roots[key] { + enqueue(request, root: root) + return + } + guard resolving[key] == nil else { + resolving[key]?.append(request) + return + } + resolving[key] = [request] + let provider = provider() + DispatchQueue.global(qos: .utility).async { + let root = GitQueries(provider: provider).root(of: folder) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let requests = self.resolving.removeValue(forKey: key) ?? [] + guard let root else { return } + self.roots[key] = root + for request in requests { self.enqueue(request, root: root) } + } + } + } + + /// Tells the queue a folder's repository root, as a lookup would (tests). + func remember(root: URL, for folder: URL) { + roots[folder.standardizedFileURL.path] = root + } + + private func enqueue(_ request: Request, root: URL) { + guard let path = GitQueries.relative(request.url, to: root) else { return } + waiting[root.path, default: [:]][path, default: []].append(request) + schedule(root.path) + } + + private func schedule(_ repo: String) { + guard !running.contains(repo), scheduled.insert(repo).inserted else { return } + var wait = delay + if let last = lastStart[repo] { wait = max(wait, last + minInterval - .now) } + Task { [weak self, wait] in + try? await Task.sleep(for: wait) + self?.start(repo) + } + } + + private func start(_ repo: String) { + scheduled.remove(repo) + guard let batch = waiting.removeValue(forKey: repo), !batch.isEmpty else { return } + running.insert(repo) + lastStart[repo] = .now + runs += 1 + let paths = batch.keys.sorted() + let provider = provider() + let run = run + DispatchQueue.global(qos: .utility).async { + let result = run(provider, repo, paths) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.running.remove(repo) + if let result { + for (path, requests) in batch { + for request in requests { request.model?.apply(result, path: path, ticket: request.ticket, url: request.url) } + } + } + if self.waiting[repo] != nil { self.schedule(repo) } + } + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitHunks.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitHunks.swift index f4b3fb0a4e..f956bc4b80 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitHunks.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitHunks.swift @@ -41,11 +41,11 @@ public struct GitHunk: Equatable, Sendable, Identifiable { /// The new text's lines this hunk covers, as a closed range (a deletion covers the line it stands before). public var newLines: ClosedRange<Int> { newStart ... max(newStart, newStart + newCount - 1) } - /// "Lines 12–14 changed", "Line 3 added", "2 lines removed before line 8", 1-based for a reader. + /// "Lines 12 to 14 changed", "Line 3 added", "2 lines removed before line 8", 1-based for a reader. public var title: String { let first = newStart + 1 let last = newStart + newCount - let span = newCount == 1 ? "Line \(first)" : "Lines \(first)–\(last)" + let span = newCount == 1 ? "Line \(first)" : "Lines \(first) to \(last)" switch kind { case .added: return "\(span) added" case .modified: return "\(span) changed" diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift index b83e79d324..d44242395a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Model/GitState.swift @@ -104,12 +104,14 @@ public struct GitFileState: Equatable, Sendable { public var hasStaged: Bool { entry?.staged != nil && entry?.staged != .untracked } public var hasUnstaged: Bool { entry?.unstaged != nil } - /// "M · 3 unstaged · 1 staged", "new file", "" when clean. + /// "Modified · 3 lines unstaged · 1 line staged", "new file, not in git", "" when clean. public var summary: String { guard let entry else { return "" } if isUntracked { return "new file, not in git" } - if entry.shown == .conflicted { return "C · conflict" } - var parts = [entry.shown?.letter ?? "M"] + if entry.unstaged == .renamed, entry.staged == nil, let old = entry.oldPath { return "Moved from \(old)" } + if entry.shown == .conflicted { return "In conflict" } + let word = (entry.shown ?? .modified).word + var parts = [word.prefix(1).uppercased() + word.dropFirst()] let unstaged = unstagedAdditions + unstagedDeletions let staged = stagedAdditions + stagedDeletions if hasUnstaged { parts.append(unstaged > 0 ? "\(Plural.count(unstaged, "line")) unstaged" : "unstaged") } @@ -147,8 +149,13 @@ public struct GitRepoStatus: Equatable, Sendable { public var upstream: String? public var ahead = 0 public var behind = 0 - public var entries: [GitStatusEntry] = [] + public var entries: [GitStatusEntry] = [] { + didSet { byPath = Self.index(entries) } + } public var commits: [GitCommit] = [] + /// Path → place in `entries`: the status bar's and the toolbar's marks ask on every render, and a search through a + /// vault's 5,650 entries showed up in the repository panel's sort sample (2026-10-25). + private var byPath: [String: Int] = [:] public init(root: URL, branch: String, upstream: String? = nil, ahead: Int = 0, behind: Int = 0, entries: [GitStatusEntry] = [], commits: [GitCommit] = []) { self.root = root @@ -158,12 +165,22 @@ public struct GitRepoStatus: Equatable, Sendable { self.behind = behind self.entries = entries self.commits = commits + byPath = Self.index(entries) + } + + private static func index(_ entries: [GitStatusEntry]) -> [String: Int] { + Dictionary(entries.enumerated().map { ($1.path, $0) }, uniquingKeysWith: { first, _ in first }) + } + + public static func == (lhs: GitRepoStatus, rhs: GitRepoStatus) -> Bool { + lhs.root == rhs.root && lhs.branch == rhs.branch && lhs.upstream == rhs.upstream && lhs.ahead == rhs.ahead + && lhs.behind == rhs.behind && lhs.entries == rhs.entries && lhs.commits == rhs.commits } /// The entry of a file in this repository, nil when the file is clean or outside it. public func entry(for file: URL) -> GitStatusEntry? { - guard let path = GitQueries.relative(file, to: root) else { return nil } - return entries.first { $0.path == path } + guard let path = GitQueries.relative(file, to: root), let index = byPath[path] else { return nil } + return entries[index] } public var staged: [GitStatusEntry] { entries.filter { $0.staged != nil } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Provider/GenesisKitGTProvider.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Provider/GenesisKitGTProvider.swift index 51f59210f8..1720a79ffb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Provider/GenesisKitGTProvider.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Provider/GenesisKitGTProvider.swift @@ -153,8 +153,10 @@ open class GenesisKitGTCLIProvider: GenesisKitGTProvider { : NSWorkspace.shared.urlForApplication(withBundleIdentifier: Self.genesisToolsBundleID) .flatMap { Bundle(url: $0)?.executablePath } PerfSpans.log("blame open \(source.session.prefix(8)) turn \(source.turn.prefix(8))") + // A click that opens nothing says why beside the pointer, rather than leaving the card as it was. guard let executable else { PerfSpans.log("blame open: GenesisTools.app is not installed") + CopyToast.post(title: "The session cannot open", detail: "GenesisTools.app is not installed", style: .problem) return } let process = Process() @@ -164,6 +166,7 @@ open class GenesisKitGTCLIProvider: GenesisKitGTProvider { try process.run() } catch { PerfSpans.log("blame open failed: \(error)") + CopyToast.post(title: "Could not start GenesisTools", detail: "The session did not open. Try again.", style: .problem) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift index 7733ad4c5c..4c12a52831 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Render/PierreWebDiffRenderer.swift @@ -62,6 +62,12 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag public var view: NSView { webView } public var onEvent: ((DiffRendererEvent) -> Void)? + /// The window shows `shown`, one plain sentence. The method name and the raw error go to the log only. + private func fail(_ detail: String, shown: String = "The diff could not be drawn. Reload it to try again.") { + GenesisKit.log("diff renderer: \(detail)") + onEvent?(.failed(shown)) + } + private var ready = false /// The latest call per method, replayed on every `ready`: the first load, and the reload after the /// web content process dies (memory pressure), which would otherwise leave the pane blank. One @@ -146,6 +152,15 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag webView.evaluateJavaScript("(() => { const host = document.getElementById('review'); if (host) host.scrollTop += \(delta); })()") } + /// Loads the page again, after a failure the user saw: the next `ready` replays the latest calls and the files. + /// A reload by hand also lifts the limit on reloads after the page's process died. + public func reload() { + GenesisKit.log("diff renderer: reload by hand") + recentTerminations = [] + ready = false + webView.reload() + } + public func show(_ files: [DiffFile], fresh: Bool) { self.files = files filesFresh = fresh @@ -178,7 +193,7 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag files: files[batches[index]].map(BridgeFile.init) ) guard let data = try? JSONEncoder().encode(batch), let json = String(data: data, encoding: .utf8) else { - DispatchQueue.main.async { self?.onEvent?(.failed("could not encode a batch of \(batch.files.count) files")) } + DispatchQueue.main.async { self?.fail("could not encode a batch of \(batch.files.count) files") } return } @@ -191,7 +206,7 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag self.webView.evaluateJavaScript(script) { [weak self] _, error in if let error { - self?.onEvent?(.failed("addFiles: \(error.localizedDescription)")) + self?.fail("addFiles: \(error.localizedDescription)") } if index + 1 < batches.count { self?.sendBatch(index + 1, batches, files: files, fresh: fresh, generation: generation, span: span) @@ -254,7 +269,7 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag guard ready, let data = try? JSONEncoder().encode(argument), let json = String(data: data, encoding: .utf8) else { return } webView.evaluateJavaScript("window.genesisDiff.\(method)(\(json));") { [weak self] _, error in if let error { - self?.onEvent?(.failed("\(method): \(error.localizedDescription)")) + self?.fail("\(method): \(error.localizedDescription)") } } } @@ -262,7 +277,7 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag /// The page may still be loading or reloading; its next `ready` message replays the latest calls. private func call<T: Encodable>(_ method: String, _ argument: T) { guard let data = try? JSONEncoder().encode(argument), let json = String(data: data, encoding: .utf8) else { - onEvent?(.failed("could not encode \(method) payload")) + fail("could not encode \(method) payload") return } @@ -272,7 +287,7 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag if ready { webView.evaluateJavaScript(script) { [weak self] _, error in if let error { - self?.onEvent?(.failed("\(method): \(error.localizedDescription)")) + self?.fail("\(method): \(error.localizedDescription)") } } } @@ -303,7 +318,10 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag default: // Every other message decodes without renderer state (DiffModel.swift), so tests pin // the page's literal shapes against the same code. - if let event = DiffRendererEvent(pageMessage: body) { + if case .failed(let detail)? = DiffRendererEvent(pageMessage: body) { + // The page's own error text is for the log; the user reads one plain sentence. + fail("page: \(detail)") + } else if let event = DiffRendererEvent(pageMessage: body) { onEvent?(event) } else { PerfSpans.log("review.page message dropped: \(type) lacks a field its receiver needs") @@ -318,7 +336,7 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag recentTerminations = recentTerminations.filter { now.timeIntervalSince($0) < 60 } + [now] guard recentTerminations.count <= 3 else { FileHandle.standardError.write(Data("review renderer: web content process ended \(recentTerminations.count) times in 60 s, not reloading\n".utf8)) - onEvent?(.failed("diff viewer crashed repeatedly; the diff may be too large")) + fail("diff viewer crashed repeatedly", shown: "The diff viewer stopped several times in a row. The diff may be too large to show.") return } @@ -334,11 +352,11 @@ public final class PierreWebDiffRenderer: NSObject, DiffRenderer, WKScriptMessag } public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - onEvent?(.failed("viewer navigation failed: \(error.localizedDescription)")) + fail("viewer navigation failed: \(error.localizedDescription)") } public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - onEvent?(.failed("viewer did not load: \(error.localizedDescription)")) + fail("viewer did not load: \(error.localizedDescription)") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitFailure.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitFailure.swift new file mode 100644 index 0000000000..3f1b0bab50 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitFailure.swift @@ -0,0 +1,71 @@ +// +// GitFailure.swift +// GenesisKit +// +// A failed git read as the user reads it: one sentence that says what happened and what to do, and the raw text +// (git's stderr, the command) for a Details disclosure. The git components show `message`; `detail` never stands in +// for it. +// + +import Foundation + +public struct GitFailure: Equatable, Sendable { + /// One plain sentence. + public var message: String + /// The raw error, for Details; nil when it says no more than `message`. + public var detail: String? + + public init(message: String, detail: String? = nil) { + self.message = message + self.detail = detail + } + + /// `error` mapped to a sentence; `fallback` is the sentence for a cause this does not know. + public init(_ error: Error, fallback: String) { + if case ReviewError.notRepository = error { + self.init(message: "\(error)") + return + } + let raw = "\(error)".trimmed + let message = Self.sentence(for: raw) ?? fallback + self.init(message: message, detail: raw.isEmpty || raw == message ? nil : raw) + } + + /// The sentence for a known cause in git's or the provider's raw text, nil for an unknown one. + static func sentence(for raw: String) -> String? { + let text = raw.lowercased() + func has(_ needles: String...) -> Bool { needles.contains { text.contains($0) } } + if has("not a git repository") { + return "This folder is not in a git repository." + } + if has("no merge base", "shares no history") { + return "This branch shares no history with its base branch, so there is nothing to compare." + } + if has("index.lock", "another git process") { + return "Another git command is using this repository. Try again in a moment." + } + if has("is not in this file's history") { + return "That commit did not change this file." + } + if has("fetch the pr branch", "fetch the base branch") { + return "That commit is not in this repository. Fetch its branch, then try again." + } + if has("exists on disk, but not in", "does not exist in", "invalid object name", "unknown revision", "bad revision", "invalid commit") { + return "That version of the file is not in git." + } + if has("launch path not accessible", "xcrun: error", "no developer tools", "command line tools") { + return "Git could not start. Check that git or the Xcode command line tools are installed." + } + if text.hasPrefix("tools ") && text.contains(" exited ") { + return "GenesisTools did not answer. Try again." + } + return nil + } +} + +extension GitFailure { + /// A failure whose sentence the thrower already knows, through code that throws. + struct Thrown: Error { + let failure: GitFailure + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitFileDiffModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitFileDiffModel.swift index f51ace2ee7..34429d1eac 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitFileDiffModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitFileDiffModel.swift @@ -27,7 +27,17 @@ public final class GitFileDiffModel: ObservableObject { @Published public private(set) var history: [GitCommit] = [] /// The commit `lastCommit` shows: the one picked, else the newest. @Published public private(set) var commit: GitCommit? + /// The last failure in full: `phase` holds its sentence, this adds the raw text for Details. + @Published public private(set) var failure: GitFailure? private var ticket = 0 + /// What the shown result answers (file, scope, pick, rename, compare). A reload of the same question keeps the result on + /// screen until the new one lands: setting `.loading` each time flashed "No unstaged changes in this file." away and + /// back on every status refresh of a busy repository (Martin, 2026-10-25: "flashes every few seconds"). + private var shownRequest: String? + /// The last load, for `retry`. + private var lastLoad: (() -> Void)? + /// How many commits of a file's history the chooser lists. + public nonisolated static let historyLimit = 30 var makeDiff: @Sendable (String, String?, String?) -> DiffFile = { GitFileDiffModel.diffFile(path: $0, old: $1, new: $2) } public init() {} @@ -37,28 +47,37 @@ public final class GitFileDiffModel: ObservableObject { load(url, workingText: text, scope: scope, commit: pick) } - /// nil is a deleted working file; an empty String is a present empty file. - func load(_ url: URL, workingText text: String?, scope: GitFileScope, commit pick: String? = nil) { + /// nil is a deleted working file; an empty String is a present empty file. `renamedFrom` is the old path of a file + /// moved outside git (the repository status pairs it): its old side is read there. + func load(_ url: URL, workingText text: String?, scope: GitFileScope, commit pick: String? = nil, renamedFrom: String? = nil) { + lastLoad = { [weak self] in self?.load(url, workingText: text, scope: scope, commit: pick, renamedFrom: renamedFrom) } ticket += 1 let ticket = self.ticket - if file == nil { phase = .loading } + let request = "load|\(url.path)|\(scope.rawValue)|\(pick ?? "")|\(renamedFrom ?? "")" + if file == nil, request != shownRequest { phase = .loading } let provider = GenesisKitGT.provider let makeDiff = self.makeDiff DispatchQueue.global(qos: .userInitiated).async { let span = PerfSpans.begin("git.fileDiff", "\(scope.rawValue) \(url.lastPathComponent)") let result = Result { () throws -> (Sides, DiffFile?) in - let sides = try Self.sides(url, text: text, scope: scope, pick: pick, provider: provider) - return (sides, sides.old == sides.new ? nil : makeDiff(sides.path, sides.old, sides.new)) + let sides = try Self.sides(url, text: text, scope: scope, pick: pick, provider: provider, renamedFrom: renamedFrom) + guard sides.old != sides.new else { return (sides, nil) } + var file = makeDiff(sides.path, sides.old, sides.new) + if let oldPath = sides.oldPath, file.status == .modified { + file.oldPath = oldPath + file.status = .renamed + } + return (sides, file) } span.end() DispatchQueue.main.async { [weak self] in guard let self, self.ticket == ticket else { return } switch result { case .failure(let error): - self.file = nil - self.oldText = nil - self.phase = .failed("\(error)") + self.fail(error) case .success(let (sides, file)): + self.failure = nil + self.shownRequest = request self.history = sides.history self.commit = sides.commit self.oldText = sides.old @@ -80,9 +99,11 @@ public final class GitFileDiffModel: ObservableObject { } func loadCompare(_ url: URL, workingText text: String?, old: GitCommit, new: GitCommit?) { + lastLoad = { [weak self] in self?.loadCompare(url, workingText: text, old: old, new: new) } ticket += 1 let ticket = self.ticket - if file == nil { phase = .loading } + let request = "compare|\(url.path)|\(old.sha)|\(new?.sha ?? "")" + if file == nil, request != shownRequest { phase = .loading } let provider = GenesisKitGT.provider let makeDiff = self.makeDiff DispatchQueue.global(qos: .userInitiated).async { @@ -105,10 +126,10 @@ public final class GitFileDiffModel: ObservableObject { guard let self, self.ticket == ticket else { return } switch result { case .failure(let error): - self.file = nil - self.oldText = nil - self.phase = .failed("\(error)") + self.fail(error) case .success(let (sides, file)): + self.failure = nil + self.shownRequest = request self.oldText = sides.old if sides.old == sides.new { self.file = nil @@ -122,6 +143,22 @@ public final class GitFileDiffModel: ObservableObject { } } + /// Runs the last load again: the Try again of a failed diff. + public func retry() { + if file == nil { phase = .loading } + lastLoad?() + } + + private func fail(_ error: Error) { + shownRequest = nil + let failure = (error as? GitFailure.Thrown)?.failure + ?? GitFailure(error, fallback: "Git could not read this file's versions. Try again.") + file = nil + oldText = nil + self.failure = failure + phase = .failed(failure.message) + } + struct Sides { var path: String var old: String? @@ -129,27 +166,42 @@ public final class GitFileDiffModel: ObservableObject { var history: [GitCommit] var commit: GitCommit? var emptyReason: String + /// The path the old side was read at, when it differs from `path` (a move outside git). + var oldPath: String? = nil } - nonisolated static func sides(_ url: URL, text: String?, scope: GitFileScope, pick: String?, provider: GenesisKitGTProvider) throws -> Sides { + nonisolated static func sides(_ url: URL, text: String?, scope: GitFileScope, pick: String?, provider: GenesisKitGTProvider, + renamedFrom: String? = nil) throws -> Sides { let queries = GitQueries(provider: provider) guard let root = queries.root(of: url.deletingLastPathComponent()), let path = GitQueries.relative(url, to: root) else { throw ReviewError.notRepository(url.deletingLastPathComponent().path) } - let history = (try? provider.history(of: url, limit: 30)) ?? [] + // A failed history read is not "no commits": it only fails the scope that needs the history. + let historyRead = Result { try provider.history(of: url, limit: Self.historyLimit) } + let history = (try? historyRead.get()) ?? [] func at(_ revision: GitRevision, path: String = path) throws -> String? { try queries.contents(path: path, in: root, at: revision) } + /// The working file's old side: at its own path, or for a file moved outside git at the path it came from. + func before(_ revision: GitRevision) throws -> String? { + if let text = try at(revision) { return text } + return try renamedFrom.flatMap { try at(revision, path: $0) } + } + let moved = renamedFrom.map { "Moved from \($0) without changes." } switch scope { case .unstaged: - let index = try at(.index) - return Sides(path: path, old: index, new: text, history: history, commit: nil, emptyReason: "No unstaged changes in this file.") + return Sides(path: path, old: try before(.index), new: text, history: history, commit: nil, + emptyReason: moved ?? "No unstaged changes in this file.", oldPath: renamedFrom) case .staged: return Sides(path: path, old: try at(.head), new: try at(.index), history: history, commit: nil, emptyReason: "Nothing of this file is staged.") case .uncommitted: - return Sides(path: path, old: try at(.head), new: text, history: history, commit: nil, emptyReason: "No changes since the last commit.") + return Sides(path: path, old: try before(.head), new: text, history: history, commit: nil, + emptyReason: moved ?? "No changes since the last commit.", oldPath: renamedFrom) case .lastCommit: let picked = try pick.map { try queries.pickedCommit($0, of: url, in: root) } + if picked == nil, case .failure(let error) = historyRead { + throw GitFailure.Thrown(failure: GitFailure(message: "Could not read this file's history. Try again.", detail: "\(error)")) + } guard let commit = picked ?? history.first else { return Sides(path: path, old: nil, new: nil, history: history, commit: nil, emptyReason: "This file has no commits yet.") } @@ -165,14 +217,15 @@ public final class GitFileDiffModel: ObservableObject { throw ReviewError.git("no merge base with \(base)") } let sha = String(decoding: mergeBase.stdout, as: UTF8.self).trimmed - return Sides(path: path, old: try at(.commit(sha)), new: text, history: history, commit: nil, emptyReason: "This branch has not changed this file since \(base).") + return Sides(path: path, old: try before(.commit(sha)), new: text, history: history, commit: nil, + emptyReason: moved ?? "This branch has not changed this file since \(base).", oldPath: renamedFrom) } } - nonisolated static func diffFile(path: String, old: String?, new: String?) -> DiffFile { + nonisolated static func diffFile(path: String, old: String?, new: String?, oldPath: String? = nil) -> DiffFile { let counts = GitWorkingTreeSource.lineCounts(old: old ?? "", new: new ?? "") - let status: DiffFile.Status = old == nil ? .added : (new == nil ? .deleted : .modified) - return DiffFile(id: path, path: path, oldPath: nil, status: status, additions: counts.additions, deletions: counts.deletions, + let status: DiffFile.Status = old == nil ? .added : (new == nil ? .deleted : (oldPath == nil ? .modified : .renamed)) + return DiffFile(id: path, path: path, oldPath: oldPath, status: status, additions: counts.additions, deletions: counts.deletions, oldContents: old, newContents: new, skipped: nil) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitQueries.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitQueries.swift index 9f09a7e518..607fd9837b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitQueries.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitQueries.swift @@ -14,12 +14,26 @@ struct GitQueries { /// The repository root holding `folder`, or nil outside a repository. func root(of folder: URL) -> URL? { - guard let result = try? provider.git(["rev-parse", "--show-toplevel"], in: folder, input: nil, gitDirectory: nil, environment: nil), + guard let result = try? provider.git(["rev-parse", "--show-toplevel"], in: Self.existingFolder(folder), input: nil, gitDirectory: nil, environment: nil), result.status == 0 else { return nil } let path = String(decoding: result.stdout, as: UTF8.self).trimmed return path.isEmpty ? nil : URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL } + /// `folder`, or its nearest parent that exists. A deleted file's folder can be gone too (a folder moved away), + /// and git cannot start in it: "This file is not in a git repository" for every file of a moved folder (Martin, + /// 2026-10-25). + static func existingFolder(_ folder: URL) -> URL { + var current = folder.standardizedFileURL + var isFolder: ObjCBool = false + while !(FileManager.default.fileExists(atPath: current.path, isDirectory: &isFolder) && isFolder.boolValue) { + let parent = current.deletingLastPathComponent() + guard parent.path != current.path else { break } + current = parent + } + return current + } + /// `file` relative to `root`, through real paths (`/var` is `/private/var`). static func relative(_ file: URL, to root: URL) -> String? { let real = GitWorkingTreeSource.realPath(file.standardizedFileURL.path) @@ -39,6 +53,11 @@ struct GitQueries { guard let root = root(of: file.deletingLastPathComponent()), let path = Self.relative(file, to: root) else { return nil } let status = Self.parseStatus(try run(["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all", "--", path], in: root)) var state = GitFileState(repoRoot: root, path: path, branch: status.branch, entry: status.entries.first { $0.path == path }) + // An untracked file may be a file moved outside git: the status bar says where it came from, as the repository + // panel does (GitWorktreeRenames). + if state.isUntracked, let from = movedFrom(path, in: root) { + state.entry = GitStatusEntry(path: path, oldPath: from, unstaged: .renamed) + } if state.hasUnstaged, !state.isUntracked, let counts = Self.parseNumstat(try run(["--no-optional-locks", "diff", "--numstat", "--", path], in: root)) { state.unstagedAdditions = counts.additions @@ -51,6 +70,14 @@ struct GitQueries { return state } + /// The deleted file an untracked `path` is an exact copy of, if any: only tracked entries are listed (no untracked + /// walk), and the pairing hashes one file at most. + func movedFrom(_ path: String, in root: URL) -> String? { + guard let output = try? run(["--no-optional-locks", "status", "--porcelain=v2", "-z", "--untracked-files=no"], in: root) else { return nil } + let deleted = Self.parseStatus(output).entries.filter { $0.unstaged == .deleted }.map(\.path) + return GitWorktreeRenames.find(in: root, deleted: deleted, untracked: [path], revision: "", provider: provider).first?.old + } + func contents(of file: URL, at revision: GitRevision) throws -> String? { guard let root = root(of: file.deletingLastPathComponent()), let path = Self.relative(file, to: root) else { return nil } return try contents(path: path, in: root, at: revision) @@ -91,7 +118,7 @@ struct GitQueries { let status = Self.parseStatus(try run(["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], in: root)) let log = commits > 0 ? (try? run(["log", "-n", String(commits), "--format=\u{1e}%H\u{1f}%h\u{1f}%s\u{1f}%an\u{1f}%ct"], in: root)) ?? "" : "" return GitRepoStatus(root: root, branch: status.branch, upstream: status.upstream, ahead: status.ahead, behind: status.behind, - entries: status.entries, commits: Self.parseLog(log)) + entries: GitWorktreeRenames.paired(status.entries, in: root, provider: provider), commits: Self.parseLog(log)) } // MARK: Parsing diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitRepos.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitRepos.swift index 00b775ef6b..f3677bced7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitRepos.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitRepos.swift @@ -4,8 +4,9 @@ // // The repositories the git components show, one status per repository: one `git status` serves every tab, the // status bar and the file tree. A repository refreshes a quarter second after its git folder changes (the index -// and HEAD: a stage, a commit, a checkout) or a host says one of its files was saved. Idle, it costs nothing: -// file events, no timer. +// and HEAD: a stage, a commit, a checkout) or a host says one of its files was saved, and at most every +// `workingTreeInterval` while files change in its working tree (another program's edit, an agent's new file). +// Idle, it costs nothing: file events, no timer. // import Combine @@ -82,16 +83,40 @@ public final class GitRepos: ObservableObject { public final class GitRepoModel: ObservableObject { public let root: URL @Published public private(set) var status: GitRepoStatus? - /// Every completed refresh invalidates followers, even when HEAD or index changes leave status equal. + /// The last status read failed: `status` keeps the last good answer, and the panel says the refresh failed. + @Published public private(set) var failure: GitFailure? + /// Bumped by a refresh that changed the status, or that git's own folder, a save or a retry asked for (HEAD or the + /// index can change while the status stays equal): followers reload on it. @Published public private(set) var generation = 0 + /// Bumped by every completed read, also one that working-tree events started and that found the status equal: the + /// repository panel re-reads its file dates on it (an edited file stays "modified" but moves up in Last update). + @Published public private(set) var reads = 0 private var watch: DispatchSourceFileSystemObject? + private var workingTree: DirectoryWatcher? private var pending: DispatchWorkItem? private var loading = false private var again = false + /// A read asked for by git's own folder, a save or a retry wakes the followers even when the status comes back + /// equal (HEAD moved, a commit landed). A read started by working-tree file events wakes them only for a changed + /// status: each wake reloads every open note's agent blame and the panel's lists. + private var wake = false + /// The wake a read queued behind the running one asked for. + private var againWake = false + private var lastLoad = Date.distantPast + + /// The least time between two refreshes that working-tree file events start. Before it, an edit by another + /// program never reached the panel: the status and its dates stayed as the last git event left them, hours old + /// (Martin, 2026-10-25: "Last update" showed a note from 5 h ago while agents wrote files every minute). + nonisolated(unsafe) public static var workingTreeInterval: TimeInterval = 3 init(root: URL) { self.root = root load() + let gitFolder = root.appendingPathComponent(".git").path + workingTree = DirectoryWatcher(paths: [root.path], latency: 1, fileEvents: false, + accepts: { $0 != gitFolder && !$0.hasPrefix(gitFolder + "/") }) { [weak self] _ in + self?.workingTreeChanged() + } let provider = GenesisKitGT.provider DispatchQueue.global(qos: .utility).async { [root] in let result = try? provider.git(["rev-parse", "--absolute-git-dir"], in: root, input: nil, gitDirectory: nil, environment: nil) @@ -104,38 +129,79 @@ public final class GitRepoModel: ObservableObject { deinit { watch?.cancel() + workingTree?.stop() + } + + /// Reads the status now: the Try again of a failed read. + public func retry() { + pending?.cancel() + pending = nil + load() + } + + /// Files changed in the working tree: a refresh, but no sooner than `workingTreeInterval` after the last one, so + /// an agent writing a file a second costs one status every few seconds. + private func workingTreeChanged() { + guard pending == nil else { return } + let wait = max(0.25, Self.workingTreeInterval - Date().timeIntervalSince(lastLoad)) + let work = DispatchWorkItem { [weak self] in + self?.pending = nil + self?.load(wake: false) + } + pending = work + DispatchQueue.main.asyncAfter(deadline: .now() + wait, execute: work) } /// A refresh a quarter second after the last call: a commit writes the index several times. public func refresh() { pending?.cancel() - let work = DispatchWorkItem { [weak self] in self?.load() } + let work = DispatchWorkItem { [weak self] in + self?.pending = nil + self?.load() + } pending = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) } - private func load() { + private func load(wake: Bool = true) { guard !loading else { again = true + againWake = againWake || wake return } + self.wake = wake loading = true + lastLoad = Date() let provider = GenesisKitGT.provider let root = self.root DispatchQueue.global(qos: .utility).async { let span = PerfSpans.begin("git.repoStatus", root.lastPathComponent) - let status = try? provider.repoStatus(root, commits: 0) + let read = Result { try provider.repoStatus(root, commits: 0) } span.end() DispatchQueue.main.async { [weak self] in guard let self else { return } self.loading = false - if self.status != status { - self.status = status + switch read { + case .success(let status): + if self.failure != nil { self.failure = nil } + if self.status != status { + self.status = status + self.wake = true + } + case .failure(let error): + // The last good list stays: a locked index or a slow disk must not blank the panel. + GenesisKit.log("git: status of \(root.lastPathComponent) failed: \(error)") + self.failure = GitFailure(error, fallback: "Could not read this repository's status. Try again.") + } + if self.wake || self.failure != nil { + self.generation += 1 } - self.generation += 1 + self.reads += 1 if self.again { + let wake = self.againWake self.again = false - self.load() + self.againWake = false + self.load(wake: wake) } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift index 4ea42f6599..2960822e7d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorkingTreeSource.swift @@ -307,9 +307,21 @@ public struct GitWorkingTreeSource { if plan.untracked { let untracked = (try? gitData(["ls-files", "--full-name", "--others", "--exclude-standard", "-z"])) ?? Data() let known = Set(entries.map(\.path)) - entries += untracked.split(separator: 0).map { String(decoding: $0, as: UTF8.self) } - .filter { !known.contains($0) } - .map { StatusEntry(path: $0, oldPath: nil, status: .added) } + let added = untracked.split(separator: 0).map { String(decoding: $0, as: UTF8.self) }.filter { !known.contains($0) } + entries += added.map { StatusEntry(path: $0, oldPath: nil, status: .added) } + // A folder moved outside git: its deleted files and their untracked twins become renames, the same pairs the + // repository panel shows (GitWorktreeRenames). + let pairs = GitWorktreeRenames.find(in: repo, deleted: entries.filter { $0.status == .deleted }.map(\.path), + untracked: added, revision: plan.old, provider: GenesisKitGT.provider) + if !pairs.isEmpty { + let newByOld = Dictionary(pairs.map { ($0.old, $0.new) }, uniquingKeysWith: { first, _ in first }) + let news = Set(pairs.map(\.new)) + entries = entries.compactMap { entry in + if entry.status == .added, news.contains(entry.path) { return nil } + guard entry.status == .deleted, let new = newByOld[entry.path] else { return entry } + return StatusEntry(path: new, oldPath: entry.path, status: .renamed) + } + } } let counts = parseNumstat((try? gitData(["diff", "--numstat", "-z", "--find-renames"] + plan.diffArgs)) ?? Data()) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorktreeRenames.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorktreeRenames.swift new file mode 100644 index 0000000000..99e2571772 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Source/GitWorktreeRenames.swift @@ -0,0 +1,199 @@ +// +// GitWorktreeRenames.swift +// GenesisKit +// +// Moves git does not report: a folder moved in Finder or by an agent, without `git mv`. Git pairs a rename only +// when both sides are in the index, so the old paths read as deleted and the new ones as untracked (Martin, +// 2026-10-25: 147 files of a moved data folder listed as "D", each opening as "not in a git repository"). A +// deleted file whose exact content is now an untracked file is that move. The rule is shared with the CLI +// (`src/utils/git/worktree-renames.ts`), and `GenesisKitFixtures/Git/contract-worktree-renames.json` holds the cases both +// sides pass. +// +// Cost on the vault (650 deleted, 4,834 untracked): one `cat-file --batch-check` for the deleted blobs' sizes, +// a `stat` per untracked file, and `hash-object` only for files whose size equals a deleted blob's: 46 ms, where +// git's own detection through a scratch index (`add -N`, then `status`) took 3.9 s. +// + +import Foundation + +public enum GitWorktreeRenames { + public struct Side: Equatable, Sendable { + public var path: String + public var oid: String + + public init(path: String, oid: String) { + self.path = path + self.oid = oid + } + } + + public struct Pair: Equatable, Sendable { + public var old: String + public var new: String + + public init(old: String, new: String) { + self.old = old + self.new = new + } + } + + /// The empty blob in SHA-1 and SHA-256 repositories: every empty file shares it, so it proves no move. + static let emptyBlobs: Set<String> = [ + "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813", + ] + + /// Pairs each deleted file with an untracked file of the same blob. Among several, the one whose path is most + /// alike wins: the same file name first, then the most equal trailing folders, then the most equal leading ones, + /// then byte order. Deleted files are taken in byte order; each untracked file pairs at most once. + public static func pair(deleted: [Side], untracked: [Side]) -> [Pair] { + var byOid: [String: [String]] = [:] + for side in untracked where !emptyBlobs.contains(side.oid) { + byOid[side.oid, default: []].append(side.path) + } + var taken = Set<String>() + var pairs: [Pair] = [] + for side in deleted.sorted(by: { Array($0.path.utf8).lexicographicallyPrecedes(Array($1.path.utf8)) }) + where !emptyBlobs.contains(side.oid) { + guard let candidates = byOid[side.oid] else { continue } + let old = side.path.split(separator: "/", omittingEmptySubsequences: false) + var best: (path: String, score: [Int])? + for path in candidates where !taken.contains(path) { + let new = path.split(separator: "/", omittingEmptySubsequences: false) + let score = [old.last == new.last ? 1 : 0, commonCount(old.reversed(), new.reversed()), commonCount(old, new)] + if let current = best { + if score.lexicographicallyPrecedes(current.score) { continue } + if score == current.score, !Array(path.utf8).lexicographicallyPrecedes(Array(current.path.utf8)) { continue } + } + best = (path, score) + } + if let best { + taken.insert(best.path) + pairs.append(Pair(old: side.path, new: best.path)) + } + } + return pairs + } + + /// `entries` with each pair's deleted entry turned into a rename at its new path, and that untracked path dropped. + public static func apply(_ pairs: [Pair], to entries: [GitStatusEntry]) -> [GitStatusEntry] { + guard !pairs.isEmpty else { return entries } + let newByOld = Dictionary(pairs.map { ($0.old, $0.new) }, uniquingKeysWith: { first, _ in first }) + let news = Set(pairs.map(\.new)) + return entries.compactMap { entry in + if entry.unstaged == .untracked, news.contains(entry.path) { return nil } + guard entry.unstaged == .deleted, let new = newByOld[entry.path] else { return entry } + var moved = entry + moved.oldPath = entry.path + moved.path = new + moved.unstaged = .renamed + return moved + } + } + + /// A repository status with its working-tree moves paired: the deleted side is the index's version. + static func paired(_ entries: [GitStatusEntry], in root: URL, provider: GenesisKitGTProvider) -> [GitStatusEntry] { + let pairs = find(in: root, + deleted: entries.filter { $0.unstaged == .deleted }.map(\.path), + untracked: entries.filter { $0.unstaged == .untracked }.map(\.path), + revision: "", provider: provider) + return apply(pairs, to: entries) + } + + private static func commonCount<A: Sequence, B: Sequence>(_ a: A, _ b: B) -> Int where A.Element == Substring, B.Element == Substring { + var count = 0 + for (x, y) in zip(a, b) { + guard x == y else { break } + count += 1 + } + return count + } + + /// The moves among `deleted` (repo-relative paths whose last version is at `revision`: "HEAD", "" for the index, + /// or a commit) and `untracked` (repo-relative paths on disk). Reads only what can match: blob sizes in one + /// `cat-file`, a `stat` per untracked file, and `hash-object` for the files of a matching size. + public static func find(in root: URL, deleted: [String], untracked: [String], revision: String, + provider: GenesisKitGTProvider) -> [Pair] { + guard !deleted.isEmpty, !untracked.isEmpty else { return [] } + let span = PerfSpans.begin("git.worktreeRenames", "\(deleted.count) deleted \(untracked.count) untracked") + defer { span.end() } + // A path with a newline cannot travel through the line-based batch commands; it stays as git reports it. + let asked = deleted.filter { !$0.contains("\n") } + let input = Data(asked.map { "\(revision):\($0)\n" }.joined().utf8) + guard let checked = try? provider.git(["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], + in: root, input: input, gitDirectory: nil, environment: nil), + checked.status == 0 else { return [] } + var deletedSides: [Side] = [] + var sizes = Set<Int>() + for (path, line) in zip(asked, String(decoding: checked.stdout, as: UTF8.self).split(separator: "\n", omittingEmptySubsequences: false)) { + let parts = line.split(separator: " ") + guard parts.count == 3, parts[1] == "blob", let size = Int(parts[2]), size > 0 else { continue } + deletedSides.append(Side(path: path, oid: String(parts[0]))) + sizes.insert(size) + } + guard !deletedSides.isEmpty else { return [] } + var known: [Side] = [] + var unknown: [(path: String, identity: HashCache.Identity)] = [] + var info = stat() + let base = root.path + "/" + for path in untracked where !path.contains("\n") { + guard lstat(base + path, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, sizes.contains(Int(info.st_size)) else { continue } + let identity = HashCache.Identity(inode: UInt64(info.st_ino), size: Int64(info.st_size), + mtime: Int64(info.st_mtimespec.tv_sec) * 1_000_000_000 + Int64(info.st_mtimespec.tv_nsec), + ctime: Int64(info.st_ctimespec.tv_sec) * 1_000_000_000 + Int64(info.st_ctimespec.tv_nsec)) + if let oid = hashes.oid(base + path, identity) { + known.append(Side(path: path, oid: oid)) + } else { + unknown.append((path, identity)) + } + } + if !unknown.isEmpty { + guard let hashed = try? provider.git(["hash-object", "--stdin-paths"], in: root, + input: Data(unknown.map { $0.path + "\n" }.joined().utf8), gitDirectory: nil, environment: nil), + hashed.status == 0 else { return [] } + let oids = String(decoding: hashed.stdout, as: UTF8.self).split(separator: "\n").map(String.init) + guard oids.count == unknown.count else { return [] } + for (file, oid) in zip(unknown, oids) { + hashes.store(base + file.path, file.identity, oid) + known.append(Side(path: file.path, oid: oid)) + } + } + return pair(deleted: deletedSides, untracked: known) + } + + /// Blob ids of untracked files by file identity (inode, size, mtime and ctime to the nanosecond): a file that did + /// not change is not hashed again on the next status read. The ctime catches a rewrite that kept the size and put + /// the mtime back (performance skill, caching-and-freshness.md: the mtime alone is not enough). Shared by every repository, bounded. + static let hashes = HashCache(limit: 50_000) + + final class HashCache: @unchecked Sendable { + struct Identity: Equatable { + let inode: UInt64 + let size: Int64 + let mtime: Int64 + let ctime: Int64 + } + private let lock = NSLock() + private var entries: [String: (identity: Identity, oid: String)] = [:] + private let limit: Int + + init(limit: Int) { + self.limit = limit + } + + func oid(_ path: String, _ identity: Identity) -> String? { + lock.lock() + defer { lock.unlock() } + guard let entry = entries[path], entry.identity == identity else { return nil } + return entry.oid + } + + func store(_ path: String, _ identity: Identity, _ oid: String) { + lock.lock() + defer { lock.unlock() } + // Past the bound the cache starts over: simpler than an order list, and a refill costs one hash pass. + if entries.count >= limit, entries[path] == nil { entries.removeAll(keepingCapacity: true) } + entries[path] = (identity, oid) + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift index b641b0c0cb..80c0f165c5 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift @@ -10,6 +10,7 @@ // `git log --no-walk` for all of its commits. // +import AppKit import SwiftUI /// How much a commit changed: every file, and the chooser's own file when it names one. @@ -218,10 +219,19 @@ public extension GitCommitPicker where Label == GitPickerChip { public extension View { /// The commit list as a popover from this view. Its rows are built when it opens, never while it is closed. + /// With `onPickRange`, Shift with the arrows or a Shift-click picks several commits in a row: the callback gets + /// them in list order, every commit between the two ends included, even ones the filter hides. func gitCommitPopover(isPresented: Binding<Bool>, choices: [GitCommitChoice], selection: String?, title: String? = nil, - details: GitCommitDetailsLoader? = nil, onPick: @escaping (GitCommitChoice) -> Void) -> some View { + details: GitCommitDetailsLoader? = nil, onPick: @escaping (GitCommitChoice) -> Void, + onPickRange: (([GitCommitChoice]) -> Void)? = nil) -> some View { popover(isPresented: isPresented, arrowEdge: .bottom) { - GitCommitPickerList(choices: choices, selection: selection, title: title, details: details) { choice in + GitCommitPickerList(choices: choices, selection: selection, title: title, details: details, + pickRange: onPickRange.map { pickRange in + { range in + isPresented.wrappedValue = false + pickRange(range) + } + }) { choice in isPresented.wrappedValue = false onPick(choice) } @@ -246,6 +256,8 @@ struct GitCommitPickerState { private(set) var rows: [GitCommitChoice] = [] /// The row the keyboard or the pointer is on: Return picks it. var highlighted: String? + /// Where a Shift-extended range started; nil while one commit is chosen. + var anchor: String? init(choices: [GitCommitChoice], selection: String?, query: String = "") { self.choices = choices @@ -254,8 +266,35 @@ struct GitCommitPickerState { highlighted = selection.flatMap { sha in rows.first { $0.sha == sha }?.id } ?? rows.first?.id } - /// Moves the highlight `step` rows, stopping at either end; from no highlight, down starts at the top. + /// Moves the highlight `step` rows, stopping at either end; from no highlight, down starts at the top. A plain move + /// ends a range. mutating func move(_ step: Int) { + anchor = nil + advance(step) + } + + /// Shift with an arrow: the range grows from where it started. + mutating func extend(_ step: Int) { + if anchor == nil { anchor = highlighted } + advance(step) + } + + /// Shift-click: the range runs from where it started (or the highlighted row) to `id`. + mutating func extend(to id: String) { + if anchor == nil { anchor = highlighted } + highlighted = id + } + + /// The commits of a range, in the list's order and with every commit between its ends (also the ones the filter + /// hides, so the diff never skips one); nil while one commit is chosen. + var range: [GitCommitChoice]? { + guard let anchor, let highlighted, anchor != highlighted, + let start = choices.firstIndex(where: { $0.id == anchor }), + let end = choices.firstIndex(where: { $0.id == highlighted }) else { return nil } + return Array(choices[min(start, end)...max(start, end)]) + } + + private mutating func advance(_ step: Int) { guard !rows.isEmpty else { return } let index = highlighted.flatMap { id in rows.firstIndex { $0.id == id } } ?? (step > 0 ? -1 : rows.count) highlighted = rows[min(max(index + step, 0), rows.count - 1)].id @@ -280,6 +319,8 @@ struct GitCommitPickerList: View { let selection: String? let title: String? let details: GitCommitDetailsLoader? + /// Several commits in a row; nil offers one commit only. + let pickRange: (([GitCommitChoice]) -> Void)? let pick: (GitCommitChoice) -> Void @State private var state: GitCommitPickerState @@ -296,10 +337,11 @@ struct GitCommitPickerList: View { static let page = 8 init(choices: [GitCommitChoice], selection: String?, title: String?, details: GitCommitDetailsLoader?, query: String = "", - pick: @escaping (GitCommitChoice) -> Void) { + pickRange: (([GitCommitChoice]) -> Void)? = nil, pick: @escaping (GitCommitChoice) -> Void) { self.selection = selection self.title = title self.details = details + self.pickRange = pickRange self.pick = pick _state = State(initialValue: GitCommitPickerState(choices: choices, selection: selection, query: query)) } @@ -344,11 +386,9 @@ struct GitCommitPickerList: View { .textFieldStyle(.plain) .font(.system(size: 12.5)) .focused($searchFocused) - .onSubmit { - if let choice = state.highlightedChoice { pick(choice) } - } - .onKeyPress(.upArrow) { move(-1) } - .onKeyPress(.downArrow) { move(1) } + .onSubmit { submit() } + .onKeyPress(.upArrow, phases: [.down, .repeat]) { press in move(-1, extending: press.modifiers.contains(.shift)) } + .onKeyPress(.downArrow, phases: [.down, .repeat]) { press in move(1, extending: press.modifiers.contains(.shift)) } .onKeyPress(.pageUp) { move(-Self.page) } .onKeyPress(.pageDown) { move(Self.page) } .accessibilityLabel("Filter commits") @@ -374,13 +414,17 @@ struct GitCommitPickerList: View { ScrollViewReader { proxy in FittedScroll(maxHeight: Self.maxListHeight) { LazyVStack(spacing: 1) { + let inRange = Set(state.range?.map(\.id) ?? []) ForEach(state.rows) { choice in - GitCommitPickerRow(choice: choice, current: choice.sha == selection, highlighted: choice.id == state.highlighted) { - pick(choice) + GitCommitPickerRow(choice: choice, current: choice.sha == selection, + highlighted: choice.id == state.highlighted || inRange.contains(choice.id)) { + click(choice) } .id(choice.id) .onHover { inside in - guard inside, state.highlighted != choice.id, Date().timeIntervalSince(keyMovedAt) > 0.4 else { return } + // A range keeps its ends while the pointer crosses other rows. + guard inside, state.anchor == nil, state.highlighted != choice.id, + Date().timeIntervalSince(keyMovedAt) > 0.4 else { return } state.highlighted = choice.id } } @@ -413,7 +457,7 @@ struct GitCommitPickerList: View { .accessibilityLabel("Reading the line counts") } Spacer(minLength: 8) - Text("↑↓ move · ↩ show · esc close") + Text(verbatim: keysHint) .accessibilityHidden(true) } .font(.system(size: 10.5)) @@ -422,8 +466,40 @@ struct GitCommitPickerList: View { .padding(.vertical, 6) } - private func move(_ step: Int) -> KeyPress.Result { - state.move(step) + /// The keys, and with a range how many commits Return shows. + private var keysHint: String { + if let range = state.range { + return "\(Plural.count(range.count, "commit")) chosen · ↩ show them · ↑↓ back to one" + } + return pickRange == nil ? "↑↓ move · ↩ show · esc close" + : "↑↓ move · ⇧↑↓ or ⇧-click more · ↩ show · esc close" + } + + private func submit() { + if pickRange != nil, let range = state.range { + pickRange?(range) + } else if let choice = state.highlightedChoice { + pick(choice) + } + } + + /// A click shows that one commit; with Shift (when ranges are offered) it extends the range instead. + private func click(_ choice: GitCommitChoice) { + if pickRange != nil, NSEvent.modifierFlags.contains(.shift) { + state.extend(to: choice.id) + keyMovedAt = Date() + return + } + + pick(choice) + } + + private func move(_ step: Int, extending: Bool = false) -> KeyPress.Result { + if extending, pickRange != nil { + state.extend(step) + } else { + state.move(step) + } scrollTarget = state.highlighted keyMovedAt = Date() return .handled @@ -466,6 +542,8 @@ struct GitCommitPickerRow: View { .buttonStyle(GitChooserRowStyle()) .accessibilityLabel(Text(verbatim: spoken)) .accessibilityAddTraits(current ? .isSelected : []) + // The copy button shows only on the highlighted row; VoiceOver reaches it on every row. + .accessibilityAction(named: "Copy commit id") { Clipboard.copy(choice.sha, what: "commit id") } .accessibilityIdentifier("git-commit-choice-\(choice.short)") ZStack { if highlighted { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitDiffView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitDiffView.swift index 7ace21c292..e511588566 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitDiffView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitDiffView.swift @@ -26,9 +26,22 @@ public struct GitDiffView: View { self.onBlameOpen = onBlameOpen } + /// The renderer's last failure sentence, shown over the page until a render succeeds or Reload is pressed. + @State private var failure: String? + @State private var reloads = 0 + public var body: some View { if PierreWebDiffRenderer.isBundled { - Renderer(files: files, options: options, blame: blame, onBlameOpen: onBlameOpen) + Renderer(files: files, options: options, blame: blame, onBlameOpen: onBlameOpen, reloads: reloads) { failure = $0 } + .overlay { + if let failure { + GitFailureNotice(GitFailure(message: failure), retryLabel: "Reload the diff") { + self.failure = nil + reloads += 1 + } + .padding(16) + } + } } else { Text("This app does not bundle the diff viewer page.") .font(.system(size: 12)) @@ -42,6 +55,10 @@ public struct GitDiffView: View { let options: DiffViewOptions let blame: AgentBlamePayload? let onBlameOpen: ((Int) -> Void)? + /// Bumped by Reload: the page loads again and gets the files again. + let reloads: Int + /// The renderer failed (its one sentence), or rendered again (nil). + let onFailure: (String?) -> Void func makeCoordinator() -> Coordinator { Coordinator() } @@ -52,8 +69,16 @@ public struct GitDiffView: View { c.options = options c.files = files c.onBlameOpen = onBlameOpen + c.onFailure = onFailure + c.reloads = reloads renderer.onEvent = { [weak c] event in - if case .blameOpen(let index) = event { c?.onBlameOpen?(index) } + switch event { + case .blameOpen(let index): c?.onBlameOpen?(index) + // On the next turn: the event can arrive inside a SwiftUI update. + case .failed(let shown): DispatchQueue.main.async { c?.onFailure?(shown) } + case .rendered: DispatchQueue.main.async { c?.onFailure?(nil) } + default: break + } } renderer.apply(options) renderer.show(files, fresh: true) @@ -77,6 +102,11 @@ public struct GitDiffView: View { c.renderer?.show(files, fresh: fresh) } c.onBlameOpen = onBlameOpen + c.onFailure = onFailure + if c.reloads != reloads { + c.reloads = reloads + c.renderer?.reload() + } if c.blame != blame { c.blame = blame c.renderer?.setBlame(blame ?? AgentBlamePayload(sources: [], files: [:], loaded: files.map(\.id))) @@ -89,6 +119,8 @@ public struct GitDiffView: View { var options = DiffViewOptions() var blame: AgentBlamePayload? var onBlameOpen: ((Int) -> Void)? + var onFailure: ((String?) -> Void)? + var reloads = 0 } } } @@ -186,7 +218,10 @@ public struct GitScopePicker: View { .accessibilityIdentifier("git-scope-picker") if scope == .lastCommit, !history.isEmpty { GitCommitPicker(choices: history.map { GitCommitChoice($0) }, selection: (shown ?? history.first)?.sha, - title: "Commits that changed this file", details: details) { commit = $0.sha } + // The history is read up to a limit: a full list says so, so its count is not taken for the total. + title: history.count >= GitFileDiffModel.historyLimit + ? "The newest \(history.count) commits that changed this file" : "Commits that changed this file", + details: details) { commit = $0.sha } .instantTooltip("Any commit that changed this file") .accessibilityIdentifier("git-commit-picker") } @@ -204,6 +239,9 @@ struct GitScopeList: View { let shown: GitCommit? let pick: (GitFileScope) -> Void @State private var highlighted: GitFileScope? + /// When a key last moved the highlight: a pointer that only bumps the mouse right after does not take it back, + /// so Return picks the row the keys chose (the commit chooser's rule). + @State private var keyMovedAt = Date.distantPast @FocusState private var focused: Bool static let width: CGFloat = 360 @@ -288,7 +326,8 @@ struct GitScopeList: View { .buttonStyle(GitChooserRowStyle()) .background(GitChooserRowBackground(highlighted: highlighted == scope)) .onHover { inside in - if inside, highlighted != scope { highlighted = scope } + guard inside, highlighted != scope, Date().timeIntervalSince(keyMovedAt) > 0.4 else { return } + highlighted = scope } .accessibilityLabel(Text(verbatim: "\(scope.title), \(scope.summary(base: base, shown: shown))\(counts[scope].map { ", \(Self.spoken($0))" } ?? "")")) .accessibilityAddTraits(current ? .isSelected : []) @@ -319,6 +358,7 @@ struct GitScopeList: View { let all = GitFileScope.allCases let index = all.firstIndex(of: highlighted ?? selection) ?? 0 highlighted = all[min(max(index + step, 0), all.count - 1)] + keyMovedAt = Date() return .handled } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitFailureNotice.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitFailureNotice.swift new file mode 100644 index 0000000000..22a9d50b50 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitFailureNotice.swift @@ -0,0 +1,32 @@ +// +// GitFailureNotice.swift +// GenesisKit +// +// A failed git read where its content would be: the sentence, a button that tries again, and git's raw text behind +// Details. One shape for the Changes pane, a commit's files and the repository panel. +// + +import SwiftUI + +public struct GitFailureNotice: View { + let failure: GitFailure + let retryLabel: String + let retry: (() -> Void)? + + public init(_ failure: GitFailure, retryLabel: String = "Try again", retry: (() -> Void)?) { + self.failure = failure + self.retryLabel = retryLabel + self.retry = retry + } + + public var body: some View { + NoticePill( + text: failure.message, + more: failure.detail, + tone: .error, + fixes: retry.map { retry in [.button(retryLabel, symbol: "arrow.clockwise") { retry() }] } ?? [] + ) + .frame(maxWidth: 460) + .accessibilityIdentifier("git-failure") + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitHistoryViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitHistoryViews.swift index 755b6686e5..64626bc385 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitHistoryViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitHistoryViews.swift @@ -9,13 +9,19 @@ import SwiftUI -/// Commits newest first: short id, subject, author and age. A click opens one; the context menu compares. +/// Commits newest first: short id, subject, author and age. A click opens one; the context menu, a button on the +/// hovered or keyed row and the C key compare. With the keyboard in the list, Up and Down move, Return opens, C +/// compares with the current text and Esc clears. public struct GitCommitList: View { let commits: [GitCommit] var selected: String? var onOpen: (GitCommit) -> Void var onCompare: ((GitCommit) -> Void)? var onCompareWithSelected: ((GitCommit) -> Void)? + /// The row the keys move, apart from the open commit (`selected`). + @State private var keyed: String? + @State private var hovered: String? + @FocusState private var focused: Bool public init(commits: [GitCommit], selected: String? = nil, onOpen: @escaping (GitCommit) -> Void, onCompare: ((GitCommit) -> Void)? = nil, onCompareWithSelected: ((GitCommit) -> Void)? = nil) { @@ -29,24 +35,93 @@ public struct GitCommitList: View { public var body: some View { LazyVStack(alignment: .leading, spacing: 1) { ForEach(commits) { commit in - Button { - onOpen(commit) - } label: { - GitCommitRow(commit: commit, selected: commit.sha == selected) + row(commit) + } + } + .focusable() + .focused($focused) + .focusEffectDisabled() + .onKeyPress(.upArrow, phases: [.down, .repeat]) { _ in move(-1) } + .onKeyPress(.downArrow, phases: [.down, .repeat]) { _ in move(1) } + .onKeyPress(.return) { + guard let commit = keyedCommit else { return .ignored } + onOpen(commit) + return .handled + } + .onKeyPress(characters: CharacterSet(charactersIn: "c")) { press in + guard press.modifiers.isEmpty, let onCompare, let commit = keyedCommit else { return .ignored } + onCompare(commit) + return .handled + } + .onKeyPress(.escape) { + guard keyed != nil else { return .ignored } + keyed = nil + return .handled + } + } + + private var keyedCommit: GitCommit? { + keyed.flatMap { sha in commits.first { $0.sha == sha } } + } + + private func move(_ step: Int) -> KeyPress.Result { + guard !commits.isEmpty else { return .ignored } + let start = commits.firstIndex { $0.sha == (keyed ?? selected) } + let index = start.map { min(max($0 + step, 0), commits.count - 1) } ?? (step > 0 ? 0 : commits.count - 1) + keyed = commits[index].sha + return .handled + } + + private func row(_ commit: GitCommit) -> some View { + let open = commit.sha == selected + let showsCompare = onCompare != nil && (hovered == commit.sha || (focused && keyed == commit.sha)) + return HStack(spacing: 0) { + Button { + keyed = commit.sha + onOpen(commit) + } label: { + GitCommitRow(commit: commit, selected: open) + } + .buttonStyle(RowButtonStyle(cornerRadius: 5)) + .accessibilityAddTraits(open ? .isSelected : []) + .accessibilityAction(named: "Copy commit id") { Clipboard.copy(commit.sha, what: "commit id") } + .accessibilityActions { + if let onCompare { + Button("Compare with the current text") { onCompare(commit) } } - .buttonStyle(RowButtonStyle(cornerRadius: 5)) - .contextMenu { - if let onCompare { - Button("Compare with the current text") { onCompare(commit) } - } - if let onCompareWithSelected, let selected, selected != commit.sha { - Button("Compare with the selected commit") { onCompareWithSelected(commit) } - } - Button("Copy commit id") { Clipboard.copy(commit.sha) } + if let onCompareWithSelected, let selected, selected != commit.sha { + Button("Compare with the selected commit") { onCompareWithSelected(commit) } + } + } + if let onCompare { + // Space kept while hidden, so the row's text does not reflow under the pointer. + IconButton(systemName: "arrow.left.arrow.right", tooltip: "Compare with the current text (C)", size: 10.5, tint: .secondary) { + onCompare(commit) } - .accessibilityIdentifier("git-commit-\(commit.short)") + .opacity(showsCompare ? 1 : 0) + .allowsHitTesting(showsCompare) + .accessibilityHidden(true) + .frame(width: 24) + } + } + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Color.accentColor.opacity(focused && keyed == commit.sha ? 0.8 : 0), lineWidth: 1.5) + .allowsHitTesting(false) + ) + .onHover { inside in + if inside { hovered = commit.sha } else if hovered == commit.sha { hovered = nil } + } + .contextMenu { + if let onCompare { + Button("Compare with the current text") { onCompare(commit) } + } + if let onCompareWithSelected, let selected, selected != commit.sha { + Button("Compare with the selected commit") { onCompareWithSelected(commit) } } + Button("Copy commit id") { Clipboard.copy(commit.sha, what: "commit id") } } + .accessibilityIdentifier("git-commit-\(commit.short)") } } @@ -61,13 +136,13 @@ struct GitCommitRow: View { .lineLimit(2) HStack(spacing: 6) { Text(verbatim: commit.short) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 11, design: .monospaced)) Text(verbatim: commit.author) .lineLimit(1) Spacer(minLength: 4) LiveAgo(date: commit.date, style: .brief) } - .font(.system(size: 10)) + .font(.system(size: 11)) .foregroundStyle(.secondary) } .padding(.horizontal, 8) @@ -86,8 +161,10 @@ public struct GitCommitView: View { var rendered: ((DiffFile) -> AnyView?)? @State private var files: [DiffFile] = [] @State private var picked: String? - @State private var error: String? + @State private var failure: GitFailure? @State private var loaded = false + @State private var attempt = 0 + @FocusState private var filesFocused: Bool public init(root: URL, commit: GitCommit, rendered: ((DiffFile) -> AnyView?)? = nil) { self.root = root @@ -102,6 +179,7 @@ public struct GitCommitView: View { ForEach(files) { file in Button { picked = file.id + filesFocused = true } label: { HStack(spacing: 6) { GitChangeMark(Self.kind(file.status)) @@ -109,23 +187,43 @@ public struct GitCommitView: View { .font(.system(size: 11.5, weight: picked == file.id ? .semibold : .regular)) .lineLimit(1) Spacer(minLength: 4) - GitLineCounts(additions: file.additions, deletions: file.deletions, size: 10) + GitLineCounts(additions: file.additions, deletions: file.deletions, size: 11) } .padding(.horizontal, 8) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 5).fill(Color.accentColor.opacity(picked == file.id ? 0.14 : 0))) } .buttonStyle(RowButtonStyle(cornerRadius: 5)) - .help(file.path) + // The keyboard's place is the picked row's own ring: the system focus ring around the padded list + // was clipped by the scroll view and sat off the rows (Martin, 2026-10-25). + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Color.accentColor.opacity(filesFocused && picked == file.id ? 0.8 : 0), lineWidth: 1.5) + .allowsHitTesting(false) + ) + .instantTooltip(file.path, monospaced: true) + .accessibilityAddTraits(picked == file.id ? .isSelected : []) } } .padding(6) + // Up and Down pick the file before or after: the diff beside follows. + .focusable() + .focused($filesFocused) + .focusEffectDisabled() + .onKeyPress(.upArrow, phases: [.down, .repeat]) { _ in step(-1) } + .onKeyPress(.downArrow, phases: [.down, .repeat]) { _ in step(1) } } .frame(width: 240) Divider() Group { - if let error { - Text(verbatim: error).foregroundStyle(.secondary) + if let failure { + GitFailureNotice(failure) { + self.failure = nil + loaded = false + attempt += 1 + } + .padding(16) } else if let file = files.first(where: { $0.id == picked }) { if let custom = rendered?(file) { custom @@ -135,12 +233,24 @@ public struct GitCommitView: View { } else if loaded { Text("This commit changed no files.").foregroundStyle(.secondary) } else { - ProgressView().controlSize(.small) + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Reading the commit") + } + .font(.system(size: 12)) + .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .task(id: commit.sha) { await load() } + .task(id: "\(commit.sha) \(attempt)") { await load() } + } + + private func step(_ by: Int) -> KeyPress.Result { + guard !files.isEmpty else { return .ignored } + let index = files.firstIndex { $0.id == picked }.map { min(max($0 + by, 0), files.count - 1) } ?? 0 + picked = files[index].id + return .handled } private func load() async { @@ -154,8 +264,8 @@ public struct GitCommitView: View { case .success(let files): self.files = files picked = files.first?.id - case .failure(let failure): - error = "\(failure)" + case .failure(let error): + failure = GitFailure(error, fallback: "Git could not read this commit. Try again.") } loaded = true } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitRepoPanel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitRepoPanel.swift index 9abfb3b147..0aaed2a4a6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitRepoPanel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitRepoPanel.swift @@ -2,11 +2,17 @@ // GitRepoPanel.swift // GenesisKit // -// A repository at a glance: branch with ahead and behind, the staged, changed and untracked files, recent commits. +// A repository's changes at a glance: branch with ahead and behind, the staged, changed and untracked files. Its commits +// are the git sidebar's own Commits tab (Martin, 2026-10-25: the changes tab shows the files, nothing else). // A vault can hold thousands of untracked files (a data folder an agent wrote), so untracked files fold under the -// shallowest folder that holds no other change, as `git status` folds them, rows are lazy, and every list shows a -// page at a time. The sort button cycles Last update, Name, Folder and Kind; a filter and an untracked switch sit -// beside it. +// shallowest folder that holds no other change, as `git status` folds them. The rows live in an AppKit table +// (`GitRepoTable`) that builds only the rows on screen, so 5,000 rows scroll like 50 and no list needs pages. The sort +// button cycles Last update, Name, Folder and Kind; in Last update the changed and untracked files and folders form +// one list, newest first. A filter and an untracked switch sit beside it. +// +// Costs that shaped it (benchmark on a vault of 5,650 entries, 2026-10-25): the lists are derived off the main thread +// once per change of their inputs, a status read that finds the same status and the same dates changes nothing, and +// the panel is `Equatable` so a publish of the window's model does not run it. // import SwiftUI @@ -92,24 +98,42 @@ public enum GitChangeList { filter.isEmpty || entry.path.localizedCaseInsensitiveContains(filter) } - /// `dates` are the files' modification dates (newest first for Last update); a file without one sorts last. + /// The last path component, sliced natively (an `NSString` helper bridges and normalizes a Czech path per call). + static func name(_ path: String) -> Substring { + path.lastIndex(of: "/").map { path[path.index(after: $0)...] } ?? Substring(path) + } + + static func folder(_ path: String) -> Substring { + path.lastIndex(of: "/").map { path[..<$0] } ?? "" + } + + /// `dates` are the files' modification dates (newest first for Last update); a file without one sorts last. Each + /// entry's keys are looked up once, not once per comparison. public static func sorted(_ entries: [GitStatusEntry], by order: GitChangeOrder, dates: [String: Date], staged: Bool) -> [GitStatusEntry] { - entries.sorted { lhs, rhs in + struct Keyed { + let entry: GitStatusEntry + let date: Date + let name: Substring + let rank: Int + } + let keyed = entries.map { entry in + Keyed(entry: entry, date: order == .lastUpdate ? dates[entry.path] ?? .distantPast : .distantPast, + name: order == .name ? name(entry.path) : "", rank: order == .kind ? rank(staged ? entry.staged : entry.unstaged) : 0) + } + return keyed.sorted { lhs, rhs in switch order { case .lastUpdate: - let left = dates[lhs.path] ?? .distantPast, right = dates[rhs.path] ?? .distantPast - if left != right { return left > right } + if lhs.date != rhs.date { return lhs.date > rhs.date } case .name: - let result = (lhs.path as NSString).lastPathComponent.localizedStandardCompare((rhs.path as NSString).lastPathComponent) + let result = lhs.name.localizedStandardCompare(rhs.name) if result != .orderedSame { return result == .orderedAscending } case .folder: break case .kind: - let left = rank(staged ? lhs.staged : lhs.unstaged), right = rank(staged ? rhs.staged : rhs.unstaged) - if left != right { return left < right } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } } - return lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending - } + return lhs.entry.path.localizedStandardCompare(rhs.entry.path) == .orderedAscending + }.map(\.entry) } public static func sorted(_ groups: [GitUntrackedGroup], by order: GitChangeOrder, dates: [String: Date]) -> [GitUntrackedGroup] { @@ -124,7 +148,7 @@ public enum GitChangeList { let left = newest[lhs.folder] ?? .distantPast, right = newest[rhs.folder] ?? .distantPast if left != right { return left > right } case .name: - let result = (lhs.folder as NSString).lastPathComponent.localizedStandardCompare((rhs.folder as NSString).lastPathComponent) + let result = name(lhs.folder).localizedStandardCompare(name(rhs.folder)) if result != .orderedSame { return result == .orderedAscending } case .kind: if lhs.files.count != rhs.files.count { return lhs.files.count > rhs.files.count } @@ -148,59 +172,100 @@ public enum GitChangeList { } } -/// The modification dates of a status's files, read off the main thread once per status. -@MainActor -final class GitChangeDates: ObservableObject { - struct Key: Equatable { - let root: URL - let generation: Int +/// One row of the panel's table, a value: the table compares rows to know which cells to build again. +enum GitPanelRow: Equatable, Identifiable { + case section(key: String, title: String, open: Bool) + case file(entry: GitStatusEntry, staged: Bool, id: String, indent: Bool, date: Date?) + case folder(folder: String, count: Int, newest: Date?, open: Bool) + + enum Kind: Hashable { + case section, file, folder } - private var ticket = 0 - var readDates: @Sendable (URL, [String]) -> [String: Date] = { root, paths in - var result: [String: Date] = [:] - for path in paths { - let values = try? root.appendingPathComponent(path).resourceValues(forKeys: [.contentModificationDateKey]) - if let date = values?.contentModificationDate { result[path] = date } + + var id: String { + switch self { + case .section(let key, _, _): return "section:" + key + case .file(_, _, let id, _, _): return id + case .folder(let folder, _, _, _): return GitRepoPanel.folderRowID(folder) } - return result } - @Published private(set) var dates: [String: Date] = [:] - /// Bumped with every new set of dates, so the panel's derived lists know to sort again. - private(set) var version = 0 - private var loadedRoot: String? - private var loadedGeneration = -1 - func load(root: URL, entries: [GitStatusEntry], generation: Int) { - guard loadedRoot != root.path || loadedGeneration != generation else { return } - if loadedRoot != root.path { - version += 1 - dates = [:] + var kind: Kind { + switch self { + case .section: return .section + case .file: return .file + case .folder: return .folder } - loadedRoot = root.path - loadedGeneration = generation - ticket += 1 - let ticket = ticket - let paths = entries.map(\.path) - let readDates = readDates - Task.detached(priority: .utility) { [weak self] in - let result = readDates(root, paths) - await self?.apply(result, root: root, generation: generation, ticket: ticket) + } + + /// The keyboard stops on every row: a heading folds its section like a folder row (Martin, 2026-10-25: "i should be + /// able to fold the Changes"). + var selectable: Bool { true } + + /// What VoiceOver says when the keyboard lands on the row. + var spoken: String { + switch self { + case .section(_, let title, let open): return "\(title), section, \(open ? "expanded" : "collapsed")" + case .file(let entry, _, _, _, _): return String(GitChangeList.name(entry.path)) + case .folder(let folder, let count, _, let open): + return "\(GitChangeList.name(folder)), folder, \(Plural.count(count, "file")), \(open ? "expanded" : "collapsed")" } } - private func apply(_ result: [String: Date], root: URL, generation: Int, ticket: Int) { - guard root.path == loadedRoot, generation == loadedGeneration, ticket == self.ticket else { return } - version += 1 - dates = result + /// What a key does: Up and Down walk the selectable rows (they repeat while held), Return opens a file and opens or + /// closes a folder or a section, Right opens a folder or a section, Left closes one or keys the row it belongs to, Esc lets the keyed row go and is ignored when + /// nothing is keyed, so it still reaches the window or a sheet. + enum KeyAction: Equatable { + case none + case select(Int) + case activate(Int) + case setOpen(Int, Bool) + case clear + } + + static func action(for key: MarkdownTreeKey, rows: [GitPanelRow], cursor: Int?) -> KeyAction { + if key == .escape { return cursor == nil ? .none : .clear } + let selectable = rows.indices.filter { rows[$0].selectable } + guard !selectable.isEmpty else { return .none } + guard let index = cursor, rows.indices.contains(index) else { + switch key { + case .down: return .select(selectable[0]) + case .up: return .select(selectable[selectable.count - 1]) + default: return .none + } + } + switch key { + case .down: return .select(selectable.first { $0 > index } ?? index) + case .up: return .select(selectable.last { $0 < index } ?? index) + case .open: return .activate(index) + case .right: + switch rows[index] { + case .folder(_, _, _, let open), .section(_, _, let open): return open ? .none : .setOpen(index, true) + case .file: return .none + } + case .left: + // The outline convention (NSOutlineView, Finder): Left folds an open row, and on anything else keys the + // row it belongs to: a folder's file keys its folder, every other row its section's heading. + switch rows[index] { + case .section(_, _, let open): return open ? .setOpen(index, false) : .none + case .folder(_, _, _, let open) where open: return .setOpen(index, false) + case .file(_, _, _, let indent, _) where indent: + return rows[..<index].lastIndex { $0.kind == .folder }.map(KeyAction.select) ?? .none + case .file, .folder: + return rows[..<index].lastIndex { $0.kind == .section }.map(KeyAction.select) ?? .none + } + case .navigate, .parent, .escape: return .none + } } } -/// The panel's lists, derived off the main thread once per change of their inputs (status, file dates, order, filter, -/// the untracked switch). Derived in the body, the fold and the sorts over a vault's thousands of entries ran on every -/// render, five times per tab switch (the parent re-renders the panel with new closures). +/// The panel's lists and its files' dates, derived off the main thread. A status read bumps `reads`; the dates are +/// read again then (an edited file stays "modified" but moves up in Last update), and the lists are derived again only +/// when the status, the dates or an input changed. Before, every read re-read 5,650 dates (76 ms), derived the lists +/// twice and re-ran the panel six times, even when nothing had changed (2026-10-25). @MainActor final class GitRepoPanelLists: ObservableObject { - struct Lists: Sendable { + struct Lists: Equatable, Sendable { var staged: [GitStatusEntry] = [] var changed: [GitStatusEntry] = [] var loose: [GitStatusEntry] = [] @@ -208,45 +273,107 @@ final class GitRepoPanelLists: ObservableObject { var groups: [GitUntrackedGroup] = [] var newest: [String: Date] = [:] var untrackedTotal = 0 + /// Last update with untracked files shown: changes, untracked files and folders in one list, newest first. + var recent: [Recent] = [] + /// Every listed file's modification date. + var dates: [String: Date] = [:] + /// A listed path → its row id and the folder it is folded in: the reveal button's lookup. + var places: [String: Place] = [:] + + enum Recent: Equatable, Sendable { + case file(GitStatusEntry) + case folder(String) + } + + struct Place: Equatable, Sendable { + var id: String + var folder: String? + } } struct Inputs: Equatable, Sendable { var root: URL - var generation: Int - var datesVersion: Int var order: GitChangeOrder var filter: String var untracked: Bool } @Published private(set) var lists = Lists() + /// The inputs `lists` were derived from; an empty state waits for them, or a stale list would flash "no match". + private(set) var settled: Inputs? private var inputs: Inputs? + private var status: GitRepoStatus? + private var reads = -1 private var ticket = 0 + /// Bumped with each new `lists`, for the row cache. + private(set) var version = 0 + var readDates: @Sendable (URL, [String]) -> [String: Date] = GitRepoPanelLists.modificationDates + + /// `lstat` per path: about 2.5 µs each, where `URL.resourceValues` cost 76 ms for 5,650 paths. + nonisolated static func modificationDates(root: URL, paths: [String]) -> [String: Date] { + var result: [String: Date] = [:] + result.reserveCapacity(paths.count) + var info = stat() + let base = root.path + "/" + for path in paths where lstat(base + path, &info) == 0 { + let time = info.st_mtimespec + result[path] = Date(timeIntervalSince1970: TimeInterval(time.tv_sec) + TimeInterval(time.tv_nsec) / 1_000_000_000) + } + return result + } func lists(for root: URL) -> Lists { inputs?.root == root ? lists : Lists() } - func update(_ inputs: Inputs, entries: [GitStatusEntry], dates: [String: Date]) { - guard inputs != self.inputs else { return } - if self.inputs?.root != inputs.root { lists = Lists() } + func update(_ inputs: Inputs, status: GitRepoStatus?, reads: Int) { + let rootChanged = inputs.root != self.inputs?.root + let statusChanged = status != self.status + let inputsChanged = inputs != self.inputs + let reread = rootChanged || statusChanged || reads != self.reads + guard reread || inputsChanged else { return } + if rootChanged { + lists = Lists() + settled = nil + version += 1 + } self.inputs = inputs + self.status = status + self.reads = reads ticket += 1 let ticket = ticket + let entries = status?.entries ?? [] + let old = lists + let readDates = readDates Task.detached(priority: .userInitiated) { [weak self] in - let lists = Self.derive(entries, dates: dates, inputs: inputs) - await self?.apply(lists, ticket: ticket) + let span = PerfSpans.begin("git.panel.derive", "\(entries.count) entries \(inputs.order.rawValue)") + let dates = reread ? readDates(inputs.root, entries.map(\.path)) : old.dates + // A read that found the same status and the same dates, with the same inputs, changes nothing. + let derive = rootChanged || statusChanged || inputsChanged || dates != old.dates + let next = derive ? Self.derive(entries, dates: dates, inputs: inputs) : nil + let changed = next.map { $0 != old } ?? false + span.end() + await self?.apply(changed ? next : nil, inputs: inputs, ticket: ticket) } } - private func apply(_ lists: Lists, ticket: Int) { + private func apply(_ next: Lists?, inputs: Inputs, ticket: Int) { guard ticket == self.ticket else { return } - self.lists = lists + let wasSettled = settled == inputs + settled = inputs + if let next { + version += 1 + lists = next + } else if !wasSettled { + // The empty state waits for `settled`: the same lists, now known to answer these inputs. + objectWillChange.send() + } } nonisolated static func derive(_ entries: [GitStatusEntry], dates: [String: Date], inputs: Inputs) -> Lists { let filter = inputs.filter var lists = Lists() + lists.dates = dates lists.staged = GitChangeList.sorted( entries.filter { $0.staged != nil && GitChangeList.matches($0, filter) }, by: inputs.order, dates: dates, staged: true ) @@ -254,6 +381,8 @@ final class GitRepoPanelLists: ObservableObject { entries.filter { $0.unstaged != nil && $0.unstaged != .untracked && GitChangeList.matches($0, filter) }, by: inputs.order, dates: dates, staged: false ) + for entry in lists.staged { lists.places[entry.path] = Lists.Place(id: "staged:" + entry.path) } + for entry in lists.changed { lists.places[entry.path] = Lists.Place(id: "changes:" + entry.path) } guard inputs.untracked else { return lists } let fold = GitChangeList.foldUntracked(entries) lists.loose = GitChangeList.sorted(fold.files.filter { GitChangeList.matches($0, filter) }, by: inputs.order, dates: dates, staged: false) @@ -263,107 +392,320 @@ final class GitRepoPanelLists: ObservableObject { return GitUntrackedGroup(folder: group.folder, files: GitChangeList.sorted(files, by: inputs.order, dates: dates, staged: false)) } lists.groups = GitChangeList.sorted(groups, by: inputs.order, dates: dates) - for group in lists.groups { lists.newest[group.folder] = group.files.compactMap { dates[$0.path] }.max() } + for group in lists.groups { + lists.newest[group.folder] = group.files.compactMap { dates[$0.path] }.max() + for entry in group.files { + lists.places[entry.path] = Lists.Place(id: "folder:\(group.folder):\(entry.path)", folder: group.folder) + } + } + for entry in lists.loose { lists.places[entry.path] = Lists.Place(id: "untracked:" + entry.path) } lists.untrackedTotal = lists.loose.count + lists.groups.reduce(0) { $0 + $1.files.count } + if inputs.order == .lastUpdate { + // One list, newest first: a change, an untracked file and an untracked folder (by its newest file) compete + // for the top (Martin, 2026-10-25: the changes section topped out at a note 5 h old while untracked files + // below it were a minute old). + var items: [(date: Date, path: String, item: Lists.Recent)] = [] + items += lists.changed.map { (dates[$0.path] ?? .distantPast, $0.path, .file($0)) } + items += lists.loose.map { (dates[$0.path] ?? .distantPast, $0.path, .file($0)) } + items += lists.groups.map { (lists.newest[$0.folder] ?? .distantPast, $0.folder, .folder($0.folder)) } + items.sort { $0.date != $1.date ? $0.date > $1.date : $0.path.localizedStandardCompare($1.path) == .orderedAscending } + lists.recent = items.map(\.item) + } return lists } } -/// A repository at a glance: branch with ahead and behind, the staged, changed and untracked files, recent commits. -public struct GitRepoPanel: View { +/// A repository's changes: branch with ahead and behind, the staged, changed and untracked files. +/// `Equatable` on what it shows (the repository, the open file), not on its callbacks: the parent builds it +/// with new closures on every render, and before this every publish of the window's model ran the panel again +/// (+13.9 ms per publish, +45 ms per tab switch on a vault, 2026-10-25). +public struct GitRepoPanel: View, Equatable { @ObservedObject var repo: GitRepoModel - @ObservedObject var log: GitHistoryModel var onOpenFile: (GitStatusEntry, _ staged: Bool) -> Void - var onOpenCommit: (GitCommit) -> Void - @StateObject private var dates = GitChangeDates() @StateObject private var derived = GitRepoPanelLists() @AppStorage("genesiskit.gitRepoPanel.order") private var orderRaw = GitChangeOrder.lastUpdate.rawValue @AppStorage("genesiskit.gitRepoPanel.untracked") private var showUntracked = true @State private var filter = "" @State private var openFolders: Set<String> = [] - @State private var shown: [String: Int] = [:] + /// The folded sections' keys (staged, changes, untracked), comma separated: a fold outlives the window. + @AppStorage("genesiskit.gitRepoPanel.folded") private var foldedRaw = "" /// The row "Show the open file" picked, lit for a moment. @State private var flash: String? + @State private var box = GitRepoTableBox() + @State private var rowCache = RowCache() @Environment(\.accessibilityReduceMotion) private var reduceMotion /// The open file, relative to the repository's root: the reveal button scrolls to its row. var revealPath: String? - /// Rows a list shows before "Show more". - static let page = 100 - - public init(repo: GitRepoModel, log: GitHistoryModel, revealPath: String? = nil, - onOpenFile: @escaping (GitStatusEntry, Bool) -> Void, onOpenCommit: @escaping (GitCommit) -> Void) { + public init(repo: GitRepoModel, revealPath: String? = nil, onOpenFile: @escaping (GitStatusEntry, Bool) -> Void) { self.repo = repo - self.log = log self.revealPath = revealPath self.onOpenFile = onOpenFile - self.onOpenCommit = onOpenCommit + } + + public static func == (lhs: GitRepoPanel, rhs: GitRepoPanel) -> Bool { + lhs.repo === rhs.repo && lhs.revealPath == rhs.revealPath } private var order: GitChangeOrder { GitChangeOrder(rawValue: orderRaw) ?? .lastUpdate } + /// A filter shows its matches in every section, like it opens every folder. + private var folded: Set<String> { + filter.isEmpty ? Set(foldedRaw.split(separator: ",").map(String.init)) : [] + } + + private func setFolded(_ key: String, _ isFolded: Bool) { + var keys = Set(foldedRaw.split(separator: ",").map(String.init)) + if isFolded { keys.insert(key) } else { keys.remove(key) } + foldedRaw = keys.sorted().joined(separator: ",") + } + private var inputs: GitRepoPanelLists.Inputs { - GitRepoPanelLists.Inputs(root: repo.root, generation: repo.generation, datesVersion: dates.version, order: order, filter: filter, untracked: showUntracked) + GitRepoPanelLists.Inputs(root: repo.root, order: order, filter: filter, untracked: showUntracked) + } + + private struct TaskKey: Equatable { + let inputs: GitRepoPanelLists.Inputs + let reads: Int + } + + /// What the cells draw besides their rows: the reveal flash. The open file (`revealPath`) is not here: a tab switch + /// changes it, and rebuilding every visible cell for it cost +51 ms per switch; its two rows are refreshed alone. + private struct Revision: Equatable { + let flash: String? } public var body: some View { let _ = RenderProbe.hit("git.repoPanel") - ScrollViewReader { proxy in - panel(proxy) + let lists = derived.lists(for: repo.root) + // The rows follow the inputs the lists were derived for, not the controls' state: a click on the sort button + // changes the rows once, when the new lists arrive, instead of once with the old lists and once with the new. + let shape = derived.settled ?? inputs + let rows = rowCache.rows(version: derived.version, root: repo.root, key: RowCache.Key( + order: shape.order, untracked: shape.untracked, filtering: !shape.filter.isEmpty, open: openFolders, folded: folded)) { + Self.rows(lists, order: shape.order, untracked: shape.untracked, folded: folded, + isOpen: { openFolders.contains($0) || !shape.filter.isEmpty }) + } + VStack(alignment: .leading, spacing: 0) { + header(lists) + .padding(.horizontal, 8) + .padding(.top, 8) + GitRepoTable(rows: rows, revision: Revision(flash: flash), box: box, keys: keys) { row in + AnyView(cell(row)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 8) + } + .task(id: TaskKey(inputs: inputs, reads: repo.reads)) { + derived.update(inputs, status: repo.status, reads: repo.reads) + } + // Main-thread busy time after each interaction, written to perf.log (`main busy … git.panel.*`), so a later + // build's numbers can be compared with these without a bench. + .onChange(of: revealPath) { old, new in + box.refresh { row in + guard case .file(let entry, _, _, _, _) = row else { return false } + return entry.path == old || entry.path == new + } + } + .onAppear { PerfMainBusy.measure("git.panel.open") } + .onChange(of: orderRaw) { _, _ in PerfMainBusy.measure("git.panel.sort") } + .onChange(of: filter) { _, _ in PerfMainBusy.measure("git.panel.filter") } + .onChange(of: openFolders) { _, _ in PerfMainBusy.measure("git.panel.fold") } + .onChange(of: foldedRaw) { _, _ in PerfMainBusy.measure("git.panel.section") } + .onChange(of: showUntracked) { _, _ in PerfMainBusy.measure("git.panel.untracked") } + } + + // MARK: Rows + + /// The rows in drawn order. Other orders: Staged, Changes, Untracked (folders, then loose files). Last update with + /// untracked files shown: Staged, then one Changes list newest first. An open folder's files follow its row; a folded + /// section keeps only its heading. + static func rows(_ lists: GitRepoPanelLists.Lists, order: GitChangeOrder, untracked: Bool, folded: Set<String> = [], + isOpen: (String) -> Bool) -> [GitPanelRow] { + var rows: [GitPanelRow] = [] + let dated = order == .lastUpdate + func file(_ entry: GitStatusEntry, staged: Bool, id: String, indent: Bool = false) -> GitPanelRow { + .file(entry: entry, staged: staged, id: id, indent: indent, date: dated ? lists.dates[entry.path] : nil) + } + func folder(_ group: GitUntrackedGroup) { + let open = isOpen(group.folder) + rows.append(.folder(folder: group.folder, count: group.files.count, newest: dated ? lists.newest[group.folder] : nil, open: open)) + guard open else { return } + rows += group.files.map { file($0, staged: false, id: "folder:\(group.folder):\($0.path)", indent: true) } + } + /// Adds the heading and tells whether the section's rows follow it. + func section(_ key: String, _ title: String) -> Bool { + let open = !folded.contains(key) + rows.append(.section(key: key, title: title, open: open)) + return open + } + if !lists.staged.isEmpty, section("staged", "Staged · \(lists.staged.count)") { + rows += lists.staged.map { file($0, staged: true, id: "staged:" + $0.path) } + } + if untracked, order == .lastUpdate { + let total = lists.changed.count + lists.untrackedTotal + if total > 0, section("changes", "Changes · \(total)") { + let groups = Dictionary(lists.groups.map { ($0.folder, $0) }, uniquingKeysWith: { first, _ in first }) + for item in lists.recent { + switch item { + case .file(let entry): + rows.append(file(entry, staged: false, id: (entry.unstaged == .untracked ? "untracked:" : "changes:") + entry.path)) + case .folder(let name): + if let group = groups[name] { folder(group) } + } + } + } + } else { + if !lists.changed.isEmpty, section("changes", "Changes · \(lists.changed.count)") { + rows += lists.changed.map { file($0, staged: false, id: "changes:" + $0.path) } + } + if untracked, lists.untrackedTotal > 0, section("untracked", "Untracked · \(lists.untrackedTotal)") { + lists.groups.forEach(folder) + rows += lists.loose.map { file($0, staged: false, id: "untracked:" + $0.path) } + } } + return rows } - private func panel(_ proxy: ScrollViewProxy) -> some View { - LazyVStack(alignment: .leading, spacing: 1) { + /// The rows the keys walk, in drawn order (the file and folder rows). + static func keyRows(_ rows: [GitPanelRow]) -> [String] { + rows.filter(\.selectable).map(\.id) + } + + /// Rows are built once per change of the lists or of what the rows show, not on every render (the filter field's + /// keystrokes render the panel before the new lists arrive). + @MainActor + final class RowCache { + struct Key: Equatable { + let order: GitChangeOrder + let untracked: Bool + let filtering: Bool + let open: Set<String> + let folded: Set<String> + } + private var last: (version: Int, root: URL, key: Key, rows: [GitPanelRow])? + + func rows(version: Int, root: URL, key: Key, build: () -> [GitPanelRow]) -> [GitPanelRow] { + if let last, last.version == version, last.root == root, last.key == key { return last.rows } + let rows = build() + last = (version, root, key, rows) + return rows + } + } + + private var keys: GitRepoTableKeys { + GitRepoTableKeys( + activate: { row in activate(row, open: nil) }, + setOpen: { row, open in activate(row, open: open) }, + start: { rows in revealPath.flatMap { path in rows.firstIndex { $0.id == derived.lists.places[path]?.id } } } + ) + } + + /// Return opens the keyed file or flips its folder or section; Right and Left open and close a folder or a section. + private func activate(_ row: GitPanelRow, open: Bool?) { + switch row { + case .file(let entry, let staged, _, _, _): + guard open == nil else { return } + onOpenFile(entry, staged) + case .folder(let folder, _, _, let isOpen): + if open ?? !isOpen { openFolders.insert(folder) } else { openFolders.remove(folder) } + case .section(let key, _, let isOpen): + setFolded(key, !(open ?? !isOpen)) + } + } + + // MARK: Header + + @ViewBuilder + private func header(_ lists: GitRepoPanelLists.Lists) -> some View { + VStack(alignment: .leading, spacing: 0) { + if let failure = repo.failure { + GitFailureNotice(failure) { repo.retry() } + .padding(.vertical, 4) + } if let status = repo.status { branch(status) - controls(proxy) + controls(lists) .padding(.vertical, 6) - let lists = derived.lists(for: repo.root) - list("Staged", lists.staged, key: "staged", staged: true) - list("Changes", lists.changed, key: "changes", staged: false) - if showUntracked { - untracked(lists) - } - if status.entries.isEmpty { - Text("No uncommitted changes.") - .font(.system(size: 11)) - .foregroundStyle(.secondary) + emptyState(status, lists) + } else if repo.failure == nil { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Reading the repository") } + .font(.system(size: 11)) + .foregroundStyle(.secondary) } - if !log.commits.isEmpty { - Text("Recent commits") - .kicker() - .padding(.top, 10) - GitCommitList(commits: log.commits, onOpen: onOpenCommit) - } - } - .task(id: GitChangeDates.Key(root: repo.root, generation: repo.generation)) { - dates.load(root: repo.root, entries: repo.status?.entries ?? [], generation: repo.generation) - } - .task(id: inputs) { - derived.update(inputs, entries: repo.status?.entries ?? [], dates: dates.dates) } } private func branch(_ status: GitRepoStatus) -> some View { - HStack(spacing: 6) { - Image(systemName: "arrow.triangle.branch") - Text(verbatim: status.branch) - .font(.system(size: 12, weight: .semibold)) - .lineLimit(1) - if status.ahead > 0 { Text(verbatim: "↑\(status.ahead)").foregroundStyle(.secondary) } - if status.behind > 0 { Text(verbatim: "↓\(status.behind)").foregroundStyle(.secondary) } + let tracking = Self.trackingText(status) + return HStack(spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.branch") + Text(verbatim: status.branch) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + if status.ahead > 0 { Text(verbatim: "↑\(status.ahead)").foregroundStyle(.secondary) } + if status.behind > 0 { Text(verbatim: "↓\(status.behind)").foregroundStyle(.secondary) } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: "Branch \(status.branch), \(tracking)")) + .instantTooltip(tracking) Spacer(minLength: 0) IconButton(systemName: "arrow.clockwise", tooltip: "Read the repository's status again", size: 11) { repo.refresh() } } .font(.system(size: 11)) - .instantTooltip(status.upstream.map { "Tracks \($0)" } ?? "No upstream branch") } - /// The sort button (a click takes the next order, a right-click lists them all), the filter, the untracked switch. - private func controls(_ proxy: ScrollViewProxy) -> some View { + /// The branch's upstream and its ahead and behind counts, in words. + static func trackingText(_ status: GitRepoStatus) -> String { + guard let upstream = status.upstream else { return "No upstream branch" } + var counts: [String] = [] + if status.ahead > 0 { counts.append("\(Plural.count(status.ahead, "commit")) ahead") } + if status.behind > 0 { counts.append("\(Plural.count(status.behind, "commit")) behind") } + return "Tracks \(upstream)" + (counts.isEmpty ? ", up to date" : ": " + counts.joined(separator: ", ")) + } + + /// Why the lists are empty: a clean repository, a filter that matches nothing, or untracked files hidden. + @ViewBuilder + private func emptyState(_ status: GitRepoStatus, _ lists: GitRepoPanelLists.Lists) -> some View { + let shownCount = lists.staged.count + lists.changed.count + (showUntracked ? lists.untrackedTotal : 0) + if status.entries.isEmpty { + emptyText("No uncommitted changes.") + } else if shownCount == 0, derived.settled == inputs { + if !filter.isEmpty { + emptyRow("No file matches “\(filter)”.", action: "Clear filter") { filter = "" } + } else if !showUntracked { + let hidden = status.entries.filter { $0.unstaged == .untracked }.count + emptyRow("\(Plural.count(hidden, "untracked file")) \(hidden == 1 ? "is" : "are") hidden.", action: "Show") { showUntracked = true } + } + } + } + + private func emptyText(_ text: String) -> some View { + Text(verbatim: text) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } + + private func emptyRow(_ text: String, action: String, perform: @escaping () -> Void) -> some View { + HStack(spacing: 8) { + emptyText(text) + Button(action, action: perform) + .buttonStyle(.genHoverPlain()) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + .accessibilityIdentifier("git-repo-empty") + } + + /// The sort button (a click takes the next order, a right-click lists them all), the filter, the reveal button and + /// the untracked switch. + private func controls(_ lists: GitRepoPanelLists.Lists) -> some View { HStack(spacing: 6) { Button { orderRaw = order.next.rawValue @@ -394,9 +736,14 @@ public struct GitRepoPanel: View { .textFieldStyle(.roundedBorder) .controlSize(.small) .font(.system(size: 11)) - let listed = revealTarget(in: derived.lists(for: repo.root)) != nil + .onExitCommand { filter = "" } + .accessibilityLabel("Filter files") + if !filter.isEmpty { + IconButton(systemName: "xmark.circle.fill", tooltip: "Clear the filter (Esc)", size: 11, tint: .secondary) { filter = "" } + } + let listed = revealPath.flatMap { lists.places[$0] } != nil IconButton(systemName: "scope", tooltip: listed ? "Show the open file in the list" : "The open file has no changes here", size: 11) { - reveal(proxy) + reveal(lists) } .disabled(!listed) .accessibilityIdentifier("git-repo-reveal") @@ -409,95 +756,64 @@ public struct GitRepoPanel: View { } } - /// Where the open file's row is: its list's key, the folder it is folded in, and its place in that list. - private func revealTarget(in lists: GitRepoPanelLists.Lists) -> (key: String, folder: String?, index: Int)? { - revealPath.flatMap { Self.revealTarget(path: $0, in: lists) } - } - - static func revealTarget(path: String, in lists: GitRepoPanelLists.Lists) -> (key: String, folder: String?, index: Int)? { - for (key, entries) in [("changes", lists.changed), ("staged", lists.staged), ("untracked", lists.loose)] { - if let index = entries.firstIndex(where: { $0.path == path }) { - return (key, nil, index) - } - } - for group in lists.groups { - if let index = group.files.firstIndex(where: { $0.path == path }) { - return ("folder:" + group.folder, group.folder, index) - } - } - return nil - } - - /// Opens the open file's folder and page when needed, scrolls its row to the middle and lights it for a moment. - private func reveal(_ proxy: ScrollViewProxy) { - guard let path = revealPath, let target = revealTarget(in: derived.lists(for: repo.root)) else { return } - if let folder = target.folder { - openFolders.insert(folder) - } - if target.index >= shown[target.key] ?? Self.page { - shown[target.key] = (target.index / Self.page + 1) * Self.page - } - let id = target.key + ":" + path - // On the next turn: a folder just opened or a page just grown has no row to scroll to yet. + /// Opens the open file's folder when needed, scrolls its row to the middle and lights it for a moment. + private func reveal(_ lists: GitRepoPanelLists.Lists) { + guard let path = revealPath, let place = lists.places[path] else { return } + if let folder = place.folder { openFolders.insert(folder) } + // On the next turn: a folder just opened has no row to scroll to yet. DispatchQueue.main.async { - withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.3)) { - proxy.scrollTo(id, anchor: .center) - } - flash = id + box.reveal(place.id, reduceMotion: reduceMotion) + flash = place.id DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { - guard flash == id else { return } - withAnimation(reduceMotion ? nil : .easeOut(duration: 0.4)) { flash = nil } + if flash == place.id { flash = nil } } } } + static func folderRowID(_ folder: String) -> String { "folder-row:" + folder } + + // MARK: Cells + @ViewBuilder - private func list(_ title: String, _ entries: [GitStatusEntry], key: String, staged: Bool) -> some View { - if !entries.isEmpty { - Text(verbatim: "\(title) · \(entries.count)") - .kicker() - .padding(.top, 6) - let limit = shown[key] ?? Self.page - ForEach(entries.prefix(limit)) { entry in - row(entry, staged: staged, indent: 0, id: key + ":" + entry.path) - } - more(key: key, total: entries.count, limit: limit, indent: 0) + private func cell(_ row: GitPanelRow) -> some View { + let _ = RenderProbe.hit("git.row") + switch row { + // Every row is one height (the table's): headings sit on the row's bottom edge. + case .section(let key, let title, let open): + sectionRow(key, title: title, open: open) + case .file(let entry, let staged, let id, let indent, let date): + fileRow(entry, staged: staged, id: id, indent: indent, date: date) + case .folder(let folder, let count, let newest, let open): + folderRow(folder, count: count, newest: newest, open: open) } } - @ViewBuilder - private func untracked(_ lists: GitRepoPanelLists.Lists) -> some View { - if lists.untrackedTotal > 0 { - Text(verbatim: "Untracked · \(lists.untrackedTotal)") - .kicker() - .padding(.top, 6) - ForEach(lists.groups) { group in - folderRow(group, newest: lists.newest[group.folder]) - if openFolders.contains(group.folder) || !filter.isEmpty { - let key = "folder:" + group.folder - let limit = shown[key] ?? Self.page - ForEach(group.files.prefix(limit)) { entry in - row(entry, staged: false, indent: 16, id: key + ":" + entry.path) - } - more(key: key, total: group.files.count, limit: limit, indent: 16) - } - } - let limit = shown["untracked"] ?? Self.page - ForEach(lists.loose.prefix(limit)) { entry in - row(entry, staged: false, indent: 0, id: "untracked:" + entry.path) + private func sectionRow(_ key: String, title: String, open: Bool) -> some View { + Button { + setFolded(key, open) + } label: { + HStack(spacing: 4) { + Image(systemName: open ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 10) + Text(verbatim: title) + .kicker() + Spacer(minLength: 0) } - more(key: "untracked", total: lists.loose.count, limit: limit, indent: 0) + .padding(.bottom, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .help(open ? "Fold \(title)" : "Unfold \(title)") + .accessibilityAddTraits(.isHeader) + .accessibilityValue(open ? "Expanded" : "Collapsed") } - private func folderRow(_ group: GitUntrackedGroup, newest: Date?) -> some View { - let open = openFolders.contains(group.folder) || !filter.isEmpty - return Button { - if openFolders.contains(group.folder) { - openFolders.remove(group.folder) - } else { - openFolders.insert(group.folder) - } + private func folderRow(_ folder: String, count: Int, newest: Date?, open: Bool) -> some View { + Button { + if openFolders.contains(folder) { openFolders.remove(folder) } else { openFolders.insert(folder) } } label: { HStack(spacing: 6) { Image(systemName: open ? "chevron.down" : "chevron.right") @@ -507,34 +823,32 @@ public struct GitRepoPanel: View { Image(systemName: "folder") .font(.system(size: 11)) .foregroundStyle(GitChangeKind.untracked.color) - Text(verbatim: (group.folder as NSString).lastPathComponent + "/") + Text(verbatim: GitChangeList.name(folder) + "/") .font(.system(size: 11.5)) .lineLimit(1) - Text(verbatim: (group.folder as NSString).deletingLastPathComponent) - .font(.system(size: 10)) + Text(verbatim: String(GitChangeList.folder(folder))) + .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) Spacer(minLength: 0) - Text(verbatim: "\(group.files.count) files") - .font(.system(size: 10)) + Text(verbatim: Plural.count(count, "file")) + .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize() - if order == .lastUpdate, let newest { - age(newest) - } + if let newest { age(newest) } } .padding(.horizontal, 8) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(RowButtonStyle(cornerRadius: 5)) - // The system tooltip, not `instantTooltip`: that one puts an AppKit hover view on every row, and scrolling the - // list made and dropped one per row (Martin, 2026-10-13: the panel lagged while scrolling). - .help(group.folder + "/") + // The system tooltip, not `instantTooltip`: that one puts an AppKit hover view on every row. + .help(folder + "/") + .accessibilityValue(open ? "Expanded" : "Collapsed") } - private func row(_ entry: GitStatusEntry, staged: Bool, indent: CGFloat, id: String) -> some View { + private func fileRow(_ entry: GitStatusEntry, staged: Bool, id: String, indent: Bool, date: Date?) -> some View { Button { onOpenFile(entry, staged) } label: { @@ -542,52 +856,34 @@ public struct GitRepoPanel: View { if let kind = staged ? entry.staged : entry.unstaged { GitChangeMark(kind) } - Text(verbatim: (entry.path as NSString).lastPathComponent) + Text(verbatim: String(GitChangeList.name(entry.path))) .font(.system(size: 11.5)) .lineLimit(1) - Text(verbatim: (entry.path as NSString).deletingLastPathComponent) - .font(.system(size: 10)) + Text(verbatim: String(GitChangeList.folder(entry.path))) + .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) Spacer(minLength: 0) - if order == .lastUpdate, let date = dates.dates[entry.path] { - age(date) - } + if let date { age(date) } } - .padding(.leading, 8 + indent) + .padding(.leading, 8 + (indent ? 16 : 0)) .padding(.trailing, 8) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(RowButtonStyle(cornerRadius: 5)) .background(RoundedRectangle(cornerRadius: 5).fill(Color.accentColor.opacity(flash == id ? 0.3 : 0))) - .help(entry.path) - .id(id) + // A rename says where it came from: a folder moved outside git pairs its files (GitWorktreeRenames). + .help(entry.oldPath.map { "\($0) → \(entry.path)" } ?? entry.path) + .accessibilityAddTraits(entry.path == revealPath ? .isSelected : []) } private func age(_ date: Date) -> some View { Text(verbatim: LiveTimeFormat.text(.compact, date, now: Date()) ?? "") - .font(.system(size: 10)) + .font(.system(size: 11)) .monospacedDigit() .foregroundStyle(.secondary) .fixedSize() } - - @ViewBuilder - private func more(key: String, total: Int, limit: Int, indent: CGFloat) -> some View { - if total > limit { - Button { - shown[key] = limit + Self.page - } label: { - Text(verbatim: "Show \(min(Self.page, total - limit)) more of \(total - limit)") - .font(.system(size: 11)) - .foregroundStyle(Color.accentColor) - .padding(.leading, 8 + indent) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(RowButtonStyle(cornerRadius: 5)) - } - } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitRepoTable.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitRepoTable.swift new file mode 100644 index 0000000000..35b00fbb5c --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitRepoTable.swift @@ -0,0 +1,295 @@ +// +// GitRepoTable.swift +// GenesisKit +// +// The repository panel's rows in an AppKit table whose cells host the SwiftUI rows, laid over the git sidebar like +// the file tree's table (`MarkdownTreeTable`, `MarkdownOverlaySlot`). The rows were a SwiftUI `LazyVStack` inside the +// sidebar's scroll view: every 45 pt scroll step cost 9.4 ms of main thread on a vault (12.2 ms with a 600-row folder +// open, spikes to 395 ms), all of it the lazy stack placing rows inside the window root's layout (benchmark, +// 2026-10-25). The table only moves its clip view while scrolling and builds a row's view when its cell comes into +// view, so the cost no longer grows with the number of rows, and the panel needs no "Show more" pages. +// + +import AppKit +import SwiftUI + +/// What the keyboard does in the table: the same verbs as a click. +struct GitRepoTableKeys { + /// Return: opens a file or a commit, opens or closes a folder. + var activate: (GitPanelRow) -> Void + /// Right (true) and Left (false) on a folder row. + var setOpen: (GitPanelRow, Bool) -> Void + /// The row the keyboard starts on (the open file), or nil for the first one. + var start: ([GitPanelRow]) -> Int? +} + +/// A handle on the table for the panel's own actions: the reveal button scrolls and lights a row. +@MainActor +final class GitRepoTableBox { + fileprivate weak var table: NSTableView? + fileprivate var rows: () -> [GitPanelRow] = { [] } + fileprivate var rebuild: ((GitPanelRow) -> Bool) -> Void = { _ in } + + /// The rows on screen that match take their views again (the open file changed: only its old and new rows differ). + func refresh(where match: @escaping (GitPanelRow) -> Bool) { + rebuild(match) + } + + /// Scrolls the row with `id` to the middle of the table, animated unless Reduce Motion is on. False when it has + /// no row (yet). + @discardableResult + func reveal(_ id: String, reduceMotion: Bool) -> Bool { + guard let table, let index = rows().firstIndex(where: { $0.id == id }), + let clip = table.enclosingScrollView?.contentView else { return false } + let rect = table.rect(ofRow: index) + let target = NSPoint(x: 0, y: max(0, min(rect.midY - clip.bounds.height / 2, table.bounds.height - clip.bounds.height))) + if reduceMotion { + clip.setBoundsOrigin(target) + } else { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.allowsImplicitAnimation = true + clip.animator().setBoundsOrigin(target) + } + } + table.enclosingScrollView?.reflectScrolledClipView(clip) + return true + } +} + +/// The panel's rows in a table. `revision` is any value that changes when the cells draw differently with the same +/// rows (the flash, the order's date column, the revealed file), so the visible cells are built again. +struct GitRepoTable<Revision: Equatable>: NSViewRepresentable { + @GenReduceMotion private var reduceMotion + let rows: [GitPanelRow] + let revision: Revision + let box: GitRepoTableBox + var keys: GitRepoTableKeys? + let cell: (GitPanelRow) -> AnyView + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> MarkdownOverlaySlot { + let table = MarkdownTreeTableView() + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("row")) + column.resizingMask = .autoresizingMask + table.addTableColumn(column) + table.headerView = nil + table.style = .plain + table.intercellSpacing = NSSize(width: 0, height: 1) + table.selectionHighlightStyle = .none + table.backgroundColor = .clear + table.focusRingType = .none + table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + table.usesAutomaticRowHeights = false + table.dataSource = context.coordinator + table.delegate = context.coordinator + table.coordinatorKeys = context.coordinator + table.setAccessibilityIdentifier("git-repo-rows") + + let scroll = NSScrollView() + scroll.documentView = table + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.hasHorizontalScroller = false + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay + scroll.automaticallyAdjustsContentInsets = false + scroll.contentInsets = NSEdgeInsets(top: 2, left: 0, bottom: 8, right: 0) + context.coordinator.table = table + box.table = table + box.rows = { [weak coordinator = context.coordinator] in coordinator?.rows ?? [] } + box.rebuild = { [weak coordinator = context.coordinator] match in coordinator?.rebuildAvailable(where: match) } + apply(to: context.coordinator, table: table, force: true) + let slot = MarkdownOverlaySlot(overlay: scroll) + slot.contentHeight = { [weak table, weak scroll] in + guard let table, let scroll else { return .greatestFiniteMagnitude } + let rows = table.numberOfRows > 0 ? table.rect(ofRow: table.numberOfRows - 1).maxY : 0 + return rows + scroll.contentInsets.top + scroll.contentInsets.bottom + } + return slot + } + + func updateNSView(_ slot: MarkdownOverlaySlot, context: Context) { + guard let table = context.coordinator.table else { return } + box.table = table + apply(to: context.coordinator, table: table, force: false) + slot.contentChanged() + } + + static func dismantleNSView(_ slot: MarkdownOverlaySlot, coordinator: Coordinator) { + slot.leave() + } + + private func apply(to coordinator: Coordinator, table: NSTableView, force: Bool) { + let reduced = reduceMotion + let buildCell = cell + coordinator.cell = { row in AnyView(buildCell(row).environment(\.nativeSettingsReduceMotion, reduced)) } + coordinator.keys = keys + (table as? MarkdownTreeTableView)?.takesKeyboard = keys != nil + let rowsChanged = force || coordinator.rows != rows + let revisionChanged = force || coordinator.revision as? Revision != revision + let old = coordinator.rows + coordinator.rows = rows + coordinator.revision = revision + if coordinator.rowHeight == nil, let first = rows.first(where: { $0.kind == .file }) ?? rows.first { + let height = MarkdownTreeTableCell.height(of: coordinator.cell(first)) + coordinator.rowHeight = height + table.rowHeight = height + } + if rowsChanged { + let span = PerfSpans.begin("git.panel.table", "\(old.count) → \(rows.count) rows") + if force { + table.reloadData() + } else if Self.animate(from: old, to: rows, in: table, reduceMotion: reduced) { + // Rows moved with their row views: rebuild those whose value changed, found by id. + let before = Dictionary(old.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + if !revisionChanged { coordinator.rebuildAvailable { before[$0.id] != $0 } } + } else { + // A sort, a filter or a large fold: the row views on screen stay and take their new rows. A reload threw + // every row view away, and each new one re-ran AppKit's key-view loop and moved a SwiftUI host into the + // window: 1,159 + 892 of 2,172 busy samples of a sort on a vault (2026-10-25). + if old.count != rows.count { table.noteNumberOfRowsChanged() } + if !revisionChanged { coordinator.rebuildAvailable(changedFrom: old) } + } + if force || revisionChanged { coordinator.rebuildAvailable() } + span.end() + coordinator.keepCursor() + } else if revisionChanged { + coordinator.rebuildAvailable() + } + } + + /// A folder opening or closing, a filter or a new status: rows that came slide in, rows that went slide out (the + /// caller then gives every row view on screen its row). False when the rows on screen should simply take their new + /// rows: Reduce Motion, a reorder, or a change too large to watch. + @MainActor + static func animate(from old: [GitPanelRow], to new: [GitPanelRow], in table: NSTableView, reduceMotion: Bool) -> Bool { + guard !old.isEmpty, !reduceMotion, abs(new.count - old.count) <= 400 else { return false } + // Linear, not `difference(from:)`: Myers costs O(n × d), and a sort or a filter over thousands of rows made it + // the slowest part of a click (195 ms per sort on a vault, 2026-10-25). Rows kept on both sides must keep their + // order; a reorder (a sort) reloads instead, since a table cannot animate a move with inserts and removes. + let oldIDs = old.map(\.id), newIDs = new.map(\.id) + let oldSet = Set(oldIDs), newSet = Set(newIDs) + let removed = IndexSet(oldIDs.indices.filter { !newSet.contains(oldIDs[$0]) }) + let inserted = IndexSet(newIDs.indices.filter { !oldSet.contains(newIDs[$0]) }) + guard removed.count + inserted.count <= 400, removed.count < old.count, + oldIDs.filter(newSet.contains) == newIDs.filter(oldSet.contains) else { return false } + let before = Dictionary(old.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let changed = IndexSet(new.indices.filter { index in + !inserted.contains(index) && before[new[index].id].map { $0 != new[index] } ?? false + }) + table.beginUpdates() + if !removed.isEmpty { table.removeRows(at: removed, withAnimation: [.slideUp, .effectFade]) } + if !inserted.isEmpty { table.insertRows(at: inserted, withAnimation: [.slideDown, .effectFade]) } + table.endUpdates() + return true + } + + @MainActor + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, MarkdownTreeKeyTarget { + weak var table: NSTableView? + var rows: [GitPanelRow] = [] + var revision: Any? + var cell: (GitPanelRow) -> AnyView = { _ in AnyView(EmptyView()) } + var keys: GitRepoTableKeys? + /// Every row has one height, measured once: a row of another height would have to be measured, and each change + /// of the rows would move the ones below it. + var rowHeight: CGFloat? + private var cursorID: String? + + var cursor: Int? { cursorID.flatMap { id in rows.firstIndex { $0.id == id } } } + + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let identifier = NSUserInterfaceItemIdentifier("git-repo-cell") + let view = tableView.makeView(withIdentifier: identifier, owner: nil) as? MarkdownTreeTableCell + ?? MarkdownTreeTableCell(identifier: identifier) + view.show(cell(rows[row])) + return view + } + + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { false } + + /// Every row view the table holds (on screen and the few it prepared beyond) that `match` picks takes its current + /// row. Each rebuild swaps a hosted SwiftUI root, about a millisecond, so only rows that changed are rebuilt. + func rebuildAvailable(where match: (GitPanelRow) -> Bool = { _ in true }) { + guard let table else { return } + let rows = rows + let cell = cell + table.enumerateAvailableRowViews { rowView, index in + guard index < rows.count, match(rows[index]) else { return } + (rowView.view(atColumn: 0) as? MarkdownTreeTableCell)?.show(cell(rows[index])) + } + } + + /// After an in-place refill: a row view shows another row when its index now holds a different value. + func rebuildAvailable(changedFrom old: [GitPanelRow]) { + guard let table else { return } + let rows = rows + let cell = cell + table.enumerateAvailableRowViews { rowView, index in + guard index < rows.count, index >= old.count || old[index] != rows[index] else { return } + (rowView.view(atColumn: 0) as? MarkdownTreeTableCell)?.show(cell(rows[index])) + } + } + + // MARK: Keyboard + + private func setCursor(_ index: Int?) { + guard let table else { return } + guard let index, rows.indices.contains(index), rows[index].selectable else { + cursorID = nil + table.deselectAll(nil) + return + } + cursorID = rows[index].id + table.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) + table.scrollRowToVisible(index) + if NSWorkspace.shared.isVoiceOverEnabled { GenAnnounce.post(rows[index].spoken, priority: .low) } + } + + func keepCursor() { + guard cursorID != nil, let table else { return } + if let index = cursor { + if table.selectedRow != index { table.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) } + } else { + cursorID = nil + table.deselectAll(nil) + } + } + + func beginKeyboard(at clicked: Int?) { + if let clicked, rows.indices.contains(clicked), rows[clicked].selectable { + setCursor(clicked) + return + } + guard cursor == nil else { return } + setCursor(keys?.start(rows) ?? rows.firstIndex(where: \.selectable)) + } + + func endKeyboard() { + cursorID = nil + table?.deselectAll(nil) + } + + func handle(_ key: MarkdownTreeKey) -> Bool { + guard let keys else { return false } + switch GitPanelRow.action(for: key, rows: rows, cursor: cursor) { + case .none: + return false + case .select(let index): + setCursor(index) + case .activate(let index): + keys.activate(rows[index]) + case .setOpen(let index, let open): + keys.setOpen(rows[index], open) + case .clear: + endKeyboard() + table?.window?.makeFirstResponder(nil) + } + return true + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitStateViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitStateViews.swift index 1e5fa24499..ce5064889a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitStateViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitStateViews.swift @@ -122,7 +122,7 @@ public struct GitFileMark: View { var body: some View { if let kind = model.status?.entry(for: file)?.shown { GitChangeMark(kind, size: size) - .instantTooltip("Git: \(kind.word)") + .instantTooltip(kind == .untracked ? "Not tracked by git" : "Git: \(kind.word)") } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift b/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift index 5542d3d54f..8dc504a484 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Files/FileTree.swift @@ -609,9 +609,7 @@ enum FileTreeClick: Equatable { /// The click count of the event that activated a row: 1 unless it is a mouse event. A row activated from the /// keyboard has a key event as the current one, and `clickCount` raises on any event that is not a mouse event. static func clickCount(of event: NSEvent?) -> Int { - guard let event, [.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp] - .contains(event.type) else { return 1 } - return event.clickCount + MouseClick.count(of: event) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowPillView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowPillView.swift index 41f2e3acea..457c6cc590 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowPillView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowPillView.swift @@ -96,26 +96,8 @@ public struct FlowPillView: View { /// not a frequency display. A real band split would need the FFT that /// BridgeVoice does in `compute_band_levels_into`. private var bars: some View { - HStack(spacing: 3) { - ForEach(0..<7, id: \.self) { index in - Capsule() - .fill(accent) - .frame(width: 3, height: barHeight(index)) - .opacity(session.phase == .listening ? 1 : 0.35) - } - } - .frame(width: 44, height: 22, alignment: .center) - .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: recognizer.micLevel) - } - - private static let barWeights: [Double] = [0.45, 0.7, 0.95, 1.0, 0.9, 0.65, 0.4] - - private func barHeight(_ index: Int) -> CGFloat { - let base: CGFloat = 4 - guard session.phase == .listening else { return base } - let level = max(0, min(1, recognizer.micLevel)) - let weighted = level * Self.barWeights[index] - return base + CGFloat(weighted) * 16 + VoiceLevelBars(level: recognizer.micLevel, active: session.phase == .listening, tint: accent, + reduceMotion: reduceMotion) } private func stopBreathing() { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowWidget.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowWidget.swift index e4fac9bf28..05c1ea810b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowWidget.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/FlowWidget.swift @@ -65,9 +65,14 @@ public struct FlowWidget: View { } windowLinks if !flow.history.isEmpty { recentDictation } - if let error = runtime.lastError ?? flow.lastError ?? focus.lastError { - Text(error).font(.system(size: 11)).foregroundStyle(Color.genError) - .fixedSize(horizontal: false, vertical: true) + if !runtime.legacyHosts.isEmpty { + // Not an error: this host leaves dictation and Focus to an older Genesis while it runs. + let notice = runtime.legacyNotice() + NoticePill(text: notice.text, detail: notice.detail, more: notice.found, tone: .warning, + fixes: notice.fixes) + .accessibilityIdentifier("flow-widget-legacy") + } else if let error = runtime.lastError ?? flow.lastError ?? focus.lastError { + NoticePill(text: error, tone: .error) } }.padding(18) } @@ -123,6 +128,7 @@ public struct FlowWidget: View { } private var dictationStatus: String { + if !runtime.legacyHosts.isEmpty { return "In the older Genesis while it runs" } if !ready { return "Waiting for Flow" } if !flow.labEnabled || !flow.config.enabled { return "Dictation is off in Settings" } switch flow.phase { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusAudio.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusAudio.swift index 7eaac86d71..dc78b4a850 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusAudio.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusAudio.swift @@ -3,27 +3,44 @@ import Foundation /// Admission to the elected runtime's microphone. Attach a gated recorder before allowing it /// to open input, then release after the recorder has closed input and exited. +/// +/// A standalone lease has no owner behind it: the host steps back for an older Genesis, so no current host owns audio +/// to coordinate with. Attach and release then only check the recorder, and the recording goes ahead. @MainActor public final class FlowFocusAudioLease { private weak var runtime: FlowFocusRuntime? private let token: UUID + let standalone: Bool private var released = false init(runtime: FlowFocusRuntime, token: UUID) { self.runtime = runtime self.token = token + standalone = false + } + + init(standaloneFor runtime: FlowFocusRuntime) { + self.runtime = runtime + token = UUID() + standalone = true } public func attachRecorder(pid: Int32) async throws { - guard !released, let runtime, let identity = FlowAudioProcess.read(pid: pid) else { + guard !released, let identity = FlowAudioProcess.read(pid: pid) else { throw FlowFocusMailbox.Failure.unavailable("The recorder is no longer available.") } + if standalone { return } + guard let runtime else { throw FlowFocusMailbox.Failure.unavailable("The recorder is no longer available.") } let value = FlowAudioAttachment(token: token, recorder: identity) _ = try await runtime.send(action: "audio.attach", payload: JSONEncoder().encode(value)) } public func release() async throws { guard !released else { return } + if standalone { + released = true + return + } guard let runtime else { throw FlowFocusMailbox.Failure.unavailable("The audio owner is no longer available.") } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusPeers.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusPeers.swift new file mode 100644 index 0000000000..313d57effd --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusPeers.swift @@ -0,0 +1,73 @@ +import AppKit +import Darwin +import Foundation + +/// A running Genesis.app, as the runtime sees it when it decides whether that copy speaks the Flow and Focus protocol. +/// Genesis links this package too, so a current copy joins the same election; a copy built before the protocol runs its +/// own dictation shortcut, capture, Focus timer and Do Not Disturb. +public struct FlowFocusPeerApp: Equatable, Sendable { + public static let genesisBundleID = "dev.foltyn.genesis" + + public let pid: Int32 + public let bundlePath: String? + public let version: String? + /// LaunchServices' launch date. Nil for an app started another way (a script, a relaunch through exec). + public let launchDate: Date? + /// The kernel's start time of the process, which every process has. + public let startedAt: Date? + + public init(pid: Int32, bundlePath: String? = nil, version: String? = nil, launchDate: Date? = nil, startedAt: Date? = nil) { + self.pid = pid + self.bundlePath = bundlePath + self.version = version + self.launchDate = launchDate + self.startedAt = startedAt + } + + /// Every running Genesis.app. + @MainActor static func running() -> [FlowFocusPeerApp] { + NSRunningApplication.runningApplications(withBundleIdentifier: genesisBundleID) + .filter { !$0.isTerminated } + .map { app in + let bundle = app.bundleURL.flatMap(Bundle.init(url:)) + let short = bundle?.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let build = (bundle?.object(forInfoDictionaryKey: "CFBundleVersion") as? String).map { "(\($0))" } + let version = [short, build].compactMap { $0 }.joined(separator: " ") + return FlowFocusPeerApp(pid: app.processIdentifier, bundlePath: app.bundleURL?.path, + version: version.isEmpty ? nil : version, launchDate: app.launchDate, + startedAt: processStart(app.processIdentifier)) + } + } + + static func processStart(_ pid: Int32) -> Date? { + FlowAudioProcess.read(pid: pid).map { + Date(timeIntervalSince1970: Double($0.seconds) + Double($0.microseconds) / 1_000_000) + } + } + + /// The process's short name from the kernel ("Genesis", "bun"), for diagnostics. + static func processName(_ pid: Int32) -> String? { + var buffer = [CChar](repeating: 0, count: 256) + guard proc_name(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil } + return String(cString: buffer) + } +} + +/// What the runtime tells a person about a state it is in or a request it refused: the message, a dim line, what was +/// found (behind "Details"), and the fixes. Drawn by GenesisKit's `NoticePill`. +public struct FlowFocusNotice { + public let text: String + public let detail: String? + public let found: String + public let fixes: [NoticeFix] +} + +enum FlowFocusDiagnostics { + static func stamp(_ date: Date?) -> String { + guard let date else { return "unknown" } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter.string(from: date) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusRuntime.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusRuntime.swift index 297d9acb7e..53c8d1e9de 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusRuntime.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Runtime/FlowFocusRuntime.swift @@ -21,6 +21,21 @@ public final class FlowFocusRuntime: ObservableObject { @Published public private(set) var role: Role = .stopped @Published public private(set) var lastError: String? + /// The older Genesis copies this host steps back for. They run their own dictation shortcut, capture, Focus timer + /// and Do Not Disturb, so this host owns none of those while they run. An explicit recording still works + /// (`acquireExternalAudio`), and the host takes its part back when they quit. + @Published public private(set) var legacyHosts: [FlowFocusPeerApp] = [] + /// Why the last `acquireExternalAudio` was refused, with what was found and the fixes. The Voice Notes module + /// shows it for the error text it matches (its store keeps only the text). + public private(set) static var lastAudioRefusal: FlowFocusNotice? + /// The running Genesis.app copies to check before this host owns anything. Only the shared data root is the one + /// an older Genesis also uses, so a runtime on an explicit root sees none. Tests replace it. + var peerApps: @MainActor () -> [FlowFocusPeerApp] + /// Asks a peer app to quit (the legacy notice's fix). Tests replace it. + var terminatePeer: @MainActor (Int32) -> Bool = { pid in + NSRunningApplication(processIdentifier: pid)?.terminate() ?? false + } + private var legacyExits: [DispatchSourceProcess] = [] public let flow: FlowSession public let focus: FocusController public let configuration: FlowFocusConfiguration @@ -66,7 +81,9 @@ public final class FlowFocusRuntime: ObservableObject { public init(dataRoot: URL? = nil, hostID: String? = nil, liveServices: Bool = true, presentsWindows: Bool = true, sharedModels: Bool = false) { let root = dataRoot ?? Self.defaultDataRoot - let usesSharedModels = sharedModels && root.standardizedFileURL == Self.defaultDataRoot.standardizedFileURL + let sharedRoot = root.standardizedFileURL == Self.defaultDataRoot.standardizedFileURL + let usesSharedModels = sharedModels && sharedRoot + peerApps = sharedRoot ? { FlowFocusPeerApp.running() } : { [] } self.dataRoot = root directory = root.appendingPathComponent("feature-runtime", isDirectory: true) self.hostID = hostID ?? Bundle.main.bundleIdentifier ?? "dev.genesis.feature-host" @@ -102,7 +119,7 @@ public final class FlowFocusRuntime: ObservableObject { do { try registerParticipant() installDiscovery() - try rejectLegacyOwner() + let legacy = legacyPeers() for _ in 0 ..< 20 { guard isCurrentStart() else { return } if Task.isCancelled { @@ -111,17 +128,33 @@ public final class FlowFocusRuntime: ObservableObject { return } if !settingsOnly, let acquired = try FlowFocusLease.acquire(directory: directory, hostID: hostID) { + guard legacy.isEmpty else { + // The lock was free, so no current host owns Flow and Focus, and an older Genesis runs them + // itself. Owning them here would run its shortcut, capture, timer and DND twice: this host + // steps back from them, and from nothing else. + acquired.release() + stepBack(for: legacy) + return + } + clearLegacy() lease = acquired try becomeOwner(acquired) return } + // A live owner already runs every service, so joining it as a client duplicates nothing, whatever + // else runs. if let owner = try? FlowFocusLease.readOwner(directory: directory) { + clearLegacy() try becomeClient(owner) return } try await Task.sleep(nanoseconds: 100_000_000) } guard isCurrentStart() else { return } + guard legacy.isEmpty else { + stepBack(for: legacy) + return + } throw FlowFocusMailbox.Failure.unavailable("The Flow and Focus owner is still starting. Try again shortly.") } catch is CancellationError { // The host withdrew the start while it waited for another owner to finish starting. That is not a @@ -200,6 +233,7 @@ public final class FlowFocusRuntime: ObservableObject { lease = nil if let applicationObserver { NSWorkspace.shared.notificationCenter.removeObserver(applicationObserver) } applicationObserver = nil + clearLegacy() unregisterParticipant() } @@ -209,6 +243,12 @@ public final class FlowFocusRuntime: ObservableObject { } public func acquireExternalAudio() async throws -> FlowFocusAudioLease { + // While this host steps back for an older Genesis, no current host owns audio to admit a recorder, and the + // older copy captures without admissions anyway: an explicit recording goes ahead on its own lease. + if mailbox == nil, !legacyHosts.isEmpty { + PerfLog.mark("flow-focus.audio standalone legacy=\(legacyHosts.map { String($0.pid) }.joined(separator: ","))") + return FlowFocusAudioLease(standaloneFor: self) + } guard let holder = FlowAudioProcess.read(pid: ProcessInfo.processInfo.processIdentifier) else { throw FlowFocusMailbox.Failure.unavailable("The recording host could not be identified.") } @@ -219,6 +259,7 @@ public final class FlowFocusRuntime: ObservableObject { _ = try await send(action: "audio.acquire", payload: JSONEncoder().encode(request)) return FlowFocusAudioLease(runtime: self, token: token) } catch { + Self.lastAudioRefusal = audioRefusal(error) do { _ = try await send(action: "audio.release", payload: JSONEncoder().encode(token)) } catch { FlowFocusLog.flow.warning("Unacknowledged audio admission cleanup: \(error.localizedDescription)") } throw error @@ -605,31 +646,169 @@ public final class FlowFocusRuntime: ObservableObject { } } - private var participantURL: URL { directory.appendingPathComponent("participant-\(participant.pid).json") } + private func participantURL(_ pid: Int32) -> URL { directory.appendingPathComponent("participant-\(pid).json") } private func registerParticipant() throws { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) - try FlowFocusLease.writePrivate(try JSONEncoder().encode(participant), to: participantURL) + try FlowFocusLease.writePrivate(try JSONEncoder().encode(participant), to: participantURL(participant.pid)) } private func unregisterParticipant() { - guard let data = try? Data(contentsOf: participantURL), + guard let data = try? Data(contentsOf: participantURL(participant.pid)), let current = try? JSONDecoder().decode(Participant.self, from: data), current.nonce == participant.nonce else { return } - do { try FileManager.default.removeItem(at: participantURL) } + do { try FileManager.default.removeItem(at: participantURL(participant.pid)) } catch { FlowFocusLog.focus.warning("participant cleanup failed: \(error.localizedDescription)") } } - private func rejectLegacyOwner() throws { - guard dataRoot.standardizedFileURL == Self.defaultDataRoot.standardizedFileURL else { return } - for app in NSRunningApplication.runningApplications(withBundleIdentifier: "dev.foltyn.genesis") - where app.processIdentifier != participant.pid { - let record = directory.appendingPathComponent("participant-\(app.processIdentifier).json") - if let data = try? Data(contentsOf: record), - let peer = try? JSONDecoder().decode(Participant.self, from: data), - peer.version == 1, peer.launchDate != nil, peer.launchDate == app.launchDate { continue } - throw FlowFocusMailbox.Failure.unavailable( - "An older Genesis is running its own Flow and Focus services. Update or quit that copy before enabling this host.") + /// The running Genesis copies that do not speak this protocol. + private func legacyPeers() -> [FlowFocusPeerApp] { + peerApps().filter { $0.pid != participant.pid && !isParticipant($0) } + } + + /// A peer speaks this protocol when its participant record was written by the process running now: the launch + /// dates match, or the record is not older than the process. A record left by an earlier process with the same + /// pid predates it. LaunchServices gives no launch date to an app started another way, so the date alone called a + /// current Genesis "older" and refused this host (2026-10-23). + private func isParticipant(_ peer: FlowFocusPeerApp) -> Bool { + let record = participantURL(peer.pid) + guard let data = try? Data(contentsOf: record), + let found = try? JSONDecoder().decode(Participant.self, from: data), + found.version == 1, found.pid == peer.pid else { return false } + if let launched = found.launchDate, launched == peer.launchDate { return true } + guard let started = peer.startedAt, + let written = (try? FileManager.default.attributesOfItem(atPath: record.path))?[.modificationDate] as? Date + else { return false } + // One second of slack for a file system that stores whole seconds. + return written >= started.addingTimeInterval(-1) + } + + private func stepBack(for hosts: [FlowFocusPeerApp]) { + let message = Self.legacyMessage(hosts) + legacyHosts = hosts + role = .unavailable(message) + // The runtime's own error only (a command sent meanwhile names the reason; the next start clears it). Not the + // Flow and Focus models' errors: nothing clears those when the host takes its part back. + lastError = message + watchLegacyExits(hosts) + PerfLog.mark("flow-focus.legacy pids=\(hosts.map { String($0.pid) }.joined(separator: ","))") + } + + static func legacyMessage(_ hosts: [FlowFocusPeerApp]) -> String { + let pids = hosts.map { String($0.pid) }.joined(separator: ", ") + return "An older Genesis (\(hosts.count == 1 ? "pid" : "pids") \(pids)) runs its own dictation shortcut, Focus " + + "timer and Do Not Disturb, so this host leaves them to it. Voice Notes still record." + } + + /// When an older copy quits, this host takes its part back: it starts again, as owner or client. + private func watchLegacyExits(_ hosts: [FlowFocusPeerApp]) { + legacyExits.forEach { $0.cancel() } + legacyExits = hosts.map { host in + let source = DispatchSource.makeProcessSource(identifier: host.pid, eventMask: .exit, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { + guard let self, case .unavailable = self.role, self.legacyHosts.contains(host) else { return } + Task { await self.start() } + } + } + source.resume() + return source + } + } + + private func clearLegacy() { + legacyExits.forEach { $0.cancel() } + legacyExits = [] + if !legacyHosts.isEmpty { legacyHosts = [] } + } + + /// The notice the Flow module shows while this host steps back: each older copy, how it was found, and how to quit it. + public func legacyNotice() -> FlowFocusNotice { + let found = legacyHosts.map { host in + [ + "Genesis, pid \(host.pid)", + "App: \(host.bundlePath.map(PathDisplay.tilde) ?? "unknown")", + "Version: \(host.version ?? "unknown")", + "Started: \(FlowFocusDiagnostics.stamp(host.startedAt ?? host.launchDate))", + "Why it counts as older: since it started, it wrote no Flow and Focus participant record " + + "(\(PathDisplay.tilde(participantURL(host.pid).path))).", + ].joined(separator: "\n") + }.joined(separator: "\n\n") + var fixes: [NoticeFix] = [] + for host in legacyHosts { + let confirm = NoticeFix.Confirmation( + "Quit Genesis (pid \(host.pid))?", + consequence: "Genesis may ask to save open work. Dictation and Focus then start in this host.", + confirmLabel: "Quit Genesis") + fixes.append(.button("Quit the older Genesis (pid \(host.pid))", symbol: "power", confirm: confirm) { [weak self] in + let asked = self?.terminatePeer(host.pid) ?? false + PerfLog.mark("flow-focus.legacy quit pid=\(host.pid) asked=\(asked)") + }) + fixes.append(.command("Or quit it from a terminal", "kill \(host.pid)")) + } + return FlowFocusNotice( + text: "An older Genesis runs dictation and Focus", + detail: "This host leaves the dictation shortcut, the Focus timer and Do Not Disturb to that copy until it " + + "quits or is updated. Voice Notes record as usual.", + found: found, fixes: fixes) + } + + /// What was found when the owner refused audio, and the fixes that apply: the owner, a dictation in progress, the + /// process that holds the microphone. + private func audioRefusal(_ error: Error) -> FlowFocusNotice { + var lines = ["This host: \(hostID), pid \(participant.pid), \(Self.describe(role))"] + var fixes: [NoticeFix] = [] + if let owner = try? FlowFocusLease.readOwner(directory: directory) { + lines.append("Flow and Focus owner: \(owner.hostID), pid \(owner.pid), since \(FlowFocusDiagnostics.stamp(owner.startedAt))") + } else { + lines.append("Flow and Focus owner: none (\(PathDisplay.tilde(directory.appendingPathComponent("owner.json").path)))") + } + if [.listening, .transcribing, .injecting].contains(flow.phase) { + lines.append("Dictation: \(flow.phase.rawValue)") + fixes.append(.button("Cancel the dictation in progress", symbol: "xmark.circle", + confirm: .init("Cancel the dictation in progress?", + consequence: "The words heard so far are not inserted.", + confirmLabel: "Cancel dictation")) { [weak self] in self?.flow.cancelTurn() }) + } + let admissionURL = directory.appendingPathComponent("audio-admission.json") + if let data = try? Data(contentsOf: admissionURL), + let admission = try? JSONDecoder().decode(FlowAudioAdmission.self, from: data) { + let process = admission.recorder ?? admission.holder + let name = FlowFocusPeerApp.processName(process.pid) ?? "a process" + lines.append("Microphone admitted to: \(name), pid \(process.pid), " + + (admission.recorder == nil ? "recorder not started yet" : "recording")) + if admission.recorder != nil, process.isAlive, process.pid != participant.pid { + fixes.append(.button("Stop the other recording (\(name), pid \(process.pid))", symbol: "stop.circle", + confirm: .init("Stop \(name) (pid \(process.pid))?", + consequence: "That recording ends now; what it captured may not be saved.", + confirmLabel: "Stop it")) { + // The notice can outlive the recorder; the kernel start time catches a reused pid. + guard process.isAlive else { + PerfLog.mark("flow-focus.audio stop pid=\(process.pid) skipped=exited") + return + } + let sent = kill(process.pid, SIGTERM) == 0 + PerfLog.mark("flow-focus.audio stop pid=\(process.pid) sent=\(sent)") + }) + fixes.append(.command("Or stop it from a terminal", "kill \(process.pid)")) + } + } + if case .unavailable = role { + fixes.append(.button("Connect to Flow and Focus again", symbol: "arrow.clockwise") { [weak self] in + guard let self, case .unavailable = self.role else { return } + Task { await self.start() } + }) + } + return FlowFocusNotice(text: error.localizedDescription, detail: nil, found: lines.joined(separator: "\n"), fixes: fixes) + } + + private static func describe(_ role: Role) -> String { + switch role { + case .stopped: return "stopped" + case .starting: return "starting" + case .owner: return "owner" + case .client(let owner): return "client of \(owner)" + case .unavailable(let why): return "unavailable: \(why)" } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift index daf5f60405..969e9ff481 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift @@ -28,8 +28,15 @@ struct AttentionPulseStyle: Equatable { /// Number of blinks. Never zero, and a zero period cannot divide by zero. var cycles: Int { max(1, Int((duration / max(period, 0.05)).rounded())) } - /// Three quick flashes of border and fill over about 1.4 s. Reads as "look here, now". - static func flash(color: Color, cornerRadius: CGFloat) -> Self { + /// Three quick flashes of the border over about 1.4 s. Reads as "look here, now" without covering the content, + /// so it is the default (Martin, 2026-10-23: a settings card under a tinted overlay read as covered, not pointed at). + static func borderFlash(color: Color, cornerRadius: CGFloat) -> Self { + Self(color: color, cornerRadius: cornerRadius, lineWidth: 2, + duration: 1.44, period: 0.48, fadeIn: 0.12, fadeOut: 0.2) + } + + /// The same three flashes with a tinted fill over the whole view, for a target too small for a border to read. + static func filledFlash(color: Color, cornerRadius: CGFloat) -> Self { Self(color: color, cornerRadius: cornerRadius, fillOpacity: 0.30, duration: 1.44, period: 0.48, fadeIn: 0.12, fadeOut: 0.2) } @@ -42,9 +49,14 @@ struct AttentionPulseStyle: Equatable { /// Plays one `AttentionPulseStyle` over the view each time `trigger` changes. A new trigger /// while a pulse runs restarts it rather than stacking a second one on top. +/// +/// `since` is when the flash began. A view that appears while it runs plays the rest of it: a list built after the +/// trigger changed (the widget's hover list, opened by the alert that flashed the session) never saw the change, so +/// only the rail bubble flashed (Martin, 2026-10-23). struct AttentionPulse<Trigger: Equatable>: ViewModifier { let trigger: Trigger let style: AttentionPulseStyle + var since: Date? @State private var isOn = false @State private var running: Task<Void, Never>? @@ -70,6 +82,13 @@ struct AttentionPulse<Trigger: Equatable>: ViewModifier { } } + /// What is left of a flash that began at `since`, or nil when none runs. + static func remaining(since: Date?, now: Date = Date(), duration: TimeInterval) -> TimeInterval? { + guard let since else { return nil } + let left = duration - now.timeIntervalSince(since) + return left > 0.05 && left <= duration ? left : nil + } + func body(content: Content) -> some View { content // Drawn over everything and never hit-testable: a pulse must not eat the next click. @@ -85,14 +104,20 @@ struct AttentionPulse<Trigger: Equatable>: ViewModifier { .accessibilityHidden(true) } .onChange(of: trigger) { _, _ in play() } + .onAppear { + guard running == nil, let left = Self.remaining(since: since, duration: style.duration) else { return } + var rest = style + rest.duration = left + play(rest) + } // Turning Reduce Motion on mid-pulse replaces the blinking with the static border at once. .onChange(of: reduceMotion) { _, _ in if running != nil { play() } } .onDisappear { running?.cancel() } } - private func play() { + private func play(_ style: AttentionPulseStyle? = nil) { running?.cancel() - let steps = Self.steps(style: style, reduceMotion: reduceMotion) + let steps = Self.steps(style: style ?? self.style, reduceMotion: reduceMotion) running = Task { @MainActor in for step in steps { if let duration = step.animation { @@ -109,8 +134,12 @@ struct AttentionPulse<Trigger: Equatable>: ViewModifier { } extension View { - /// Blinks this view for attention each time `trigger` changes. Pass a counter you bump. - func attentionPulse<Trigger: Equatable>(trigger: Trigger, style: AttentionPulseStyle) -> some View { - modifier(AttentionPulse(trigger: trigger, style: style)) + /// Blinks this view for attention each time `trigger` changes. Pass a counter you bump. The default is a + /// border-only flash in the accent colour; `.filledFlash` and `.slowBorder` are the other presets. + func attentionPulse<Trigger: Equatable>( + trigger: Trigger, style: AttentionPulseStyle = .borderFlash(color: .accentColor, cornerRadius: 12), + since: Date? = nil + ) -> some View { + modifier(AttentionPulse(trigger: trigger, style: style, since: since)) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift b/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift index d64ee2d739..9e41d9f3e4 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Focus/Pomodoro/FocusChime.swift @@ -75,20 +75,17 @@ public enum FocusChime { // Genesis.app, and `UNUserNotificationCenter.current()` does not fail there, it raises // and takes the test process down with it. guard FlowFocusHost.shared.notificationsEnabled else { return } - let content = UNMutableNotificationContent() - content.title = "\(finished.label) finished" - content.body = autoStarted - ? "\(next.label) has started." - : "\(next.label) is ready when you are." - content.sound = nil // the chime already played; two sounds for one event is noise - content.categoryIdentifier = "focus.phase" - content.userInfo = ["kind": notificationKind] - - let request = UNNotificationRequest( - identifier: "focus.phase.\(Int(Date().timeIntervalSince1970))", - content: content, - trigger: nil) - UNUserNotificationCenter.current().add(request) { error in + // One phase card at a time: the next phase replaces the last one instead of stacking under it. + NotificationEngine.shared.post(EngineNotification( + source: "focus", + title: "\(finished.label) finished", + body: autoStarted ? "\(next.label) has started." : "\(next.label) is ready when you are.", + idPrefix: "focus.phase", + replaceKey: "focus.phase", + category: "focus.phase", + userInfo: ["kind": notificationKind], + sound: nil // the chime already played; two sounds for one event is noise + )) { error in if let error { FlowFocusLog.focus.debug("focus phase notification failed: \(error.localizedDescription)") } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDashboardShare.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDashboardShare.swift index 9e14d32da8..b5257c065c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDashboardShare.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDashboardShare.swift @@ -13,9 +13,15 @@ enum MarkdownDashboardShare { struct Outcome: Equatable { var url: URL? + /// One plain sentence for the reader: what went wrong and what to do. var message: String? + /// What the command itself said, for Details. + var detail: String? } + /// How long the share command may run before the viewer gives up and says so. + static let timeoutSeconds = 30 + static func isAvailable(httpStatus: Int?) -> Bool { guard let httpStatus else { return false } return (100..<600).contains(httpStatus) @@ -36,25 +42,45 @@ enum MarkdownDashboardShare { return nil } + /// Each cause has its own sentence; the command's own output goes to `detail` (UX audit D1). static func outcome(exitCode: Int32, stdout: String, stderr: String) -> Outcome { if exitCode == 0, let url = shareURL(in: stdout) { return Outcome(url: url, message: nil) } - let detail = stderr - .split(whereSeparator: \.isNewline) - .map { String($0).trimmingCharacters(in: .whitespaces) } - .first { !$0.isEmpty } - - if let detail { - return Outcome(url: nil, message: detail) - } + let errorText = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + let outputText = stdout.trimmingCharacters(in: .whitespacesAndNewlines) if exitCode != 0 { - return Outcome(url: nil, message: "dev-dashboard share exited \(exitCode)") + let said = errorText.isEmpty ? outputText : errorText + return Outcome( + url: nil, + message: "The dev dashboard could not share this note. Details say why.", + detail: (said.isEmpty ? "" : said + "\n") + "Exit code \(exitCode)." + ) } - return Outcome(url: nil, message: "dev-dashboard share did not print a URL") + let said = [outputText, errorText].filter { !$0.isEmpty }.joined(separator: "\n") + return Outcome( + url: nil, + message: "The dev dashboard answered without a share link. Try again in a moment.", + detail: said.isEmpty ? nil : said + ) + } + + /// A share that could not run at all: the sentence and the raw error. + static func failure(_ error: Error) -> Outcome { + switch error { + case ToolsBridgeError.timeout: + return Outcome( + url: nil, + message: "The dev dashboard did not answer within \(timeoutSeconds) seconds. Check that it is running, then share again." + ) + case ToolsBridgeError.binaryNotFound: + return Outcome(url: nil, message: error.localizedDescription) + default: + return Outcome(url: nil, message: "The share could not start.", detail: error.localizedDescription) + } } } @@ -63,7 +89,24 @@ enum MarkdownDashboardShare { final class MarkdownShareModel: ObservableObject { @Published private(set) var available = false @Published private(set) var busy = false + /// The failure's sentence. Set after `errorDetail`, so a view that reacts to it reads both. @Published var error: String? + /// What the command or the system said, for Details. + @Published private(set) var errorDetail: String? + /// The running share, for `cancel`. + private var running: Task<Void, Never>? + + /// Starts a share that `cancel` can stop (it ends the `tools` process). + func start(note: URL) { + guard !busy else { return } + running = Task { await share(note: note) } + } + + /// Stops a running share without an error: the person chose to. + func cancel() { + running?.cancel() + running = nil + } func refresh() async { let up = await Self.probe() @@ -77,6 +120,7 @@ final class MarkdownShareModel: ObservableObject { guard !busy else { return } busy = true error = nil + errorDetail = nil defer { busy = false } let bridge = ToolsBridge(binaryPath: GenesisKit.host?.toolsBinaryPath ?? ToolsBridge.resolveBinaryPath(configured: nil)) @@ -85,7 +129,7 @@ final class MarkdownShareModel: ObservableObject { let result = try await bridge.run( subcommand: "dev-dashboard", args: ["share", note.path], - timeoutSeconds: 30 + timeoutSeconds: MarkdownDashboardShare.timeoutSeconds ) let outcome = MarkdownDashboardShare.outcome( exitCode: result.exitCode, @@ -93,38 +137,51 @@ final class MarkdownShareModel: ObservableObject { stderr: result.stderr ) + guard !Task.isCancelled else { return } guard let url = outcome.url else { - error = outcome.message + fail(outcome) return } - copyToClipboard(url) - openInBrave(url) + // The confirmation: "Copied share link" beside the pointer, whichever browser opens it. + Clipboard.copy(url.absoluteString, what: "share link") + open(url, in: GenesisKitMarkdownConfiguration.shared.shareOpensIn) + } catch is CancellationError { + GenesisKit.log("viewer: share cancelled") } catch { - self.error = error.localizedDescription + if Task.isCancelled { return } + fail(MarkdownDashboardShare.failure(error)) } } - private func copyToClipboard(_ url: URL) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(url.absoluteString, forType: .string) + private func fail(_ outcome: MarkdownDashboardShare.Outcome) { + errorDetail = outcome.detail + error = outcome.message } - private func openInBrave(_ url: URL) { - let app = URL(fileURLWithPath: MarkdownDashboardShare.braveAppPath) - - guard FileManager.default.fileExists(atPath: app.path) else { - error = "Brave Browser is not installed. The share URL is on the clipboard." - NSWorkspace.shared.open(url) + private func open(_ url: URL, in browser: MarkdownShareBrowser) { + switch browser { + case .none: return - } - - let config = NSWorkspace.OpenConfiguration() - config.activates = true - NSWorkspace.shared.open([url], withApplicationAt: app, configuration: config) { _, openError in - guard let openError else { return } - Task { @MainActor in - self.error = openError.localizedDescription + case .system: + NSWorkspace.shared.open(url) + case .brave: + let app = URL(fileURLWithPath: MarkdownDashboardShare.braveAppPath) + // No Brave: the system browser opens the link, which is a working share, not an error. + guard FileManager.default.fileExists(atPath: app.path) else { + GenesisKit.log("viewer: share opens in the system browser, Brave is not installed") + NSWorkspace.shared.open(url) + return + } + let config = NSWorkspace.OpenConfiguration() + config.activates = true + NSWorkspace.shared.open([url], withApplicationAt: app, configuration: config) { _, openError in + guard let openError else { return } + Task { @MainActor in + GenesisKit.log("viewer: Brave did not open the share link: \(openError)") + self.errorDetail = openError.localizedDescription + self.error = "Brave did not open the share link. It is on the clipboard." + } } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDefaultApp.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDefaultApp.swift index 4c8b92d0db..086ac08beb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDefaultApp.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDefaultApp.swift @@ -170,13 +170,16 @@ final class MarkdownDefaultAppDialog: NSObject { You can change this later with Genesis Markdown → Set as Default App…. """ alert.addButton(withTitle: "Make Default…") - alert.addButton(withTitle: "Not Now") + // It never asks again, so the button says so ("Not Now" promised a later reminder). + alert.addButton(withTitle: "Don't Ask Again") + // It came by itself: Quit and an update do not wait for an answer. + alert.window.preventsApplicationTerminationWhenModal = false // Recorded before the answer, so a crash or a force quit in the // middle cannot bring the question back. defaults.set(true, forKey: MarkdownDefaultApp.promptAnsweredKey) GenesisKit.log("viewer: default-app prompt shown (not default for \(missing))") let response = alert.runModal() - GenesisKit.log("viewer: default-app prompt answered: \(response == .alertFirstButtonReturn ? "make default" : "not now")") + GenesisKit.log("viewer: default-app prompt answered: \(response == .alertFirstButtonReturn ? "make default" : "don't ask again")") if response == .alertFirstButtonReturn { showChooser(nil) } @@ -212,11 +215,18 @@ final class MarkdownDefaultAppDialog: NSObject { alert.accessoryView = stack alert.addButton(withTitle: "Set as Default") alert.addButton(withTitle: "Cancel") + alert.window.preventsApplicationTerminationWhenModal = false + // "Set as Default" works only while a box is ticked: with none it would close and do nothing. + let ticks = ChooserTicks(button: alert.buttons.first, boxes: boxes.map(\.box)) if statuses.allSatisfy(\.isDefault) { alert.informativeText = "Genesis Markdown already opens every one of these file types." alert.buttons.first?.isEnabled = false + } else { + ticks.changed(nil) } - guard alert.runModal() == .alertFirstButtonReturn else { return } + let response = alert.runModal() + withExtendedLifetime(ticks) {} + guard response == .alertFirstButtonReturn else { return } let chosen = boxes.filter { $0.box.isEnabled && $0.box.state == .on }.map(\.ext) guard !chosen.isEmpty else { return } @@ -235,7 +245,7 @@ final class MarkdownDefaultAppDialog: NSObject { } else if status.isDefault { title += " (default already)" } else { - title += " (now opens in \(status.handlerName ?? "no app"))" + title += status.handlerName.map { " (now opens in \($0))" } ?? " (no app opens it yet)" } let siblings = MarkdownDefaultApp.sharesType(status.ext, in: statuses) if !siblings.isEmpty { @@ -244,23 +254,57 @@ final class MarkdownDefaultAppDialog: NSObject { return title } + /// One plain sentence per refusal; the raw error stays in the log. + private static func plainReason(_ error: Error?) -> String { + switch error { + case nil: "macOS kept the previous app" + case is MarkdownDefaultApp.DefaultAppError: "macOS does not know this file type" + default: "macOS refused the change, so the previous app stays" + } + } + private func showResult(chosen: [String], failures: [String: Error]) { let after = currentStatuses() let set = chosen.filter { ext in after.first { $0.ext == ext }?.isDefault == true } let notSet = chosen.filter { !set.contains($0) } + // A success is a note that fades, never a dialog to dismiss. + guard !notSet.isEmpty else { + let opens = "It opens " + set.map { ".\($0)" }.joined(separator: ", ") + " files." + CopyToast.post(title: "Genesis Markdown is now the default app", detail: opens, style: .copied) + GenAnnounce.post("Genesis Markdown is now the default app. \(opens)") + return + } let alert = NSAlert() - if notSet.isEmpty { - alert.messageText = "Genesis Markdown is now the default app" - alert.informativeText = "It opens " + set.map { ".\($0)" }.joined(separator: ", ") + " files." - } else { - alert.alertStyle = .warning - alert.messageText = "Some file types still open in another app" - let lines = notSet.map { ext in - ".\(ext): " + (failures[ext]?.localizedDescription ?? "macOS kept the previous app") - } - alert.informativeText = lines.joined(separator: "\n") + alert.alertStyle = .warning + alert.messageText = "Some file types still open in another app" + let lines = notSet.map { ext in + ".\(ext): " + Self.plainReason(failures[ext]) } - alert.addButton(withTitle: "OK") + // The system's own error text is in the log (`viewer: default app refused for …`), not in the dialog. + alert.informativeText = lines.joined(separator: "\n") + "\n\nYou can change it in Finder with Get Info, Open with, Change All." + alert.addButton(withTitle: "Close") + alert.window.preventsApplicationTerminationWhenModal = false alert.runModal() } } + +/// Keeps the chooser's "Set as Default" enabled only while an enabled box is ticked. +@MainActor +private final class ChooserTicks: NSObject { + weak var button: NSButton? + let boxes: [NSButton] + + init(button: NSButton?, boxes: [NSButton]) { + self.button = button + self.boxes = boxes + super.init() + for box in boxes where box.isEnabled { + box.target = self + box.action = #selector(changed(_:)) + } + } + + @objc func changed(_ sender: Any?) { + button?.isEnabled = boxes.contains { $0.isEnabled && $0.state == .on } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDrop.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDrop.swift index d026a35ffa..e2cb644836 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDrop.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownDrop.swift @@ -62,9 +62,26 @@ final class MarkdownDropWebView: WKWebView { var onDropFiles: (([URL]) -> Void)? override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - MarkdownDrop.urls(from: sender.draggingPasteboard).isEmpty - ? super.draggingEntered(sender) - : .copy + guard !MarkdownDrop.urls(from: sender.draggingPasteboard).isEmpty else { return super.draggingEntered(sender) } + showsDropTarget(true) + return .copy + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + showsDropTarget(false) + super.draggingExited(sender) + } + + override func draggingEnded(_ sender: NSDraggingInfo) { + showsDropTarget(false) + super.draggingEnded(sender) + } + + /// An accent outline while files are dragged over the page, so the window says it takes them. + private func showsDropTarget(_ on: Bool) { + wantsLayer = true + layer?.borderColor = NSColor.controlAccentColor.cgColor + layer?.borderWidth = on ? 2 : 0 } override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { @@ -76,6 +93,7 @@ final class MarkdownDropWebView: WKWebView { override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { let urls = MarkdownDrop.urls(from: sender.draggingPasteboard) guard !urls.isEmpty else { return super.performDragOperation(sender) } + showsDropTarget(false) onDropFiles?(urls) return true } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownExport.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownExport.swift index bfd28dc7ad..e5603d503b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownExport.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownExport.swift @@ -112,8 +112,10 @@ enum MarkdownExport { proxy.exportPDF(to: target) { error in if let error { onError(error.localizedDescription) - } else { + } else if GenesisKitMarkdownConfiguration.shared.revealSavedPDF { NSWorkspace.shared.activateFileViewerSelecting([target]) + } else { + CopyToast.post(title: "Saved the PDF", detail: PathDisplay.tilde(target.path), style: .copied) } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownFileOps.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownFileOps.swift index e1cd5613f1..ca795f0884 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownFileOps.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Document/MarkdownFileOps.swift @@ -61,13 +61,16 @@ extension MarkdownDocumentsModel { } } - /// Every tab but `keep`, each asking first when it has unsaved edits; stops at the first cancel. - func closeOthers(_ keep: MarkdownDocument) { - for doc in docs where doc.id != keep.id { - if !close(doc) { - return - } - } + /// Every tab but `keep`, each asking first when it has unsaved edits; stops at the first cancel. One action: + /// one note with Undo, and ⇧⌘T reopens them all (`closeTabs`, UX audit W3). + func closeOthers(_ keep: MarkdownDocument, confirm: @MainActor (MarkdownDocument) -> UnsavedChoice = MarkdownDocumentsModel.askUnsaved) { + closeTabs(docs.filter { $0.id != keep.id }, confirm: confirm) + } + + /// "Close 5 Other Tabs": the menus name how many go. + var closeOthersTitle: String { + let others = max(docs.count - 1, 0) + return others == 1 ? "Close Other Tab" : "Close \(others) Other Tabs" } } @@ -79,7 +82,8 @@ struct MarkdownTabMenu: View { var body: some View { Button("Close Tab") { model.close(doc) } - Button("Close Other Tabs") { model.closeOthers(doc) } + Button(model.closeOthersTitle) { model.closeOthers(doc) } + .disabled(model.docs.count < 2) Divider() PathActionsMenu(url: doc.url, kind: .file, actions: model.pathActions(for: .file), handlers: model.pathHandlers(for: doc.url, kind: .file)) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift index d167f89d9b..48d1b49ccf 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/GenesisKitMarkdownConfiguration.swift @@ -26,6 +26,24 @@ public enum MarkdownToolbarLabels: String, CaseIterable, Sendable { case always } +/// Where a dashboard share link opens once it is on the clipboard. +public enum MarkdownShareBrowser: String, CaseIterable, Sendable { + /// Brave, or the system browser when Brave is not installed. The default. + case brave + /// The system's default browser. + case system + /// Nowhere: the link is only copied. + case none + + public var title: String { + switch self { + case .brave: return "Brave" + case .system: return "Default browser" + case .none: return "Do not open" + } + } +} + @MainActor public final class GenesisKitMarkdownConfiguration: ObservableObject { public static let shared = GenesisKitMarkdownConfiguration() @@ -49,6 +67,30 @@ public final class GenesisKitMarkdownConfiguration: ObservableObject { /// A single click in the file tree opens a preview tab (italic title) that the next single click /// replaces. An edit, a double-click or a double-click on the tab keeps it. @Published public var previewTabs: Bool { didSet { store(previewTabs, "previewTabs") } } + /// How tabs fall into groups by themselves (`MarkdownTabGrouping`). `.off` keeps one flat row. + @Published public var tabGrouping: MarkdownTabGroupRule { didSet { store(tabGrouping.rawValue, "tabGrouping") } } + /// Groups appear once this many tabs are open; fewer stay one plain row. 0 groups always. + @Published public var tabGroupFrom: Int { didSet { store(tabGroupFrom, "tabGroupFrom") } } + /// An automatic group smaller than this joins "Other". + @Published public var tabGroupMinimum: Int { didSet { store(tabGroupMinimum, "tabGroupMinimum") } } + /// A Project group with more tabs than this splits by its next folder. + @Published public var tabGroupSplitAbove: Int { didSet { store(tabGroupSplitAbove, "tabGroupSplitAbove") } } + /// Wrapped tabs: groups flow one after another (the default), or each open group starts its own rows. + @Published public var tabGroupRows: Bool { didSet { store(tabGroupRows, "tabGroupRows") } } + /// Wrapped tabs take at most this many rows and scroll past them, with a fade at the cut edge. 0 is no limit. + /// Picking a row count drops a dragged height. + @Published public var wrapTabsMaxRows: Int { + didSet { + store(wrapTabsMaxRows, "wrapTabsMaxRows") + tabBarHeight = 0 + } + } + /// The wrapped bar's tallest height in points, dragged on its bottom edge. 0 follows `wrapTabsMaxRows`. + @Published public var tabBarHeight: Double { didSet { store(tabBarHeight, "tabBarHeight") } } + /// The reader's in-place edits: group order, collapsed groups, names, tabs moved by hand. + @Published public var tabGroupState: MarkdownTabGroupState { + didSet { if let data = try? JSONEncoder().encode(tabGroupState) { store(data, "tabGroupState") } } + } // MARK: Chrome @@ -70,6 +112,11 @@ public final class GenesisKitMarkdownConfiguration: ObservableObject { /// Ticks on the page's scrollbar where the find matches are. @Published public var scrollbarMatchMarks: Bool { didSet { store(scrollbarMatchMarks, "scrollbarMatchMarks") } } + // MARK: Editor + + /// The source editor opens the list of notes after `[[` and of tags after `#word` by itself. Off: Esc opens it. + @Published public var completeWhileTyping: Bool { didSet { store(completeWhileTyping, "completeWhileTyping") } } + // MARK: Git /// Every git control: the file's state, the gutter, the Changes mode, the history and the tree marks. A note @@ -89,6 +136,13 @@ public final class GenesisKitMarkdownConfiguration: ObservableObject { git && self[keyPath: part] } + // MARK: Sharing and export + + /// Where a dashboard share link opens. + @Published public var shareOpensIn: MarkdownShareBrowser { didSet { store(shareOpensIn.rawValue, "shareOpensIn") } } + /// A saved PDF is shown in Finder, which comes to the front. Off: a note says it was saved. + @Published public var revealSavedPDF: Bool { didSet { store(revealSavedPDF, "revealSavedPDF") } } + // MARK: Path actions /// What a folder offers (a path segment, a tree folder), in menu order. @@ -109,6 +163,16 @@ public final class GenesisKitMarkdownConfiguration: ObservableObject { hoverPreviewDelay = double("hoverPreviewDelay", 1.0) wrapTabs = bool("wrapTabs", false) previewTabs = bool("previewTabs", true) + tabGrouping = string("tabGrouping").flatMap(MarkdownTabGroupRule.init(rawValue:)) ?? .smart + func int(_ key: String, _ fallback: Int) -> Int { defaults.object(forKey: Self.prefix + key) as? Int ?? fallback } + tabGroupFrom = int("tabGroupFrom", 10) + tabGroupMinimum = int("tabGroupMinimum", 2) + tabGroupSplitAbove = int("tabGroupSplitAbove", 12) + tabGroupRows = bool("tabGroupRows", false) + wrapTabsMaxRows = int("wrapTabsMaxRows", 4) + tabBarHeight = double("tabBarHeight", 0) + tabGroupState = defaults.data(forKey: Self.prefix + "tabGroupState") + .flatMap { try? JSONDecoder().decode(MarkdownTabGroupState.self, from: $0) } ?? MarkdownTabGroupState() contrast = string("contrast").flatMap(MarkdownContrast.init(rawValue:)) ?? .high toolbarLabels = string("toolbarLabels").flatMap(MarkdownToolbarLabels.init(rawValue:)) ?? .whenRoom treeFontScalesWithWidth = bool("treeFontScalesWithWidth", true) @@ -117,11 +181,14 @@ public final class GenesisKitMarkdownConfiguration: ObservableObject { codeBlockBar = bool("codeBlockBar", true) stickySection = bool("stickySection", true) scrollbarMatchMarks = bool("scrollbarMatchMarks", true) + completeWhileTyping = bool("completeWhileTyping", true) git = bool("git", true) gitGutter = bool("gitGutter", true) gitTreeDecorations = bool("gitTreeDecorations", true) gitAgentAttribution = bool("gitAgentAttribution", true) gitDefaultScope = string("gitDefaultScope").flatMap(GitFileScope.init(rawValue:)) ?? .uncommitted + shareOpensIn = string("shareOpensIn").flatMap(MarkdownShareBrowser.init(rawValue:)) ?? .brave + revealSavedPDF = bool("revealSavedPDF", true) } private func store(_ value: Any, _ key: String) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/MarkdownSupport.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/MarkdownSupport.swift index 7e389480bf..8fbecfcbb6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/MarkdownSupport.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Support/MarkdownSupport.swift @@ -37,9 +37,9 @@ public enum MarkdownRenderer: String, CaseIterable, Identifiable, Sendable { public var detail: String { switch self { - case .web: "WKWebView — mermaid + hljs (offline)" - case .native: "Native Swift — flat code, no diagrams" - case .nativeRich: "Native — BeautifulMermaid + hljs" + case .web: "Web: diagrams and highlighted code, works offline" + case .native: "Native: plain code, no diagrams" + case .nativeRich: "Native+: diagrams and highlighted code, drawn natively" } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/FileTreeSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/FileTreeSearch.swift index e650a5a286..5d93993895 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/FileTreeSearch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/FileTreeSearch.swift @@ -44,6 +44,19 @@ enum FileTreeSearch { static let maxNameHits = 200 static let maxTextHits = 300 + /// What a search never looks at, said under an empty or short result so "No match" is not read as "not there". + static let leftOut = "Hidden folders, node_modules, build output and files over 2 MB are not searched." + + /// ripgrep's exit status 2 is an error; with `--no-messages` it is also 2 when a file could not be read, so it + /// counts as a failure only when nothing was found. + static func ripgrepFailed(status: Int32, hits: Int) -> Bool { status == 2 && hits == 0 } + + /// A search that could not run: one sentence, and the raw text for Details. + struct Failure: Equatable { + let message: String + let detail: String? + } + static func skips(directoryNamed name: String) -> Bool { skippedFolders.contains(name) || name.hasPrefix(".") } @@ -207,6 +220,11 @@ final class FileTreeSearchModel: ObservableObject { @Published private(set) var truncated = false /// Names mode found no file name, so the hits are text matches inside files. @Published private(set) var fellBackToText = false + /// Names mode stopped at `maxNameHits` names. + @Published private(set) var namesCut = false + /// The search could not run (ripgrep did not start, or stopped with an error): one sentence, and the raw text for + /// Details. Not the same as "No match". + @Published private(set) var failure: FileTreeSearch.Failure? /// Files the name index holds for the current root (0 before the first search). @Published private(set) var indexedCount = 0 /// The folder "Search in Folder…" limits the search to, nil for the whole tree (the scope chip). @@ -260,6 +278,8 @@ final class FileTreeSearchModel: ObservableObject { searching = false truncated = false fellBackToText = false + namesCut = false + failure = nil } /// Debounced by the view: called on every keystroke, it cancels the @@ -277,6 +297,8 @@ final class FileTreeSearchModel: ObservableObject { generation += 1 let gen = generation fellBackToText = false + namesCut = false + failure = nil searching = true let rootPath = root switch mode { @@ -303,6 +325,7 @@ final class FileTreeSearchModel: ObservableObject { self.hits = ranked.map { FileTreeSearch.Hit(path: rootPath + "/" + $0, relative: $0, line: nil, snippet: nil) } + self.namesCut = ranked.count >= FileTreeSearch.maxNameHits self.searching = false } case .text: @@ -369,20 +392,27 @@ final class FileTreeSearchModel: ObservableObject { proc.executableURL = URL(fileURLWithPath: rg) proc.arguments = FileTreeSearch.ripgrepArguments(query: query, root: root) let pipe = Pipe() + let errors = Pipe() proc.standardOutput = pipe - proc.standardError = FileHandle.nullDevice + // `--no-messages` keeps per-file complaints out, so what is left here is short: why the run failed. + proc.standardError = errors process = proc searchTask = Task.detached(priority: .userInitiated) { [weak self] in do { try proc.run() } catch { + let detail = "\(rg): \(error.localizedDescription)" await MainActor.run { [weak self] in guard let self, self.generation == gen else { return } - self.hits = []; self.searching = false + self.hits = [] + self.searching = false + self.failure = FileTreeSearch.Failure(message: "The text search could not start.", detail: detail) } return } // Read everything, then cut: rg is done long before 300 hits cost anything. let data = pipe.fileHandleForReading.readDataToEndOfFile() + let errorText = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) proc.waitUntilExit() + let status = proc.terminationReason == .exit ? proc.terminationStatus : 0 let text = String(decoding: data, as: UTF8.self) var hits: [FileTreeSearch.Hit] = [] var cut = false @@ -394,11 +424,17 @@ final class FileTreeSearchModel: ObservableObject { } let result = hits let wasCut = cut + let failed = FileTreeSearch.ripgrepFailed(status: status, hits: result.count) await MainActor.run { [weak self] in guard let self, self.generation == gen else { return } self.hits = result self.truncated = wasCut self.searching = false + if failed { + let detail = errorText.trimmingCharacters(in: .whitespacesAndNewlines) + self.failure = FileTreeSearch.Failure(message: "The text search stopped with an error.", + detail: detail.isEmpty ? "ripgrep exited with status 2" : detail) + } } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownChangesPane.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownChangesPane.swift index 26a7b85695..a28fb5cdf2 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownChangesPane.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownChangesPane.swift @@ -110,7 +110,10 @@ struct MarkdownChangesPane: View { if let compare { diff.loadCompare(doc.url, workingText: model.workingText(for: doc), old: compare.old, new: compare.new) } else { - diff.load(doc.url, workingText: model.workingText(for: doc), scope: currentScope, commit: currentScope == .lastCommit ? model.changesCommit : nil) + // A file moved outside git reads its old side at the path it came from (GitWorktreeRenames). + let movedFrom = repos.model(for: doc.folder)?.status?.entry(for: doc.url).flatMap { $0.unstaged == .renamed ? $0.oldPath : nil } + diff.load(doc.url, workingText: model.workingText(for: doc), scope: currentScope, + commit: currentScope == .lastCommit ? model.changesCommit : nil, renamedFrom: movedFrom) } } @@ -161,13 +164,15 @@ struct MarkdownChangesPane: View { private var content: some View { switch diff.phase { case .idle, .loading: - ProgressView() - .controlSize(.small) - .frame(maxWidth: .infinity, maxHeight: .infinity) + MarkdownDelayedLoading(text: "Reading the changes", color: model.theme.textSecondary) case .empty(let reason): message(reason, systemImage: "checkmark.circle") case .failed(let error): - message(error, systemImage: "exclamationmark.triangle") + // One sentence, Try again, and git's own words behind Details (`GitFailureNotice`). + GitFailureNotice(diff.failure ?? GitFailure(message: error)) { diff.retry() } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { GenAnnounce.post(error) } case .ready: if let file = diff.file { if isNote && rendered { @@ -229,3 +234,28 @@ struct MarkdownChangesPane: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +/// A spinner that waits 200 ms before it shows, then names the work: a diff that takes 50 ms never flashes one. +struct MarkdownDelayedLoading: View { + let text: String + let color: Color + @State private var shown = false + + var body: some View { + ZStack { + if shown { + VStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(text).font(.system(size: 11)).foregroundColor(color) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(text) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + try? await Task.sleep(for: .milliseconds(200)) + if !Task.isCancelled { shown = true } + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownConflictView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownConflictView.swift index f5dfd3b5a3..230f2c6f63 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownConflictView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownConflictView.swift @@ -13,6 +13,7 @@ import SwiftUI struct MarkdownConflictBanner: View { @ObservedObject var doc: MarkdownDocument @ObservedObject var model: MarkdownDocumentsModel + @State private var discard = MarkdownDiscardEdits() var body: some View { HStack(spacing: 10) { @@ -25,18 +26,20 @@ struct MarkdownConflictBanner: View { action("Compare", id: "markdown-conflict-compare", tip: "Show my edits against the version on disk") { model.compareOpen = true } action("Merge", id: "markdown-conflict-merge", tip: "Combine both versions; conflicts are marked in the text") { doc.mergeDiskChange() } action("Keep mine", id: "markdown-conflict-keep", tip: "Keep my edits; the next save overwrites the disk file") { doc.keepMine() } - action("Reload", id: "markdown-conflict-reload", destructive: true, tip: "Discard my edits and load the version on disk") { doc.takeDisk() } + action(MarkdownDiscardEdits.verb, id: "markdown-conflict-reload", destructive: true, + tip: "Throw away my edits and load the version on disk. Asks first") { discard.ask(.takeDisk) } } else if doc.mergeConflicts > 0 { // After a merge `text` already is the disk version, so the // watcher's `reload` would see nothing new and do nothing. - action("Reload from disk", id: "markdown-conflict-reload", destructive: true, tip: "Discard the merged text and load the file from disk") { - doc.discardDraft() - } + action(MarkdownDiscardEdits.verb, id: "markdown-conflict-reload", destructive: true, + tip: "Throw away the merged text and load the file from disk. Asks first") { discard.ask(.discardDraft) } } else { action("Save", id: "markdown-missing-save") { model.saveSelected() } - action("Discard", id: "markdown-missing-discard", destructive: true) { doc.discardDraft() } + action("Discard edits", id: "markdown-missing-discard", destructive: true, + tip: "Throw away my edits; the file stays gone. Asks first") { discard.ask(.discardDraft) } } } + .markdownDiscardEditsAlert($discard, doc: doc) .font(GenTypography.body(12)) .foregroundColor(model.theme.text) .padding(.horizontal, 12) @@ -46,6 +49,9 @@ struct MarkdownConflictBanner: View { Rectangle().fill(Color.genWarning.opacity(0.4)).frame(height: 1) } .accessibilityIdentifier("markdown-conflict-banner") + // The banner comes by itself (an edit on disk, a deleted file), so VoiceOver is told. + .onAppear { GenAnnounce.post(message) } + .onChange(of: message) { _, now in GenAnnounce.post(now) } } private var message: String { @@ -82,6 +88,7 @@ struct MarkdownConflictBanner: View { struct MarkdownCompareSheet: View { @ObservedObject var doc: MarkdownDocument @ObservedObject var model: MarkdownDocumentsModel + @State private var discard = MarkdownDiscardEdits() var body: some View { VStack(spacing: 0) { @@ -110,8 +117,9 @@ struct MarkdownCompareSheet: View { Button("Close") { model.compareOpen = false } .keyboardShortcut(.cancelAction) if doc.hasDiskChange { - Button("Reload") { doc.takeDisk(); model.compareOpen = false } - .instantTooltip("Discard my edits and load the version on disk") + Button(MarkdownDiscardEdits.verb) { discard.ask(.takeDisk) } + .instantTooltip("Throw away my edits and load the version on disk. Asks first") + .accessibilityIdentifier("markdown-compare-reload") Button("Keep mine") { doc.keepMine(); model.compareOpen = false } .instantTooltip("Keep my edits; the next save overwrites the disk file") Button("Merge") { doc.mergeDiskChange(); model.compareOpen = false } @@ -124,6 +132,7 @@ struct MarkdownCompareSheet: View { } .frame(minWidth: 720, idealWidth: 900, minHeight: 420, idealHeight: 640) .foregroundColor(model.theme.text) + .markdownDiscardEditsAlert($discard, doc: doc) { model.compareOpen = false } .accessibilityIdentifier("markdown-compare-sheet") } @@ -159,3 +168,77 @@ struct MarkdownCompareSheet: View { .background(tint.map { $0.opacity(0.12) } ?? Color.clear) } } + +/// Throwing away unsaved edits (Discard edits and reload, after a change on disk, a merge or a deleted file) asks +/// first: outside the source editor nothing brings the draft back. The alert names +/// the loss in changed lines, and its default button keeps the edits. +struct MarkdownDiscardEdits: Equatable { + /// One verb for one loss, on the banner, the compare sheet and the alert. + static let verb = "Discard edits and reload" + + enum Way: Equatable { + /// Load the version on disk the watcher reported (`MarkdownDocument.takeDisk`). + case takeDisk + /// Drop the draft and read the file again (`MarkdownDocument.discardDraft`): after a merge, or a deleted file. + case discardDraft + } + + var pending: Way? + + mutating func ask(_ way: Way) { pending = way } + + /// Lines that differ between the draft and the disk version the alert counts. + static func changedLines(_ diff: [TextMerge.DiffLine]) -> Int { + diff.reduce(0) { count, line in + if case .equal = line { return count } + return count + 1 + } + } + + static func message(changedLines n: Int, missing: Bool) -> String { + let lines = n == 1 ? "1 changed line" : "\(n) changed lines" + return missing + ? "Your unsaved edits (\(lines)) are thrown away, and the file stays gone from disk." + : "Your unsaved edits (\(lines)) are thrown away and the file is read from disk again." + } + + static func confirmTitle(missing: Bool) -> String { missing ? "Discard edits" : verb } +} + +private struct MarkdownDiscardEditsAlert: ViewModifier { + @Binding var state: MarkdownDiscardEdits + @ObservedObject var doc: MarkdownDocument + let after: () -> Void + + func body(content: Content) -> some View { + let missing = doc.missing + content.alert( + missing ? "Discard your edits?" : "Discard your edits and reload?", + isPresented: Binding(get: { state.pending != nil }, set: { if !$0 { state.pending = nil } }) + ) { + // Return keeps the edits: the safe choice is the default, the loss is never on Return. + Button("Keep my edits", role: .cancel) { state.pending = nil } + .keyboardShortcut(.defaultAction) + Button(MarkdownDiscardEdits.confirmTitle(missing: missing), role: .destructive) { + let way = state.pending + state.pending = nil + switch way { + case .takeDisk: doc.takeDisk() + case .discardDraft: doc.discardDraft() + case nil: return + } + after() + } + } message: { + Text(MarkdownDiscardEdits.message(changedLines: MarkdownDiscardEdits.changedLines(doc.diskDiff), missing: missing)) + } + } +} + +extension View { + /// The confirmation in front of `MarkdownDocument.takeDisk` and `discardDraft`. `after` runs once the edits are gone. + func markdownDiscardEditsAlert(_ state: Binding<MarkdownDiscardEdits>, doc: MarkdownDocument, + after: @escaping () -> Void = {}) -> some View { + modifier(MarkdownDiscardEditsAlert(state: state, doc: doc, after: after)) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownContentView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownContentView.swift index beeffebd19..8be3a235d9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownContentView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownContentView.swift @@ -397,9 +397,17 @@ struct MarkdownBlockView: View { Image(systemName: "list.bullet.rectangle") .font(.system(size: 9)) .foregroundColor(style.mutedColor) + .accessibilityLabel("Properties") ForEach(keys.prefix(8), id: \.self) { k in TagPill(text: k, color: .neonPurple) } + // Nothing is dropped without a word: the keys past the eighth are counted. + if keys.count > 8 { + Text("+\(keys.count - 8) more") + .font(.system(size: 11)) + .foregroundColor(style.mutedColor) + .instantTooltip(keys.dropFirst(8).joined(separator: ", ")) + } } } case .table(let header, let aligns, let rows, _): @@ -416,7 +424,7 @@ struct MarkdownBlockView: View { ForEach(0..<columns, id: \.self) { col in let unit = firstUnit + col Text(found(MarkdownRenderCache.headerCell(cell(header, col), wikilinks: style.wikilinks), unit)) - .font(GenTypography.mono(10, weight: .medium)) + .font(GenTypography.mono(11, weight: .medium)) .foregroundColor(style.mutedColor) .tracking(0.6) .frame(maxWidth: .infinity, alignment: frameAlignment(align(col, aligns))) @@ -534,7 +542,8 @@ struct MarkdownCodeBlock: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(style.codePadding) } else { - ScrollView(.horizontal, showsIndicators: false) { + // The indicator is the cue that a long line goes on past the edge. + ScrollView(.horizontal, showsIndicators: true) { codeView.padding(style.codePadding) } } @@ -574,7 +583,7 @@ struct MarkdownCopyButton: View { Image(systemName: copied ? "checkmark" : "doc.on.doc") .font(.system(size: 9.5, weight: .semibold)) Text(copied ? "Copied" : "Copy") - .font(.system(size: 10.5, weight: .medium)) + .font(.system(size: 11, weight: .medium)) } .foregroundColor(copied ? doneTint : tint) .padding(.horizontal, 6) @@ -598,6 +607,8 @@ struct MarkdownCopyButton: View { board.clearContents() board.setString(text, forType: .string) withAnimation(reduceMotion ? nil : KitTheme.quick) { copied = true } + // A label change on the focused button is not spoken; the result is. + AccessibilityNotification.Announcement("Copied").post() } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift index 7dd7c126fd..286deba6ba 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownDocumentToolbar.swift @@ -81,6 +81,7 @@ struct MarkdownDocumentToolbar: View { @ObservedObject private var repos = GitRepos.shared /// Observed so a key changed in Keyboard Shortcuts redraws these tooltips. @ObservedObject private var keymap = MarkdownKeymap.shared + @GenReduceMotion private var reduceMotion /// Read, Split, Source, and Changes while git is on and the file is in a repository (or Changes is open). private var modes: [MarkdownViewMode] { @@ -100,22 +101,14 @@ struct MarkdownDocumentToolbar: View { } } .layoutPriority(1) - // The window runs every other key (`MarkdownKeymap.registerWindowCommands`); these two need the share - // model's error line. - .keymapAction("markdown.file.pdf", in: MarkdownKeymap.shared, perform: exportPDF) - .keymapAction("markdown.file.print", in: MarkdownKeymap.shared, perform: printDocument) Spacer(minLength: 8) - - if let error = share.error ?? model.exportError { - Text(error) - .font(GenTypography.mono(9.5)) - .foregroundColor(model.theme.accent) - .lineLimit(1) - .truncationMode(.tail) - .instantTooltip(error) - .accessibilityIdentifier("markdown-export-error") - } + } + // A failed share goes to the window's message slot (`MarkdownNoticeSlot`) with the rest: one sentence, the + // command's own words behind Details, kept until it is closed. + .onChange(of: share.error) { _, error in + guard let error else { return } + model.post(Self.shareNotice(error, detail: share.errorDetail)) } .padding(.horizontal, 10) .padding(.vertical, 7) @@ -151,14 +144,14 @@ struct MarkdownDocumentToolbar: View { "sidebar.left", label: "Files", tint: model.showFiles ? model.theme.accent : nil, tooltip: keymap.hint("Files", "markdown.view.files"), id: "markdown-files-toggle", labels: labels, - action: toggleFiles + isOn: model.showFiles, action: toggleFiles ) groupDivider toolButton( "list.bullet.indent", label: "Outline", tint: model.showOutline ? model.theme.accent : nil, tooltip: keymap.hint("Outline", "markdown.view.outline"), id: "markdown-outline-toggle", labels: labels, - action: toggleOutline + isOn: model.showOutline, action: toggleOutline ) if modes.contains(.changes) { groupDivider @@ -166,7 +159,7 @@ struct MarkdownDocumentToolbar: View { "arrow.triangle.branch", label: "Git", tint: model.showGit ? model.theme.accent : nil, tooltip: "Git: this file's history and the repository", id: "markdown-git-toggle", labels: labels, - action: { withAnimation(GenAnimation.quick) { model.showGit.toggle() } } + isOn: model.showGit, action: { GenMotion.perform(expanding: !model.showGit, reduceMotion: reduceMotion) { model.showGit.toggle() } } ) } } @@ -182,7 +175,7 @@ struct MarkdownDocumentToolbar: View { "square.and.arrow.down", label: "Save", tint: doc.isDirty ? model.theme.accent : model.theme.textMuted, tooltip: keymap.hint("Save", "markdown.file.save") - + (doc.hasDiskChange ? " — resolve the change on disk first" : ""), + + (doc.hasDiskChange ? ". Resolve the change on disk first." : ""), id: "markdown-save", labels: labels ) { model.saveSelected() @@ -192,7 +185,7 @@ struct MarkdownDocumentToolbar: View { model.floating ? "pin.fill" : "pin", label: "On Top", tint: model.floating ? model.theme.accent : nil, tooltip: model.floating ? "Keep on top: on" : "Keep on top: off", - id: "markdown-float-toggle", labels: labels + id: "markdown-float-toggle", labels: labels, isOn: model.floating ) { model.floating.toggle() } @@ -236,9 +229,9 @@ struct MarkdownDocumentToolbar: View { model.zoomReset() } label: { Text(MarkdownZoom.percent(model.zoom)) - .font(GenTypography.mono(9, weight: .medium)) + .font(GenTypography.mono(11, weight: .medium)) .foregroundColor(model.zoom == MarkdownZoom.base ? model.theme.textSecondary : model.theme.accent) - .frame(minWidth: 34, minHeight: 18) + .frame(minWidth: 34, minHeight: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) @@ -256,28 +249,49 @@ struct MarkdownDocumentToolbar: View { toolButton( "arrow.down.doc", label: "PDF", tint: model.canExport ? nil : model.theme.textMuted, - tooltip: keymap.hint("Save as PDF", "markdown.file.pdf"), id: "markdown-export-pdf", labels: labels, + tooltip: Self.exportTooltip(keymap.hint("Save as PDF", "markdown.file.pdf"), canExport: model.canExport, what: "PDF"), + id: "markdown-export-pdf", labels: labels, action: exportPDF ) groupDivider toolButton( "printer", label: "Print", tint: model.canExport ? nil : model.theme.textMuted, - tooltip: keymap.hint("Print", "markdown.file.print"), id: "markdown-print", labels: labels, + tooltip: Self.exportTooltip(keymap.hint("Print", "markdown.file.print"), canExport: model.canExport, what: "Printing"), + id: "markdown-print", labels: labels, action: printDocument ) - if share.available { + if share.available || share.busy { groupDivider - toolButton( - "square.and.arrow.up", label: "Share", - tint: share.busy ? model.theme.textMuted : nil, - tooltip: "Share on the dev dashboard", id: "markdown-share", labels: labels - ) { - let note = doc.url - model.exportError = nil - Task { await share.share(note: note) } + if share.busy { + // A share can take a while: say it is running and offer a way out (UX audit D3). + HStack(spacing: 5) { + ProgressView().controlSize(.mini) + Text("Sharing").font(.system(size: 11, weight: .medium)) + .foregroundColor(model.theme.textSecondary) + .fixedSize() + Button("Cancel") { share.cancel() } + .buttonStyle(.genHoverPlain()) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(model.theme.accent) + .instantTooltip("Stop sharing this note") + .accessibilityIdentifier("markdown-share-cancel") + } + .padding(.horizontal, 4) + .accessibilityElement(children: .contain) + .accessibilityLabel("Sharing") + .accessibilityIdentifier("markdown-share-busy") + } else { + toolButton( + "square.and.arrow.up", label: "Share", + tint: nil, + tooltip: "Share on the dev dashboard", id: "markdown-share", labels: labels + ) { + model.exportError = nil + share.error = nil + share.start(note: doc.url) + } } - .disabled(share.busy) } } } @@ -286,27 +300,36 @@ struct MarkdownDocumentToolbar: View { } private func toggleFiles() { - withAnimation(GenAnimation.quick) { model.showFiles.toggle() } + GenMotion.perform(expanding: !model.showFiles, reduceMotion: reduceMotion) { model.showFiles.toggle() } } private func toggleOutline() { - withAnimation(GenAnimation.quick) { model.showOutline.toggle() } + GenMotion.perform(expanding: !model.showOutline, reduceMotion: reduceMotion) { model.showOutline.toggle() } } private func revealInFinder() { NSWorkspace.shared.activateFileViewerSelecting([doc.url]) } + /// A share failure for the slot: the share model's sentence for the cause, the command's own words behind + /// Details (`MarkdownDashboardShare.outcome`). + static func shareNotice(_ error: String, detail: String?) -> MarkdownNotice { + MarkdownNotice(error, tone: .error, more: detail) + } + + /// Why PDF and Print are muted: they need the page the Web renderer draws (UX audit D6). + static func exportTooltip(_ hint: String, canExport: Bool, what: String) -> String { + canExport ? hint : hint + ". " + what + " needs the reading view with the Web renderer." + } + private func exportPDF() { share.error = nil - model.exportError = nil - MarkdownExport.savePDF(from: model.webProxy, note: doc.url, in: NSApp.keyWindow) { model.exportError = $0 } + model.exportPDF() } private func printDocument() { share.error = nil - model.exportError = nil - model.webProxy.printDocument(title: doc.title, in: NSApp.keyWindow) { model.exportError = $0 } + model.printDocument() } /// The reading engine, cycled by a click. Always named: the name is the state. @@ -318,15 +341,15 @@ struct MarkdownDocumentToolbar: View { Image(systemName: model.renderer.icon) .font(.system(size: 10, weight: .semibold)) Text(model.renderer.label) - .font(GenTypography.mono(9, weight: .medium)) + .font(GenTypography.mono(11, weight: .medium)) } .foregroundColor(model.theme.accent) .padding(.horizontal, 6) - .frame(minHeight: 18) + .frame(minHeight: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) - .instantTooltip("Renderer: \(model.renderer.detail) — click to cycle") + .instantTooltip("\(model.renderer.detail). Click to switch.") .hoverHighlight(color: model.theme.accent, cornerRadius: 5, fillOpacity: 0.12) .accessibilityIdentifier("markdown-renderer-toggle") } @@ -353,7 +376,7 @@ struct MarkdownDocumentToolbar: View { } .foregroundColor(active ? model.theme.accent : model.theme.textSecondary) .padding(.horizontal, labels ? 6 : 0) - .frame(minWidth: 26, minHeight: 18) + .frame(minWidth: 26, minHeight: 22) .background(active ? model.theme.accent.opacity(0.14) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 5)) .contentShape(Rectangle()) @@ -390,6 +413,7 @@ struct MarkdownDocumentToolbar: View { tooltip: String, id: String, labels: Bool, + isOn: Bool? = nil, action: @escaping () -> Void ) -> some View { Button(action: action) { @@ -405,7 +429,7 @@ struct MarkdownDocumentToolbar: View { } .foregroundColor(tint ?? model.theme.textSecondary) .padding(.horizontal, labels ? 6 : 0) - .frame(minWidth: 22, minHeight: 18) + .frame(minWidth: 22, minHeight: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) @@ -414,6 +438,9 @@ struct MarkdownDocumentToolbar: View { // The tooltip is a hover overlay, not an accessibility label, and the // identifier is for automation; VoiceOver needs the action's name. .accessibilityLabel(MarkdownDocumentActions.spokenLabel(tooltip)) + // A toggle says its state, not only by its tint (UX audit E5). + .accessibilityValue(isOn.map { $0 ? "On" : "Off" } ?? "") + .accessibilityAddTraits(isOn == true ? .isSelected : []) .accessibilityIdentifier(id) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift index d38bc32d6c..04aa015972 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFileTreeView.swift @@ -30,6 +30,10 @@ struct MarkdownFileTreeView: View { @StateObject private var tree = FileTreeModel(root: FileManager.default.homeDirectoryForCurrentUser) @StateObject private var search = FileTreeSearchModel() @State private var searchOpen = false + /// The search hit the keyboard is on (Up and Down in the field); nil: Return opens the first hit. + @State private var hitCursor: Int? + /// Why the tree has no rows, once a check of the folder says so (nil while it loads or has rows). + @State private var treeEmpty: MarkdownTreeEmpty? @State private var debounce: Task<Void, Never>? @FocusState private var searchFocused: Bool /// The folder "Go to Location…" revealed: highlighted until another document is selected. @@ -113,6 +117,8 @@ struct MarkdownFileTreeView: View { .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) } + } else if let empty = shownEmpty { + emptyTree(empty) } else { // An AppKit table with its own scroll view (`MarkdownTreeTable`). treeRows @@ -130,6 +136,10 @@ struct MarkdownFileTreeView: View { } } .onChange(of: model.revealToken) { _, _ in handleReveal(proxy) } + .onChange(of: hitCursor) { _, index in + guard let index, search.hits.indices.contains(index) else { return } + proxy.scrollTo(search.hits[index].id) + } .onChange(of: tree.rows) { _, _ in scrollToPending(proxy) applyPendingPlaceScroll(force: false) @@ -186,6 +196,51 @@ struct MarkdownFileTreeView: View { .onChange(of: model.treeSearchToken) { _, _ in applySearchRequest() } .onChange(of: search.query) { _, _ in scheduleSearch() } .onChange(of: search.mode) { _, _ in scheduleSearch() } + .onChange(of: search.hits) { _, _ in hitCursor = nil } + .onChange(of: search.failure) { _, now in if let now { GenAnnounce.post(now.message) } } + // An empty tree is checked once its rows settle: a folder still being read has no rows either. + .task(id: MarkdownTreeEmptyCheck(root: root.path, empty: tree.rows.isEmpty)) { + guard tree.rows.isEmpty else { + treeEmpty = nil + return + } + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled, tree.rows.isEmpty else { return } + let path = root.path + treeEmpty = await Task.detached(priority: .utility) { MarkdownTreeEmpty.check(path) }.value + } + } + + /// The empty state on screen: the folder has nothing, cannot be read, or the changed-files filter hides every row. + private var shownEmpty: MarkdownTreeEmpty? { + if tree.rows.isEmpty { return treeEmpty } + if changedOnly, gitMarks, displayedRows.isEmpty { return .noChanges } + return nil + } + + @ViewBuilder + private func emptyTree(_ empty: MarkdownTreeEmpty) -> some View { + let name = Self.folderName(model.filesRootURL) + switch empty { + case .empty: + EmptyState(symbol: "folder", text: "\(name) has no visible files", + detail: "Hidden files are not listed.", + actionTitle: Self.parentFolderName(model.filesRootURL).map { "Go up to \($0)" }, + action: { model.filesRootUp() }) + .accessibilityIdentifier("markdown-files-empty") + case .unreadable: + EmptyState(symbol: "lock", text: "\(name) cannot be read", + detail: "macOS privacy settings (Files and Folders, or Full Disk Access) or the folder's permissions block it.", + actionTitle: "Open Privacy Settings", + action: { MarkdownTreeEmpty.openPrivacySettings() }) + .accessibilityIdentifier("markdown-files-unreadable") + case .noChanges: + EmptyState(symbol: "checkmark.circle", text: "No changed files", + detail: "Show only changed files is on, and nothing under \(name) differs from git.", + actionTitle: "Show all files", + action: { changedOnly = false }) + .accessibilityIdentifier("markdown-files-no-changes") + } } /// The rows on screen, in order: every row, or only the changed ones when the git filter is on. @@ -239,27 +294,52 @@ struct MarkdownFileTreeView: View { .foregroundColor(model.theme.textMuted) TextField(search.mode == .names ? "File name" : "Text in files", text: $search.query) .textFieldStyle(.plain) - .font(GenTypography.mono(10)) + .font(GenTypography.mono(11)) .foregroundColor(model.theme.text) .focused($searchFocused) .onExitCommand { closeSearch() } + // Return opens the hit the keyboard is on, or the first; Up and Down walk the hits. + .onSubmit { openHit(at: hitCursor ?? 0) } + .onKeyPress(.downArrow, phases: [.down, .repeat]) { _ in moveHit(1) } + .onKeyPress(.upArrow, phases: [.down, .repeat]) { _ in moveHit(-1) } + .accessibilityLabel(search.mode == .names ? "Find a file by name" : "Find text in files") + .accessibilityHint("Up and Down choose a result, Return opens it") .accessibilityIdentifier("markdown-files-search") + if !search.query.isEmpty { + Button { + search.query = "" + searchFocused = true + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 10.5)) + .foregroundColor(model.theme.textMuted) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + .instantTooltip("Clear") + .accessibilityLabel("Clear the search") + .accessibilityIdentifier("markdown-files-search-clear") + } // Names or text: one toggle, the way a code editor keeps ⌘P and ⌘⇧F apart. Button { search.mode = search.mode == .names ? .text : .names } label: { Text(search.mode == .names ? "name" : "text") - .font(GenTypography.mono(8.5, weight: .semibold)) + .font(GenTypography.mono(11, weight: .semibold)) .foregroundColor(model.theme.accent) .padding(.horizontal, 5) .padding(.vertical, 2) .background(model.theme.accent.opacity(0.14)) .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame(minHeight: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .accessibilityLabel(search.mode == .names ? "Searching file names" : "Searching text in files") + .accessibilityHint("Switches between file names and text in files") .accessibilityIdentifier("markdown-files-search-mode") - .instantTooltip(search.mode == .names ? "Searching file names — click for text in files" : "Searching text in files — click for file names") + .instantTooltip(search.mode == .names ? "Searching file names. Click for text in files" : "Searching text in files. Click for file names") if search.searching { ProgressView().controlSize(.mini) } @@ -286,7 +366,7 @@ struct MarkdownFileTreeView: View { Image(systemName: "folder.fill") .font(.system(size: 8.5)) Text(name.isEmpty ? "/" : name) - .font(GenTypography.mono(9, weight: .medium)) + .font(GenTypography.mono(11, weight: .medium)) .lineLimit(1) .truncationMode(.middle) Image(systemName: "xmark") @@ -297,10 +377,11 @@ struct MarkdownFileTreeView: View { .padding(.horizontal, 7) .padding(.vertical, 2.5) .background(Capsule().fill(model.theme.accent.opacity(0.14))) - .contentShape(Capsule()) + .frame(minHeight: 22) + .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) - .instantTooltip("Searching only in \(PathLabel.display(scope)) — click to search the whole tree") + .instantTooltip("Searching only in \(PathLabel.display(scope)). Click to search the whole tree") .accessibilityLabel("Searching only in \(name). Remove the folder limit") .accessibilityIdentifier("markdown-files-search-scope") } @@ -308,30 +389,41 @@ struct MarkdownFileTreeView: View { @ViewBuilder private var results: some View { let size = fontSize - if search.hits.isEmpty, !search.searching { - Text("No match under \((search.searchRoot as NSString).lastPathComponent)") - .font(GenTypography.mono(9.5)) - .foregroundColor(model.theme.textMuted) - .padding(.horizontal, 10) + if let failure = search.failure, !search.searching { + NoticePill(text: failure.message, more: failure.detail, tone: .error, + fixes: [.button("Try again", symbol: "arrow.clockwise") { search.run() }]) + .padding(.horizontal, 8) .padding(.vertical, 6) - .accessibilityIdentifier("markdown-files-no-match") + .accessibilityIdentifier("markdown-files-search-failed") + } else if search.hits.isEmpty, !search.searching { + VStack(alignment: .leading, spacing: 3) { + Text("No match under \((search.searchRoot as NSString).lastPathComponent)") + .font(GenTypography.mono(11)) + .foregroundColor(model.theme.textSecondary) + .accessibilityIdentifier("markdown-files-no-match") + Text(FileTreeSearch.leftOut) + .font(.system(size: 11)) + .foregroundColor(model.theme.textMuted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("markdown-files-left-out") + } + .padding(.horizontal, 10) + .padding(.vertical, 6) } if search.fellBackToText, !search.hits.isEmpty { Text("No file name matches. Text inside files:") - .font(GenTypography.mono(9)) + .font(GenTypography.mono(11)) .foregroundColor(model.theme.textMuted) .padding(.horizontal, 10) .padding(.vertical, 4) .accessibilityIdentifier("markdown-files-text-fallback") } - ForEach(search.hits) { hit in - MarkdownFileTreeResultRow(hit: hit, model: model, fontSize: size) { open(hit) } + ForEach(Array(search.hits.enumerated()), id: \.element.id) { index, hit in + MarkdownFileTreeResultRow(hit: hit, model: model, fontSize: size, keyed: index == hitCursor) { open(hit) } } - if search.truncated { - Text(search.mode == .names && !search.fellBackToText - ? "Index stopped at \(FileTreeSearch.maxIndexedFiles) files — pick a narrower folder" - : "First \(FileTreeSearch.maxTextHits) hits shown") - .font(GenTypography.mono(9)) + if search.truncated || search.namesCut { + Text(Self.cutNotice(names: search.mode == .names && !search.fellBackToText, indexFull: search.truncated)) + .font(GenTypography.mono(11)) .foregroundColor(model.theme.textMuted) .padding(.horizontal, 10) .padding(.vertical, 6) @@ -349,6 +441,29 @@ struct MarkdownFileTreeView: View { if let line = hit.line { model.jump(toLine: line) } } + /// Under a cut list: the name index stopped, or only the first names or text hits are shown. + static func cutNotice(names: Bool, indexFull: Bool) -> String { + if names { + return indexFull + ? "Index stopped at \(FileTreeSearch.maxIndexedFiles) files. Pick a narrower folder" + : "First \(FileTreeSearch.maxNameHits) names shown. Type more of the name" + } + return "First \(FileTreeSearch.maxTextHits) hits shown" + } + + /// Up or Down in the search field: the keyboard moves through the hits, from the first. + private func moveHit(_ step: Int) -> KeyPress.Result { + guard showingResults, !search.hits.isEmpty else { return .ignored } + let next = (hitCursor ?? (step > 0 ? -1 : search.hits.count)) + step + hitCursor = min(max(next, 0), search.hits.count - 1) + return .handled + } + + private func openHit(at index: Int) { + guard showingResults, search.hits.indices.contains(index) else { return } + open(search.hits[index]) + } + private func closeSearch() { search.clear() search.setScope(nil) @@ -588,7 +703,7 @@ struct MarkdownFileTreeView: View { Image(systemName: symbol) .font(.system(size: 9, weight: .bold)) .foregroundColor(enabled ? model.theme.textSecondary : model.theme.textMuted.opacity(0.45)) - .frame(width: 16, height: 16) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) @@ -599,8 +714,10 @@ struct MarkdownFileTreeView: View { } private func header(_ root: URL) -> some View { - HStack(spacing: 6) { - HStack(spacing: 2) { + // The 22 pt targets sit side by side with no gap: with 6 pt gaps the folder chip kept about 30 pt of a 230 pt + // sidebar and showed only "(…" (viewer shots, 2026-10-25). Each button keeps its own 22 pt target. + HStack(spacing: 2) { + HStack(spacing: 0) { headerButton("chevron.left", enabled: history.canGoBack, id: "markdown-files-back", tip: history.back.last.map { "Back to \(Self.placeName($0))" } ?? "Back") { model.treeBack() } headerButton("chevron.right", enabled: history.canGoForward, id: "markdown-files-forward", @@ -621,9 +738,11 @@ struct MarkdownFileTreeView: View { model.filesRootUp() } label: { Text(parentName + " /") - .font(.system(size: 10.5)) + .font(.system(size: 11)) .foregroundColor(model.theme.textMuted) .lineLimit(1) + .frame(minHeight: 22) + .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) .layoutPriority(0) @@ -637,7 +756,7 @@ struct MarkdownFileTreeView: View { .truncationMode(.middle) .layoutPriority(1) } - .padding(.horizontal, 6) + .padding(.horizontal, 4) .padding(.vertical, 2) .background(RoundedRectangle(cornerRadius: 5).fill(model.theme.accent.opacity(0.10))) // "Navigate in Sidebar", or a reveal that re-rooted the tree: the new root lights up. @@ -646,14 +765,16 @@ struct MarkdownFileTreeView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Folder \(root.lastPathComponent)") .accessibilityIdentifier("markdown-files-root") + .layoutPriority(1) Spacer(minLength: 0) + HStack(spacing: 0) { PathMenuButton(entries: { newItemEntries(root) }) { Image(systemName: "plus") .font(.system(size: 9, weight: .bold)) .foregroundColor(model.theme.textMuted) - .frame(width: 16, height: 16) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) @@ -670,10 +791,13 @@ struct MarkdownFileTreeView: View { Image(systemName: "plusminus") .font(.system(size: 9, weight: .bold)) .foregroundColor(changedOnly ? model.theme.accent : model.theme.textMuted) - .frame(width: 16, height: 16) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .accessibilityLabel("Show only changed files") + .accessibilityValue(changedOnly ? "on" : "off") + .accessibilityAddTraits(changedOnly ? .isSelected : []) .accessibilityIdentifier("markdown-files-changed-only") .instantTooltip(changedOnly ? "Showing only changed files: click for all" : "Show only changed files") } @@ -684,10 +808,13 @@ struct MarkdownFileTreeView: View { Image(systemName: "magnifyingglass") .font(.system(size: 9, weight: .bold)) .foregroundColor(searchOpen ? model.theme.accent : model.theme.textMuted) - .frame(width: 16, height: 16) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .accessibilityLabel("Find in files") + .accessibilityValue(searchOpen ? "open" : "closed") + .accessibilityAddTraits(searchOpen ? .isSelected : []) .accessibilityIdentifier("markdown-files-search-toggle") .instantTooltip(keymap.hint("Find a file by name, or text in files", "markdown.find.files")) @@ -697,15 +824,18 @@ struct MarkdownFileTreeView: View { Image(systemName: "arrow.triangle.2.circlepath") .font(.system(size: 9, weight: .bold)) .foregroundColor(model.theme.textMuted) - .frame(width: 16, height: 16) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .accessibilityLabel("Re-read this folder") .accessibilityIdentifier("markdown-files-reload") .instantTooltip("Re-read this folder") + } } - .padding(.horizontal, 8) - .padding(.vertical, 7) + .padding(.horizontal, 6) + // 22 pt buttons in a header that kept its height: 4 + 22 + 4 is the old 7 + 16 + 7. + .padding(.vertical, 4) } } @@ -752,7 +882,16 @@ private struct MarkdownFileTreeRows: View, Equatable { rows: rows, revision: Revision(selected: selected, pulse: pulse, actions: actions.revision, git: git, metrics: style.metrics, colors: [style.text, style.secondary, style.muted, style.accent, style.selection]), - box: box + box: box, + keys: MarkdownTreeKeys( + open: { actions.open($0.url) }, + toggle: { row in + GenMotion.perform(expanding: !row.expanded, reduceMotion: tree.reduceMotion) { tree.toggle(row.id) } + }, + navigate: { actions.setRoot($0.url) }, + up: { model.filesRootUp() }, + start: { rows in selectedKey.flatMap { key in rows.firstIndex { $0.key == key } } } + ) ) { row in AnyView( FileTreeRowView( @@ -763,6 +902,9 @@ private struct MarkdownFileTreeRows: View, Equatable { ) .equatable() .markdownHoverPreview(row.isFolder ? nil : row.url, model: model) + // A folder says whether it is open; the open file says it is the selected one. + .accessibilityValue(row.isFolder ? (row.expanded ? "expanded" : "collapsed") : "") + .accessibilityAddTraits(row.key == selectedKey ? .isSelected : []) ) } } @@ -772,6 +914,8 @@ private struct MarkdownFileTreeResultRow: View { let hit: FileTreeSearch.Hit @ObservedObject var model: MarkdownDocumentsModel let fontSize: CGFloat + /// The keyboard is on this hit (Up and Down in the search field). + var keyed = false let action: () -> Void var body: some View { @@ -815,9 +959,14 @@ private struct MarkdownFileTreeResultRow: View { } .padding(.horizontal, 8) .padding(.vertical, metrics.verticalPadding) + .background( + RoundedRectangle(cornerRadius: 5).fill(keyed ? model.theme.accent.opacity(0.16) : Color.clear) + .padding(.horizontal, 3) + ) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .accessibilityAddTraits(keyed ? .isSelected : []) .contextMenu { let url = URL(fileURLWithPath: hit.path) PathActionsMenu(entries: MarkdownSearchResultMenu.entries( @@ -827,7 +976,8 @@ private struct MarkdownFileTreeResultRow: View { open: action )) } - .instantTooltip(hit.path) + // `.help`, not an instant tooltip: up to 300 rows each with an AppKit hover view lagged the list (2026-10-13). + .help(hit.path) .accessibilityIdentifier("markdown-files-result") } } @@ -1053,3 +1203,37 @@ enum MarkdownTreeScroll { RunLoop.main.add(timer, forMode: .common) } } + +/// Why the file tree shows no rows. +enum MarkdownTreeEmpty: Equatable, Sendable { + /// The folder holds no visible file. + case empty + /// The folder cannot be listed: a privacy grant or its permissions. + case unreadable + /// "Show only changed files" is on and nothing changed. + case noChanges + + /// Reads the folder once, off the main thread: an empty listing and a refused one are different answers. + nonisolated static func check(_ path: String) -> MarkdownTreeEmpty { + do { + _ = try FileManager.default.contentsOfDirectory(atPath: path) + return .empty + } catch { + return .unreadable + } + } + + /// System Settings at Privacy & Security, Files and Folders. + @MainActor + static func openPrivacySettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders") { + NSWorkspace.shared.open(url) + } + } +} + +/// The empty-tree check runs again when the root changes or the rows empty or fill. +private struct MarkdownTreeEmptyCheck: Equatable { + let root: String + let empty: Bool +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindAllTabs.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindAllTabs.swift index fc91ab9310..3f761ef5cf 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindAllTabs.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindAllTabs.swift @@ -118,7 +118,8 @@ struct MarkdownFindAllTabs: View { } .buttonStyle(.genHoverPlain()) .hoverHighlight(color: model.theme.accent, cornerRadius: 6, fillOpacity: 0.1, borderOpacity: 0) - .instantTooltip(hit.path) + // `.help`, not an instant tooltip: one AppKit hover view per row of a long list lagged it (2026-10-13). + .help(hit.path) .accessibilityLabel("\(hit.title), \(hit.count == 1 ? "1 match" : "\(hit.count) matches"): \(hit.snippet)") .accessibilityHint("Shows this tab and its matches") .accessibilityIdentifier("markdown-find-all-tabs-row") diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindBar.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindBar.swift index 16de90deed..658a4e3961 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindBar.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownFindBar.swift @@ -56,6 +56,8 @@ struct MarkdownFindBar: View { @ObservedObject var keymap = MarkdownKeymap.shared @FocusState private var focused: Bool @State private var allTabsOpen = false + /// The counter is spoken once it settles, not at every keystroke. + @State private var counterAnnounce: Task<Void, Never>? var body: some View { VStack(alignment: .leading, spacing: 5) { @@ -92,10 +94,11 @@ struct MarkdownFindBar: View { Image(systemName: model.replaceOpen ? "chevron.down" : "chevron.right") .font(.system(size: 9, weight: .semibold)) .foregroundColor(model.theme.textMuted) - .frame(width: 16, height: 22) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .hoverHighlight(color: model.theme.accent, cornerRadius: 5, fillOpacity: 0.12) .instantTooltip(keymap.hint("Replace", "markdown.find.replace")) .accessibilityLabel(model.replaceOpen ? "Hide replace" : "Show replace") .keymapAction("markdown.find.replace", in: MarkdownKeymap.shared) { model.replaceOpen.toggle() } @@ -106,12 +109,14 @@ struct MarkdownFindBar: View { if model.supportsFind { Text(counter) - .font(GenTypography.mono(9.5)) + .font(GenTypography.mono(11)) + .monospacedDigit() .foregroundColor(counterIsWarning ? model.theme.accent : model.theme.textMuted) .lineLimit(1) .fixedSize() .frame(minWidth: 46, alignment: .center) .accessibilityIdentifier("markdown-find-counter") + .onChange(of: counter) { _, now in announceCounter(now) } stepButton("chevron.up", tooltip: stepTooltip("Previous match", "⇧↩", "markdown.find.previous"), id: "markdown-find-prev") { @@ -144,11 +149,23 @@ struct MarkdownFindBar: View { allTabsButton } } else { - Text("Web renderer only") - .font(GenTypography.mono(9.5)) - .foregroundColor(model.theme.accent) - .fixedSize() - .instantTooltip("The native renderers draw no marks. Switch to the Web renderer to search the page") + // The native renderers draw no marks, so the way out is a button, not a tooltip. + Button { + model.rendererRaw = MarkdownRenderer.web.rawValue + model.search() + } label: { + Text("Use the web renderer") + .font(GenTypography.mono(11, weight: .medium)) + .foregroundColor(model.theme.accent) + .padding(.horizontal, 6) + .frame(minHeight: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + .hoverHighlight(color: model.theme.accent, cornerRadius: 5, fillOpacity: 0.12) + .fixedSize() + .instantTooltip("Find marks matches only in the web renderer. This switches the reading view to it") + .accessibilityIdentifier("markdown-find-use-web") } stepButton("xmark", tooltip: "Close (Esc)", id: "markdown-find-done", enabled: true) { @@ -178,6 +195,10 @@ struct MarkdownFindBar: View { .accessibilityLabel("Find") .accessibilityIdentifier("markdown-find-field") + if !model.findQuery.isEmpty { + clearButton { model.findQuery = "" } + } + optionToggle( Text("Aa"), isOn: model.findOptions.matchCase, @@ -218,7 +239,7 @@ struct MarkdownFindBar: View { /// Replace works on the source, which is the text the file actually holds. private var replaceRow: some View { HStack(spacing: 3) { - Spacer().frame(width: 16) + Spacer().frame(width: 22) TextField(model.findOptions.regex ? "Replace ($1 = group 1)" : "Replace", text: $model.replaceText) .textFieldStyle(.plain) .font(GenTypography.mono(11)) @@ -239,7 +260,7 @@ struct MarkdownFindBar: View { model.replaceCurrent() } textButton( - "All", + "Replace all", tooltip: model.activeSourceScope == nil ? "Replace every match" : "Replace every match in the selection", id: "markdown-replace-all" ) { @@ -256,7 +277,7 @@ struct MarkdownFindBar: View { Image(systemName: "rectangle.stack") .font(.system(size: 10.5, weight: .semibold)) .foregroundColor(allTabsOpen ? model.theme.accent : model.theme.textSecondary) - .frame(width: 22, height: 20) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) @@ -321,7 +342,7 @@ struct MarkdownFindBar: View { label .font(.system(size: 10.5, weight: .semibold, design: .monospaced)) .foregroundColor(isOn ? model.theme.accent : model.theme.textMuted) - .frame(width: 20, height: 18) + .frame(width: 22, height: 22) .background(RoundedRectangle(cornerRadius: 4).fill(isOn ? model.theme.accent.opacity(0.16) : Color.clear)) .overlay( RoundedRectangle(cornerRadius: 4) @@ -349,7 +370,7 @@ struct MarkdownFindBar: View { Image(systemName: symbol) .font(.system(size: 10.5, weight: .semibold)) .foregroundColor(isOn ? model.theme.accent : model.theme.textSecondary) - .frame(width: 22, height: 20) + .frame(width: 22, height: 22) .background(RoundedRectangle(cornerRadius: 5).fill(isOn ? model.theme.accent.opacity(0.16) : Color.clear)) .overlay( RoundedRectangle(cornerRadius: 5) @@ -377,7 +398,7 @@ struct MarkdownFindBar: View { Image(systemName: symbol) .font(.system(size: 10, weight: .semibold)) .foregroundColor(model.theme.textSecondary) - .frame(width: 22, height: 20) + .frame(width: 22, height: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) @@ -388,15 +409,51 @@ struct MarkdownFindBar: View { .accessibilityIdentifier(id) } + /// Empties the find field; shown only while it holds text. + private func clearButton(_ clear: @escaping () -> Void) -> some View { + Button(action: clear) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 10.5)) + .foregroundColor(model.theme.textMuted) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + .instantTooltip("Clear") + .accessibilityLabel("Clear the search") + .accessibilityIdentifier("markdown-find-clear") + } + + /// "3 of 12", "No match" or the pattern error, spoken 0.6 s after the last change of the counter. + private func announceCounter(_ text: String) { + counterAnnounce?.cancel() + guard !text.isEmpty else { return } + counterAnnounce = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled else { return } + GenAnnounce.post(Self.spokenCounter(text), priority: .low) + } + } + + /// The counter in words: "3/12" reads as "Match 3 of 12". + static func spokenCounter(_ text: String) -> String { + let parts = text.split(separator: "/") + if parts.count == 2, let index = Int(parts[0]), let count = Int(parts[1]) { + return "Match \(index) of \(count)" + } + return text.prefix(1).uppercased() + text.dropFirst() + } + private func textButton(_ title: String, tooltip: String, id: String, action: @escaping () -> Void) -> some View { Button(title, action: action) .buttonStyle(.genHoverPlain()) - .font(GenTypography.mono(10)) + .font(GenTypography.mono(11)) .foregroundColor(model.findCount > 0 ? model.theme.accent : model.theme.textMuted) .padding(.horizontal, 5) .frame(height: 22) .disabled(model.findCount == 0) .instantTooltip(tooltip) + .accessibilityLabel(MarkdownDocumentActions.spokenLabel(tooltip)) .accessibilityIdentifier(id) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGitViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGitViews.swift index 62bcaef420..c35d8e441e 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGitViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGitViews.swift @@ -8,72 +8,152 @@ import SwiftUI -/// The git sidebar beside Files and Outline. -struct MarkdownGitSidebar: View { +/// The git sidebar beside Files and Outline. `Equatable` on the document, the model's identity and the theme: it +/// observes the document and the repositories itself, so a publish of the window's model (a heading report, a tab +/// switch) no longer runs it, and with it the repository panel (+13.9 ms per publish on a vault, 2026-10-25). +struct MarkdownGitSidebar: View, Equatable { @ObservedObject var doc: MarkdownDocument - @ObservedObject var model: MarkdownDocumentsModel + let model: MarkdownDocumentsModel + let theme: MarkdownTheme @ObservedObject private var repos = GitRepos.shared @StateObject private var fileHistory = GitHistoryModel() - @StateObject private var repoLog = GitHistoryModel(limit: 15) + @StateObject private var repoLog = GitHistoryModel(limit: 50) @AppStorage("markdown.config.gitSidebarTab") private var tab = "file" + /// The last repository the Repository tab showed: a tab outside any repository keeps it on screen rather than + /// blanking the panel (Martin, 2026-10-25). + @State private var lastRepo: GitRepoModel? + + static func == (lhs: MarkdownGitSidebar, rhs: MarkdownGitSidebar) -> Bool { + lhs.doc === rhs.doc && lhs.model === rhs.model && lhs.theme == rhs.theme + } var body: some View { let _ = RenderProbe.hit("md.gitSidebar") VStack(alignment: .leading, spacing: 0) { - GenSegmentedTabs("Git", items: [.init("file", "This file"), .init("repo", "Repository")], selection: $tab) + // Changes keeps the stored value "repo" of the former Repository tab, so a saved choice still lands on it. + GenSegmentedTabs("Git", items: [.init("file", "This file"), .init("repo", "Changes"), .init("commits", "Commits")], + selection: $tab) .frame(maxWidth: .infinity) .padding(8) .accessibilityIdentifier("markdown-git-tab") - Rectangle().fill(model.theme.border).frame(height: 1) - ScrollView { - Group { - switch repos.lookup(for: doc.folder) { - case .pending: - EmptyView() - case .repo(let repo): - if tab == "file" { - fileTab - } else { - GitRepoPanel( - repo: repo, - log: repoLog, - revealPath: GitQueries.relative(doc.url, to: repo.root), - onOpenFile: { entry, staged in model.openChanges(entry, staged: staged, root: repo.root) }, - onOpenCommit: { model.openCommit($0, root: repo.root) } - ) - } - case .none: - Text("This file is not in a git repository.") - .font(.system(size: 11)) - .foregroundColor(model.theme.textSecondary) - } + Rectangle().fill(theme.border).frame(height: 1) + if tab == "repo" { + // The panel's rows scroll in their own table; a scroll view around it would lay the window out again. + repoTab + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else if tab == "commits" { + ScrollView { + commitsTab + .padding(8) + } + } else { + ScrollView { + fileTab + .padding(8) } - .padding(8) } } - .background(model.theme.page.opacity(0.6)) + .background(theme.page.opacity(0.6)) .onAppear { follow() } .onChange(of: doc.url) { _, _ in follow() } } + @ViewBuilder + private var repoTab: some View { + switch repos.lookup(for: doc.folder) { + case .pending: + if let lastRepo { repoPanel(lastRepo, revealPath: nil) } + case .repo(let repo): + repoPanel(repo, revealPath: GitQueries.relative(doc.url, to: repo.root)) + .onAppear { lastRepo = repo } + .onChange(of: repo.root) { _, _ in lastRepo = repo } + case .none: + VStack(alignment: .leading, spacing: 4) { + notInGit + if let lastRepo { + Text(verbatim: "Showing \(lastRepo.root.lastPathComponent), the last repository.") + .font(.system(size: 11)) + .foregroundColor(theme.textSecondary) + .padding(.horizontal, 8) + repoPanel(lastRepo, revealPath: nil) + } + } + } + } + + @ViewBuilder + private var fileTab: some View { + switch repos.lookup(for: doc.folder) { + case .pending: + EmptyView() + case .repo: + MarkdownGitFileHistory(doc: doc, model: model, history: fileHistory) + case .none: + notInGit + } + } + + /// The repository's recent commits; a click opens one in a sheet. + @ViewBuilder + private var commitsTab: some View { + switch repos.lookup(for: doc.folder) { + case .pending: + EmptyView() + case .repo(let repo): + if repoLog.loaded && repoLog.commits.isEmpty { + Text("No commits yet.") + .font(.system(size: 11)) + .foregroundColor(theme.textSecondary) + } else { + GitCommitList(commits: repoLog.commits, onOpen: { [model] in model.openCommit($0, root: repo.root) }) + } + case .none: + notInGit + } + } + + private var notInGit: some View { + Text("This file is not in a git repository.") + .font(.system(size: 11)) + .foregroundColor(theme.textSecondary) + .padding(tab == "file" ? 0 : 8) + } + + private func repoPanel(_ repo: GitRepoModel, revealPath: String?) -> some View { + GitRepoPanel( + repo: repo, + revealPath: revealPath, + onOpenFile: { [model] entry, staged in model.openChanges(entry, staged: staged, root: repo.root) } + ) + .equatable() + } + private func follow() { fileHistory.follow(.file(doc.url)) repoLog.follow(.repository(doc.folder)) } +} - @ViewBuilder - private var fileTab: some View { - if fileHistory.loaded && fileHistory.commits.isEmpty { +/// This file's commits. It observes the window's model (the version shown in the tab), so it lives apart from the +/// sidebar, which does not. +private struct MarkdownGitFileHistory: View { + @ObservedObject var doc: MarkdownDocument + @ObservedObject var model: MarkdownDocumentsModel + @ObservedObject var history: GitHistoryModel + + var body: some View { + if history.loaded && history.commits.isEmpty { Text("No commits have this file yet.") .font(.system(size: 11)) .foregroundColor(model.theme.textSecondary) } else { VStack(alignment: .leading, spacing: 6) { - Text("Click a commit to read that version. Right-click to compare.") - .font(.system(size: 10)) - .foregroundColor(model.theme.textMuted) + Text("Click a commit to read that version. Right-click it, or press C, to compare.") + .font(.system(size: 11)) + .foregroundColor(model.theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) GitCommitList( - commits: fileHistory.commits, + commits: history.commits, selected: model.revisionView.flatMap { $0.docID == doc.id ? $0.commit.sha : nil }, onOpen: { model.viewRevision($0, of: doc) }, onCompare: { model.compareRevision($0, with: nil, of: doc) }, diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGoTo.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGoTo.swift index 7cf95d506c..c28484acd9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGoTo.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownGoTo.swift @@ -92,12 +92,17 @@ struct MarkdownGoToSheet: View { return .handled } .onChange(of: text) { _, _ in problem = nil } + .onChange(of: problem) { _, now in if let now { GenAnnounce.post(now) } } .accessibilityIdentifier("markdown-goto-field") if let problem { - Text(problem) - .font(.system(size: 11.5)) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) + Label { + Text(problem).fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.triangle.fill").accessibilityHidden(true) + } + .font(.system(size: 11.5)) + .foregroundStyle(Color.genError) + .accessibilityIdentifier("markdown-goto-problem") } HStack { Spacer() diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverImage.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverImage.swift index 2ae11fcdc9..e1e82ad2ab 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverImage.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverImage.swift @@ -176,7 +176,7 @@ struct MarkdownHoverPicturePane: View { .accessibilityLabel(Text(verbatim: "Picture of \(name), \(caption)")) .accessibilityIdentifier("markdown-hover-picture") Text(verbatim: caption) - .font(GenTypography.mono(10)) + .font(GenTypography.mono(11)) .foregroundStyle(theme.textMuted) .lineLimit(1) .truncationMode(.middle) @@ -249,6 +249,9 @@ struct MarkdownHoverSourceToggle: View { .foregroundStyle(theme.textSecondary) .padding(.horizontal, 7) .padding(.vertical, 2.5) + // 22 pt tall to aim at; the drawn pill stays the header's size. + .frame(minHeight: 22) + .contentShape(Rectangle()) // At rest too: a control, not more of the header's text. .background(RoundedRectangle(cornerRadius: 5, style: .continuous).fill(theme.fill)) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverPreview.swift index 1283e55515..19764d0c35 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownHoverPreview.swift @@ -226,6 +226,8 @@ final class MarkdownHoverPreviewController { private var resignObserver: NSObjectProtocol? /// While the panel is up: a click anywhere else in the app takes it down at once. private var clickMonitor: Any? + /// While the panel is up: Esc takes it down (it appears by itself, so it must close without acting). + private var keyMonitor: Any? var isShowing: Bool { panel?.isVisible == true && shown != nil } @@ -283,6 +285,10 @@ final class MarkdownHoverPreviewController { NSEvent.removeMonitor(clickMonitor) self.clickMonitor = nil } + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } } /// Whether the preview may open for `request` now: not over an open menu, not for an anchor just @@ -514,6 +520,22 @@ final class MarkdownHoverPreviewController { return event } } + if keyMonitor == nil { + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + // Esc (53) with no modifier: the preview goes and the key is used up; anything else passes. + guard event.keyCode == 53, + event.modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting(.capsLock).isEmpty else { + return event + } + let closed = MainActor.assumeIsolated { + let controller = MarkdownHoverPreviewController.shared + guard controller.isShowing else { return false } + controller.hide() + return true + } + return closed ? nil : event + } + } } private func makePanel() -> MarkdownHoverPanel { @@ -663,7 +685,7 @@ struct MarkdownHoverCard: View { .foregroundStyle(theme.text) .lineLimit(1) Text((content.url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath) - .font(GenTypography.mono(9.5)) + .font(GenTypography.mono(11)) .foregroundStyle(theme.textMuted) .lineLimit(1) .truncationMode(.head) @@ -673,7 +695,7 @@ struct MarkdownHoverCard: View { } // A text file opens as a tab; a picture that is no text file opens in the image viewer. Text(FileTree.isReadable(content.url) ? "Click to open" : "Click to view") - .font(.system(size: 10)) + .font(.system(size: 11)) .foregroundStyle(theme.textMuted) .fixedSize() } @@ -710,7 +732,9 @@ struct MarkdownHoverCard: View { onWikilink: { _ in }, highlightQuery: nil, highlightToken: 0, - rich: renderer == .nativeRich + rich: renderer == .nativeRich, + zoom: zoom, + theme: theme ) .id(content.url) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownImageViewer.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownImageViewer.swift index 21aa24812e..d26a705910 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownImageViewer.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownImageViewer.swift @@ -79,6 +79,8 @@ extension MarkdownWebView.Coordinator { } guard items.contains(where: { $0.id == String(selected) }) else { PerfLog.mark("imageviewer.refused n=\(selected) (not a vault or embedded picture)") + // Moved or deleted since the render: the double-click gets an answer. + self.show(.pictureGone) return } ImageViewer.present(ImageViewerRequest( diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownMedia.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownMedia.swift index fe6f87b4b3..675018818b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownMedia.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownMedia.swift @@ -222,6 +222,9 @@ final class VaultMediaIndex { var attachmentFolder: String? { snapshot.attachmentFolder } + /// False until the first scan has finished: a name-only reference that misses now may still be found. + var isReady: Bool { builtAt != nil } + /// Never reads the filesystem on the caller's queue. Concurrent requests share one scan; /// callbacks run on the main queue after the complete snapshot has been published. func refreshIfNeeded(now: Date = Date(), completion: @escaping () -> Void) { @@ -337,7 +340,14 @@ final class MarkdownMediaRegistry { var map: [String: [String: String]] = [:] for ref in references where map[ref.key] == nil { guard let url = MarkdownMedia.resolve(ref.key, in: context, index: index), - let kind = MarkdownMedia.kind(ofExtension: url.pathExtension) else { continue } + let kind = MarkdownMedia.kind(ofExtension: url.pathExtension) else { + // The vault scan is still running and will render again: a miss now is no answer yet, so the page + // draws a quiet placeholder instead of "not found" (a pending lookup never shows its negative answer). + if let index, !index.isReady, onIndexReady != nil { + map[ref.key] = ["kind": "pending", "name": (ref.key as NSString).lastPathComponent] + } + continue + } let token = nextTokens[url.path] ?? tokenByPath[url.path] ?? UUID().uuidString.lowercased() nextTokens[url.path] = token next[token] = Entry(url: url, kind: kind, context: context) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownOutlineView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownOutlineView.swift index 9b9412ad66..8f89aa0d88 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownOutlineView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownOutlineView.swift @@ -19,8 +19,18 @@ struct MarkdownOutline { var id: Int { index } } + /// Appended to a target the pane already has, so a second click on the same row is a new value and the + /// pane jumps again (the reader may have scrolled away). `slugify` drops it, so it names the same heading. + static let repeatMark = "\u{2060}" + + /// The scroll target for a click on `item`: its slug, marked when the pane was already sent there. + static func target(for item: Item, current: String?) -> String { + current == item.slug ? item.slug + repeatMark : item.slug + } + /// Headings of a document, in file order. Levels are normalised so a note - /// that starts at `##` still indents from the left edge. + /// that starts at `##` still indents from the left edge. Slugs are unique + /// (`examples`, `examples-1`), the ids the page gives its headings. static func items(of markdown: String) -> [Item] { var raw: [Item] = [] for (i, block) in MarkdownBlocks.parse(markdown).enumerated() { @@ -28,6 +38,8 @@ struct MarkdownOutline { raw.append(Item(level: level, text: text, slug: slug, index: i)) } } + let unique = MarkdownBlocks.uniqueSlugs(raw.map(\.slug)) + raw = zip(raw, unique).map { Item(level: $0.level, text: $0.text, slug: $1, index: $0.index) } guard let top = raw.map(\.level).min(), top > 1 else { return raw } return raw.map { Item(level: $0.level - top + 1, text: $0.text, slug: $0.slug, index: $0.index) } } @@ -66,6 +78,7 @@ struct MarkdownOutlineView: View { /// The model's heading on screen, observed here and nowhere else in the window. @ObservedObject var section: MarkdownSectionSpy @Namespace private var barNS + @GenReduceMotion private var reduceMotion @State private var outline = MarkdownOutlineCache() init(doc: MarkdownDocument, model: MarkdownDocumentsModel) { @@ -89,7 +102,7 @@ struct MarkdownOutlineView: View { if items.isEmpty { Text("No headings") - .font(GenTypography.mono(9.5)) + .font(GenTypography.mono(11)) .foregroundColor(model.theme.textMuted) .padding(.horizontal, 14) } else { @@ -99,7 +112,7 @@ struct MarkdownOutlineView: View { } } .padding(.bottom, 10) - .animation(.easeOut(duration: 0.22), value: section.slug) + .animation(reduceMotion ? nil : .easeOut(duration: 0.22), value: section.slug) } .background(model.theme.chrome) .accessibilityIdentifier("markdown-outline") @@ -109,11 +122,11 @@ struct MarkdownOutlineView: View { let active = section.slug == item.slug return Button { model.activeSectionSlug = item.slug - model.scrollTarget = item.text + model.scrollTarget = MarkdownOutline.target(for: item, current: model.scrollTarget) } label: { HStack(spacing: 0) { Text(item.text) - .font(GenTypography.mono(item.level <= 2 ? 10.5 : 10, + .font(GenTypography.mono(11, weight: item.level <= 2 ? .semibold : .regular)) .foregroundColor(active ? model.theme.accent : model.theme.textSecondary) .lineLimit(1) @@ -137,6 +150,7 @@ struct MarkdownOutlineView: View { } .buttonStyle(.genHoverPlain()) .instantTooltip(item.text) + .accessibilityAddTraits(active ? .isSelected : []) .accessibilityIdentifier("markdown-outline-\(item.slug)") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownPageNotice.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownPageNotice.swift new file mode 100644 index 0000000000..3e62fc5ce1 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownPageNotice.swift @@ -0,0 +1,249 @@ +// +// MarkdownPageNotice.swift +// GenesisKit +// +// The reading page's own answers, as a NoticePill over the bottom of the page: a link that goes nowhere ("No +// heading “Setup” in this note"), a page that could not be drawn (one sentence, Retry, the raw error behind +// Details), a removed property (Undo). Before this, each of these ended in a log line and the pane did nothing. +// The notice is an AppKit subview of the WKWebView, so it also shows while the page itself is dead. +// + +import AppKit +import SwiftUI +import WebKit + +/// What the reading page tells the reader. +struct MarkdownPageNotice: Equatable { + /// The one button a notice may carry. + enum Action: Equatable { + /// Load the page again and send the text again. + case retry + /// Put the properties back as they were before the last removal. + case undoProperties + /// Hand an allowed web address to the browser. + case open(URL) + + var label: String { + switch self { + case .retry: "Retry" + case .undoProperties: "Undo" + case .open: "Open in browser" + } + } + + var symbol: String { + switch self { + case .retry: "arrow.clockwise" + case .undoProperties: "arrow.uturn.backward" + case .open: "safari" + } + } + } + + enum Kind: Equatable { + /// A link, a heading or a picture that cannot be followed. Goes by itself. + case link + /// The page could not be drawn. Stays until Retry works or the reader closes it. + case failure + /// A change that can be taken back. Goes by itself. + case undo + } + + var text: String + var detail: String? + /// The raw message, shown only after a click on Details. + var more: String? + var kind: Kind + var action: Action? + + var tone: NoticePill.Tone { + switch kind { + case .link: .warning + case .failure: .error + case .undo: .success + } + } + + /// Seconds on screen before it goes by itself; nil = until dismissed. The same six seconds as NoticePill's + /// own fade. + var lasts: Double? { kind == .failure ? nil : 6 } + + // MARK: The texts + + static func noHeading(_ heading: String) -> MarkdownPageNotice { + MarkdownPageNotice(text: "No heading “\(heading)” in this note.", kind: .link) + } + + /// A link whose scheme the page never hands to the system (`ssh:`, an app's own scheme). + static func blockedLink(_ href: String) -> MarkdownPageNotice { + let scheme = URL(string: href)?.scheme?.lowercased() + let text = scheme.map { "The viewer does not open \($0): links." } ?? "The viewer cannot open this link." + return MarkdownPageNotice(text: text, detail: "It opens web, mail and Obsidian links, and files.", kind: .link) + } + + static let noFileLinks = MarkdownPageNotice(text: "Links to files do not open in this pane.", kind: .link) + + static func remotePicture(_ src: String) -> MarkdownPageNotice { + MarkdownPageNotice(text: "This picture is on the web, so the viewer cannot open it.", kind: .link, + action: MarkdownWebView.externalURL(src).map(Action.open)) + } + + static let pictureGone = MarkdownPageNotice(text: "This picture is no longer there.", + detail: "It was moved or deleted after the note was drawn.", kind: .link) + + static func failure(_ raw: String) -> MarkdownPageNotice { + MarkdownPageNotice(text: "This page could not be drawn.", detail: "The Source mode still shows the text.", + more: raw, kind: .failure, action: .retry) + } + + static func propertyRemoved(_ name: String) -> MarkdownPageNotice { + let text = name.isEmpty ? "Property removed." : "Property “\(name)” removed." + return MarkdownPageNotice(text: text, kind: .undo, action: .undoProperties) + } + + static func itemRemoved(_ item: String, from name: String) -> MarkdownPageNotice { + MarkdownPageNotice(text: "“\(item)” removed from \(name.isEmpty ? "the property" : name).", kind: .undo, + action: .undoProperties) + } +} + +/// The pill, on a backing that reads over the page's text. +struct MarkdownPageNoticeView: View { + let notice: MarkdownPageNotice + /// The widest the pill may grow; it hugs shorter text and sits centred. + let width: CGFloat + let perform: @MainActor () -> Void + let dismiss: () -> Void + /// Where the pill is, so a click beside it reaches the page. + let onFrame: (CGRect) -> Void + private static let space = "markdown-page-notice" + + var body: some View { + NoticePill(text: notice.text, detail: notice.detail, more: notice.more, tone: notice.tone, + fixes: notice.action.map { [NoticeFix.button($0.label, symbol: $0.symbol, perform: perform)] } ?? [], + dismiss: dismiss) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + .shadow(color: .black.opacity(0.25), radius: 8, y: 2) + .background(GeometryReader { geo in + Color.clear + .onAppear { onFrame(geo.frame(in: .named(Self.space))) } + .onChange(of: geo.frame(in: .named(Self.space))) { _, frame in onFrame(frame) } + }) + .frame(width: width) + .fixedSize(horizontal: false, vertical: true) + .coordinateSpace(name: Self.space) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("markdown-page-notice") + } +} + +/// The notice's host. A click outside the pill falls through to the page, and a size change (Details opened) +/// places it again. +final class MarkdownPageNoticeHost: NSHostingView<MarkdownPageNoticeView> { + var pill: CGRect = .zero + var onResize: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + let local = convert(point, from: superview) + let rect = isFlipped ? pill : CGRect(x: pill.minX, y: bounds.height - pill.maxY, width: pill.width, height: pill.height) + guard rect.contains(local) else { return nil } + return super.hitTest(point) + } + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + onResize?() + } +} + +extension MarkdownWebView.Coordinator { + /// Shows `notice` over the page, in place of the one before. + @MainActor + func show(_ notice: MarkdownPageNotice) { + guard let web else { return } + noticeToken += 1 + let token = noticeToken + currentNotice = notice + let width = Self.noticeWidth(in: web) + let view = MarkdownPageNoticeView( + notice: notice, width: width, + perform: { [weak self] in self?.perform(notice.action) }, + dismiss: { [weak self] in self?.hideNotice() }, + onFrame: { [weak self] frame in self?.noticeHost?.pill = frame }) + if let host = noticeHost, host.superview === web { + host.rootView = view + } else { + noticeHost?.removeFromSuperview() + let host = MarkdownPageNoticeHost(rootView: view) + host.onResize = { [weak self] in + DispatchQueue.main.async { MainActor.assumeIsolated { self?.layoutNotice() } } + } + web.addSubview(host) + web.postsFrameChangedNotifications = true + noticeFrameWatch = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, object: web, queue: .main + ) { [weak self] _ in MainActor.assumeIsolated { self?.layoutNotice() } } + noticeHost = host + } + layoutNotice() + GenAnnounce.post(notice.text, priority: notice.kind == .failure ? .high : .medium) + if let lasts = notice.lasts { + DispatchQueue.main.asyncAfter(deadline: .now() + lasts) { [weak self] in + MainActor.assumeIsolated { + guard let self, self.noticeToken == token else { return } + self.hideNotice() + } + } + } + } + + /// Takes the notice away (its close button, its time, a page that drew after a failure, the page leaving). + @MainActor + func hideNotice() { + noticeToken += 1 + currentNotice = nil + noticeHost?.removeFromSuperview() + noticeHost = nil + if let noticeFrameWatch { + NotificationCenter.default.removeObserver(noticeFrameWatch) + self.noticeFrameWatch = nil + } + } + + static func noticeWidth(in web: NSView) -> CGFloat { + max(200, min(520, web.bounds.width - 32)) + } + + /// Bottom centre of the page, 16 pt up. + @MainActor + func layoutNotice() { + guard let web, let host = noticeHost, host.superview === web, let notice = currentNotice else { return } + let width = Self.noticeWidth(in: web) + if host.rootView.width != width { + let old = host.rootView + host.rootView = MarkdownPageNoticeView(notice: notice, width: width, perform: old.perform, + dismiss: old.dismiss, onFrame: old.onFrame) + } + let height = host.fittingSize.height + let x = (web.bounds.width - width) / 2 + let y = web.isFlipped ? web.bounds.height - height - 16 : 16 + let frame = CGRect(x: x, y: y, width: width, height: height).integral + if host.frame != frame { host.frame = frame } + } + + @MainActor + private func perform(_ action: MarkdownPageNotice.Action?) { + switch action { + case .retry?: + retryPage() + case .undoProperties?: + hideNotice() + eval("window.gnPropsUndo&&window.gnPropsUndo()") + case .open(let url)?: + hideNotice() + if let allowed = MarkdownWebView.externalURL(url.absoluteString) { NSWorkspace.shared.open(allowed) } + case nil: + break + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownPreview.swift index d3b8276692..625b6c7e9f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownPreview.swift @@ -32,6 +32,23 @@ public enum MarkdownBlocks { } } + /// Heading ids the way the web page writes them: the second "Examples" is `examples-1`, the third + /// `examples-2`, and a suffix already taken moves on. The outline and both renderers use it, so a row + /// scrolls to the heading it names and only that row shows as current. + public static func uniqueSlugs(_ slugs: [String]) -> [String] { + var used = Set<String>() + return slugs.map { slug in + var candidate = slug + var n = 0 + while used.contains(candidate) { + n += 1 + candidate = "\(slug)-\(n)" + } + used.insert(candidate) + return candidate + } + } + public static func slugify(_ heading: String) -> String { heading.lowercased() .replacingOccurrences(of: #"[^a-z0-9\s-]"#, with: "", options: .regularExpression) @@ -379,6 +396,27 @@ public struct MarkdownPreviewView: View { /// Engine 3: render code/mermaid natively-rich (hljs + BeautifulMermaid) /// instead of the flat MarkdownBlockView code renderer. var rich: Bool = false + /// The window's text size, 1 = the style's own sizes (⌘+ and ⌘- reach the native panes too). + var zoom: Double = 1 + /// The window's palette; nil = the vault look. + var theme: MarkdownTheme? = nil + + /// The markdown window's pane: its text size and its palette. + init( + markdown: String, + scrollTarget: String?, + onWikilink: @escaping (String) -> Void, + highlightQuery: String?, + highlightToken: Int = 0, + rich: Bool = false, + zoom: Double, + theme: MarkdownTheme? + ) { + self.init(markdown: markdown, scrollTarget: scrollTarget, onWikilink: onWikilink, highlightQuery: highlightQuery, + highlightToken: highlightToken, rich: rich) + self.zoom = zoom + self.theme = theme + } public init( markdown: String, @@ -400,6 +438,7 @@ public struct MarkdownPreviewView: View { @ObservedObject private var config = GenesisKitMarkdownConfiguration.shared @State private var flashBlockId: String? @State private var flashOn = false + @GenReduceMotion private var reduceMotion @State private var lastHandledToken = -1 public var body: some View { @@ -407,6 +446,11 @@ public struct MarkdownPreviewView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 10) { + if blocks.isEmpty { + Text("This note is empty.") + .font(.system(size: 13 * CGFloat(zoom)).italic()) + .foregroundColor(blockStyle.mutedColor) + } ForEach(blocks) { block in Group { if rich { @@ -431,7 +475,7 @@ public struct MarkdownPreviewView: View { .onChange(of: highlightToken) { _, _ in tryHighlightJump(proxy: proxy, blocks: blocks) } .onChange(of: markdown) { _, _ in tryHighlightJump(proxy: proxy, blocks: blocks) } } - .background(Color.vaultReadingBg) + .background(theme?.nativePage ?? Color.vaultReadingBg) .environment(\.openURL, linkAction) .axMarker("knowledge-preview") } @@ -456,20 +500,37 @@ public struct MarkdownPreviewView: View { } private var blockStyle: MarkdownStyle { - MarkdownStyle.vault.with { $0.codeCopyButton = config.codeBlockBar } + let base = theme?.native(.vault) ?? .vault + return base.with { + $0.codeCopyButton = config.codeBlockBar + // A reading pane is where people copy from: every block selects, not only code. + $0.selection = .all + let scale = CGFloat(zoom) + if abs(scale - 1) > 0.001 { + $0.bodySize *= scale + $0.headingScale *= scale + $0.codeFontSize = ($0.codeFontSize ?? $0.bodySize / scale - 1.5) * scale + } + } } private func scrollIfNeeded(proxy: ScrollViewProxy, blocks: [MarkdownBlocks.Block]) { guard let target = scrollTarget, !target.isEmpty else { return } let slug = MarkdownBlocks.slugify(target) + // Unique like the page's ids: `examples-1` is the second "Examples"; plain text finds the first. + let headings = blocks.filter { if case .heading = $0 { return true } else { return false } } + let unique = MarkdownBlocks.uniqueSlugs(headings.map { block in + if case .heading(_, _, let s, _) = block { return s } else { return "" } + }) + if let at = unique.firstIndex(of: slug) { + let block = headings[at] + GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { proxy.scrollTo(block.id, anchor: .top) } + return + } for block in blocks { - if case .heading(_, _, let s, _) = block, s == slug { - withAnimation(.easeOut(duration: 0.25)) { proxy.scrollTo(block.id, anchor: .top) } - return - } // Block refs: find the paragraph containing "^block" if target.hasPrefix("^"), case .paragraph(let text, _) = block, text.contains(target) { - withAnimation(.easeOut(duration: 0.25)) { proxy.scrollTo(block.id, anchor: .top) } + GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { proxy.scrollTo(block.id, anchor: .top) } return } } @@ -487,7 +548,7 @@ public struct MarkdownPreviewView: View { ?? blocks.first { b in terms.contains { blockText(b).lowercased().contains($0) } } guard let target else { return } // doc not loaded yet — wait for markdown change lastHandledToken = highlightToken - withAnimation(.easeInOut(duration: 0.35)) { + GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { proxy.scrollTo(target.id, anchor: .center) } flashBlockId = target.id @@ -497,9 +558,9 @@ public struct MarkdownPreviewView: View { /// Two flash pulses on the matched block. private func blink(_ remaining: Int) { guard remaining > 0 else { flashBlockId = nil; return } - withAnimation(.easeInOut(duration: 0.22)) { flashOn = true } + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) { flashOn = true } DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) { - withAnimation(.easeInOut(duration: 0.22)) { flashOn = false } + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) { flashOn = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.26) { blink(remaining - 1) } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownSourceEditor.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownSourceEditor.swift index 462b9d69e6..bb68d6b58a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownSourceEditor.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownSourceEditor.swift @@ -30,15 +30,30 @@ final class SourceTextView: NSTextView { /// The lines that differ from HEAD, drawn as bars in the left inset (green added, blue changed, a red mark where /// lines were removed). A click on a bar calls `onHunk` with the bar's rect. var gitHunks: [GitHunk] = [] { - didSet { if gitHunks != oldValue { needsDisplay = true } } + didSet { + guard gitHunks != oldValue else { return } + needsDisplay = true + window?.invalidateCursorRects(for: self) + setAccessibilityCustomActions(gitHunks.isEmpty ? nil : hunkActions()) + } } var onHunk: ((GitHunk, NSRect) -> Void)? + /// The tooltips of the bars on screen, removed before the next set is added. + private var hunkTips: [NSView.ToolTipTag] = [] override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) + // Colour is not the only signal when the reader asks for that: changed lines draw as a dashed bar. + let dashed = NSWorkspace.shared.accessibilityDisplayShouldDifferentiateWithoutColor for (hunk, bar) in hunkBars() where bar.intersects(rect) { Self.color(hunk.kind).setFill() - if hunk.kind == .deleted { + if hunk.kind == .modified, dashed { + var y = bar.minY + while y < bar.maxY { + NSBezierPath(rect: NSRect(x: bar.minX, y: y, width: bar.width, height: min(3, bar.maxY - y))).fill() + y += 5 + } + } else if hunk.kind == .deleted { // A small wedge on the line the removed text stood before. let path = NSBezierPath() path.move(to: NSPoint(x: bar.minX, y: bar.minY)) @@ -53,6 +68,65 @@ final class SourceTextView: NSTextView { } + /// The pointing hand and a tooltip over each bar on screen: the bars open the change, so they look clickable. + override func resetCursorRects() { + super.resetCursorRects() + hunkTips.forEach { removeToolTip($0) } + hunkTips = [] + guard onHunk != nil else { return } + let visible = visibleRect + for (hunk, bar) in hunkBars() { + let target = bar.insetBy(dx: -4, dy: -2) + guard target.intersects(visible) else { continue } + addCursorRect(target, cursor: .pointingHand) + hunkTips.append(addToolTip(target, owner: Self.hunkTip(hunk.kind) as NSString, userData: nil)) + } + } + + /// What a bar is, in words: the tooltip and the spoken name of the change. + static func hunkTip(_ kind: GitHunk.Kind) -> String { + switch kind { + case .added: return "Added lines. Click to see the change" + case .modified: return "Changed lines. Click to see the change" + case .deleted: return "Removed lines. Click to see what was removed" + } + } + + /// The bars from the keyboard and VoiceOver: Next change and Previous change move the caret to a hunk and open it, + /// as a click on its bar does. + private func hunkActions() -> [NSAccessibilityCustomAction] { + [ + NSAccessibilityCustomAction(name: "Next change") { [weak self] in self?.openHunk(step: 1) ?? false }, + NSAccessibilityCustomAction(name: "Previous change") { [weak self] in self?.openHunk(step: -1) ?? false }, + ] + } + + /// The hunk after (or before) the caret's line, wrapping at the ends; false when there is none. + @discardableResult + func openHunk(step: Int) -> Bool { + let bars = hunkBars() + let caretLine = SourceEditing.line(at: selectedRange().location, starts: SourceEditing.lineStarts(string)) + guard let index = Self.hunkIndex(bars.map(\.0), from: caretLine, step: step) else { return false } + let (hunk, bar) = bars[index] + let starts = SourceEditing.lineStarts(string) + if hunk.newStart < starts.count { + setSelectedRange(NSRange(location: starts[hunk.newStart], length: 0)) + scrollRangeToVisible(selectedRange()) + } + GenAnnounce.post(Self.hunkTip(hunk.kind).components(separatedBy: ".").first ?? "") + onHunk?(hunk, bar) + return true + } + + /// The next hunk that starts after `line` (step 1) or the last one that starts before it (step -1), wrapping. + static func hunkIndex(_ hunks: [GitHunk], from line: Int, step: Int) -> Int? { + guard !hunks.isEmpty else { return nil } + if step >= 0 { + return hunks.firstIndex { $0.newStart > line } ?? 0 + } + return hunks.lastIndex { $0.newStart < line } ?? hunks.count - 1 + } + static func color(_ kind: GitHunk.Kind) -> NSColor { switch kind { case .added: return NSColor(GitChangeKind.added.color) @@ -224,6 +298,10 @@ struct MarkdownSourceEditor: NSViewRepresentable { var onOpenLink: ((String) -> Void)? = nil /// Vault names for `[[` and tags for `#`. nil = no completion. var completions: ((MarkdownCompletion.Request) -> [String])? = nil + /// The list opens by itself after `[[` or `#word` (the setting "Suggest links and tags while typing"). Off: only + /// Esc (AppKit's Complete) opens it. + /// nil: follow the setting. + var completesWhileTyping: Bool? = nil /// Files dropped onto the editor. nil = AppKit's own handling, which /// pastes the path into the document. var onDropFiles: (([URL]) -> Void)? = nil @@ -278,6 +356,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { view.usesFindBar = true view.textContainerInset = NSSize(width: 12, height: 12) view.setAccessibilityIdentifier("markdown-source-view") + view.setAccessibilityLabel("Markdown source") scroll.drawsBackground = true context.coordinator.view = view context.coordinator.watchScroll(scroll) @@ -303,13 +382,7 @@ struct MarkdownSourceEditor: NSViewRepresentable { source.gitHunks = gitHunks } if view.string != text { - context.coordinator.invalidateLineStarts() - let sel = view.selectedRange() - context.coordinator.programmaticSelection = true - view.string = text - let end = (text as NSString).length - view.setSelectedRange(NSRange(location: min(sel.location, end), length: 0)) - context.coordinator.programmaticSelection = false + context.coordinator.replaceFromOutside(with: text, in: view) } if selection == nil { context.coordinator.lastSelection = nil } if let wanted = selection, context.coordinator.lastSelection != wanted { @@ -332,6 +405,27 @@ struct MarkdownSourceEditor: NSViewRepresentable { } } + /// The smallest replacement that turns `old` into `new`: the common start and end are kept, in UTF-16 units. + static func changedRange(old: NSString, new: NSString) -> (range: NSRange, replacement: String) { + let oldLength = old.length + let newLength = new.length + var prefix = 0 + let shorter = min(oldLength, newLength) + while prefix < shorter, old.character(at: prefix) == new.character(at: prefix) { prefix += 1 } + var suffix = 0 + while suffix < shorter - prefix, + old.character(at: oldLength - 1 - suffix) == new.character(at: newLength - 1 - suffix) { suffix += 1 } + // Never split a surrogate pair: a cut that lands before a low surrogate moves back by one. + func splitsPair(_ text: NSString, at index: Int) -> Bool { + index > 0 && index < text.length && UTF16.isTrailSurrogate(text.character(at: index)) + } + while prefix > 0, splitsPair(old, at: prefix) || splitsPair(new, at: prefix) { prefix -= 1 } + while suffix > 0, splitsPair(old, at: oldLength - suffix) || splitsPair(new, at: newLength - suffix) { suffix -= 1 } + let range = NSRange(location: prefix, length: oldLength - prefix - suffix) + let replacement = new.substring(with: NSRange(location: prefix, length: newLength - prefix - suffix)) + return (range, replacement) + } + private static func reveal(_ range: NSRange, in view: NSTextView) { view.scrollRangeToVisible(range) view.showFindIndicator(for: range) @@ -436,6 +530,34 @@ struct MarkdownSourceEditor: NSViewRepresentable { func invalidateLineStarts() { cachedStarts = nil } + /// True while a change from outside lands, so it opens no completion list. + private var replacingFromOutside = false + + /// Text changed from outside the editor (Replace All, Restore, Merge, Discard edits and reload): only the part + /// that differs is replaced, through the text view, so it is one step on the editor's undo stack and ⌘Z takes + /// it back. Assigning `string` registered nothing, so ⌘Z after Replace All undid the typing before it. + func replaceFromOutside(with text: String, in view: NSTextView) { + invalidateLineStarts() + let sel = view.selectedRange() + programmaticSelection = true + replacingFromOutside = true + defer { + replacingFromOutside = false + programmaticSelection = false + } + let change = MarkdownSourceEditor.changedRange(old: view.string as NSString, new: text as NSString) + view.breakUndoCoalescing() + if view.shouldChangeText(in: change.range, replacementString: change.replacement) { + view.textStorage?.replaceCharacters(in: change.range, with: change.replacement) + view.didChangeText() + } else { + view.string = text + } + view.breakUndoCoalescing() + let end = (text as NSString).length + view.setSelectedRange(NSRange(location: min(sel.location, end), length: 0)) + } + func textViewDidChangeSelection(_ notification: Notification) { guard let view, let report = parent.onSelectionChange else { return } report(view.selectedRange(), !programmaticSelection) @@ -451,7 +573,9 @@ struct MarkdownSourceEditor: NSViewRepresentable { /// re-enters this while it inserts the chosen value, hence the guard. private var completing = false private func offerCompletion() { - guard !completing, parent.completions != nil, + let automatic = parent.completesWhileTyping + ?? MainActor.assumeIsolated { GenesisKitMarkdownConfiguration.shared.completeWhileTyping } + guard automatic, !completing, !replacingFromOutside, parent.completions != nil, let view = view as? SourceTextView, view.completionRequest != nil else { return } completing = true diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownStatusBar.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownStatusBar.swift index cf7ca338e8..5b9fb74bf7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownStatusBar.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownStatusBar.swift @@ -88,28 +88,34 @@ struct MarkdownStatusBar: View { Text(model.theme.label) } .foregroundColor(model.theme.textSecondary) + .frame(minHeight: 22) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) - .instantTooltip("Theme: \(model.theme.label) — click for \(model.theme.next.label)") + .instantTooltip("Theme: \(model.theme.label). Click for \(model.theme.next.label).") .hoverHighlight(color: model.theme.accent, cornerRadius: 5, fillOpacity: 0.12) .accessibilityIdentifier("markdown-theme-toggle") } .onAppear { git.follow(configuration.git ? doc.url : nil) } .onChange(of: doc.url) { _, url in git.follow(configuration.git ? url : nil) } .onChange(of: configuration.git) { _, on in git.follow(on ? doc.url : nil) } + // The file changing under unsaved edits happens without the reader: say it (UX audit E6). + .onChange(of: doc.hasDiskChange) { _, changed in + if changed { GenAnnounce.post("\(doc.title) changed on disk while you have unsaved edits.", priority: .high) } + } // A save changes the file's state before git's own folder changes (an unstaged edit touches no index). .onChange(of: doc.modifiedAt) { _, _ in guard configuration.git else { return } git.refresh() GitRepos.shared.noteSaved(doc.url) } - .font(GenTypography.mono(9.5)) + .font(GenTypography.mono(11)) // Secondary, not muted: every word here is information (Martin, 2026-10-13: the bar read too gray). .foregroundColor(model.theme.textSecondary) .lineLimit(1) .padding(.horizontal, 12) - .padding(.vertical, 5) + // 22 pt targets (the theme button) with 11 pt text: the bar keeps about its old height with less padding. + .padding(.vertical, 2) .background(model.theme.chrome) .overlay(alignment: .top) { Rectangle().fill(model.theme.border).frame(height: 1) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTheme.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTheme.swift index 405d9a1f7b..75f413e3f1 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTheme.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTheme.swift @@ -319,6 +319,26 @@ struct MarkdownTheme: Hashable, Identifiable { isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.06) } + // MARK: - Native renderers + + /// The Native and Native+ panes in this palette. The Genesis palette keeps the vault look they always had; + /// Obsidian and Light take their page, text and accent colours, so the pane follows the theme cycle. + func native(_ style: MarkdownStyle) -> MarkdownStyle { + guard palette != .genesis else { return style } + return style.with { + $0.textColor = text + $0.secondaryColor = textSecondary + $0.mutedColor = textMuted + $0.accentColor = accent + $0.linkColor = accent + $0.codeColor = text + $0.codeBackground = isDark ? Color.black.opacity(0.3) : Color.black.opacity(0.05) + } + } + + /// Behind a native pane: the vault's near-black for Genesis, the palette's page otherwise. + var nativePage: Color { palette == .genesis ? .vaultReadingBg : page } + // MARK: - Page (web renderer) /// The `--dd-*` variables the renderer's stylesheet reads, plus diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTreeTable.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTreeTable.swift index cdccbaec71..f2af79db4b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTreeTable.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownTreeTable.swift @@ -23,6 +23,8 @@ struct MarkdownTreeTable<Revision: Equatable>: NSViewRepresentable { let revision: Revision /// The shared scroll handle used by reveals and history restoration. let box: MarkdownTreeScrollBox + /// What the keyboard does on a row (Up, Down, Left, Right, Return). nil: the table never takes the keyboard. + var keys: MarkdownTreeKeys? = nil /// Builds the hosted SwiftUI content for a row when the table needs it. let cell: (FileTreeRow) -> AnyView @@ -46,6 +48,7 @@ struct MarkdownTreeTable<Revision: Equatable>: NSViewRepresentable { table.usesAutomaticRowHeights = false table.dataSource = context.coordinator table.delegate = context.coordinator + table.coordinatorKeys = context.coordinator let scroll = NSScrollView() scroll.documentView = table @@ -88,6 +91,8 @@ struct MarkdownTreeTable<Revision: Equatable>: NSViewRepresentable { let rowsChanged = force || coordinator.rows != rows let revisionChanged = force || coordinator.reduceMotion != reduced || coordinator.revision as? Revision != revision coordinator.reduceMotion = reduced + coordinator.keys = keys + (table as? MarkdownTreeTableView)?.takesKeyboard = keys != nil let old = coordinator.rows coordinator.rows = rows coordinator.revision = revision @@ -106,6 +111,7 @@ struct MarkdownTreeTable<Revision: Equatable>: NSViewRepresentable { } else if revisionChanged { coordinator.rebuildVisible() } + if rowsChanged { coordinator.keepCursor() } } /// A folder opening or closing: its rows slide in or out, and the rows whose value changed (the folder's chevron) @@ -138,7 +144,7 @@ struct MarkdownTreeTable<Revision: Equatable>: NSViewRepresentable { /// Keeps row values and builds reusable cells for the AppKit table. @MainActor - final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, MarkdownTreeKeyTarget { /// The table supplied by the representable, held without extending its lifetime. weak var table: NSTableView? /// The row snapshot currently exposed to the table data source. @@ -155,6 +161,75 @@ struct MarkdownTreeTable<Revision: Equatable>: NSViewRepresentable { var rowHeight: CGFloat? /// The current builder installed by the representable for reusable cells. var cell: (FileTreeRow) -> AnyView = { _ in AnyView(EmptyView()) } + /// The keyboard's verbs, from the representable. + var keys: MarkdownTreeKeys? + /// The row the keyboard is on, by identity, so a folder opening above it does not move it to another row. + private var cursorKey: FileTreeRowKey? + + /// The keyboard row's index in the current rows. + var cursor: Int? { cursorKey.flatMap { key in rows.firstIndex { $0.key == key } } } + + /// Puts the keyboard on `index` (nil: off), selects it in the table and scrolls it into view. + func setCursor(_ index: Int?) { + guard let table else { return } + guard let index, rows.indices.contains(index) else { + cursorKey = nil + table.deselectAll(nil) + return + } + cursorKey = rows[index].key + table.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) + table.scrollRowToVisible(index) + if NSWorkspace.shared.isVoiceOverEnabled { + GenAnnounce.post(MarkdownTreeKeys.spoken(rows[index]), priority: .low) + } + } + + /// The rows changed (a folder opened, a listing landed): the keyboard stays on its row, or leaves when it is gone. + func keepCursor() { + guard cursorKey != nil, let table else { return } + if let index = cursor { + if table.selectedRow != index { table.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) } + } else { + cursorKey = nil + table.deselectAll(nil) + } + } + + /// The table took the keyboard: start on the row it was clicked on, else the open file, else the first row. + func beginKeyboard(at clicked: Int?) { + if let clicked { setCursor(clicked); return } + guard cursor == nil else { return } + setCursor(keys?.start(rows) ?? (rows.isEmpty ? nil : 0)) + } + + func endKeyboard() { + cursorKey = nil + table?.deselectAll(nil) + } + + /// Runs one key; false hands it on. + func handle(_ key: MarkdownTreeKey) -> Bool { + guard let keys else { return false } + switch MarkdownTreeKeys.action(for: key, rows: rows, cursor: cursor) { + case .none: + return false + case .select(let index): + setCursor(index) + case .toggle(let index): + keys.toggle(rows[index]) + case .open(let index): + keys.open(rows[index]) + case .navigate(let index): + keys.navigate(rows[index]) + case .up: + keys.up() + case .clear: + endKeyboard() + table?.window?.makeFirstResponder(nil) + } + return true + } /// Reports exactly the number of rows in the current snapshot. func numberOfRows(in tableView: NSTableView) -> Int { rows.count } @@ -183,10 +258,48 @@ struct MarkdownTreeTable<Revision: Equatable>: NSViewRepresentable { } } -/// The table under the rows: it never takes the keyboard or a selection, so a click reaches the row's button. +/// The table under the rows. A click reaches the row's button; the keyboard, when the host gives it verbs +/// (`MarkdownTreeKeys`), walks the rows with a selection of its own, which the pointer's hover never moves. final class MarkdownTreeTableView: NSTableView { - /// Keeps keyboard focus in the row controls rather than the table itself. - override var acceptsFirstResponder: Bool { false } + /// The coordinator that owns the rows and the keyboard row. + weak var coordinatorKeys: (any MarkdownTreeKeyTarget)? + /// Whether the host gave the keyboard any verbs. + var takesKeyboard = false { + didSet { if takesKeyboard != oldValue { selectionHighlightStyle = takesKeyboard ? .regular : .none } } + } + /// A row click that brings the keyboard here, read once by `becomeFirstResponder`. + var clickedRowForKeyboard: Int? + + override var acceptsFirstResponder: Bool { takesKeyboard } + + override func becomeFirstResponder() -> Bool { + guard super.becomeFirstResponder() else { return false } + coordinatorKeys?.beginKeyboard(at: clickedRowForKeyboard) + clickedRowForKeyboard = nil + return true + } + + override func resignFirstResponder() -> Bool { + guard super.resignFirstResponder() else { return false } + coordinatorKeys?.endKeyboard() + return true + } + + override func keyDown(with event: NSEvent) { + if let key = MarkdownTreeKey(event), coordinatorKeys?.handle(key) == true { return } + super.keyDown(with: event) + } + + /// A click on a row puts the keyboard there, as in Finder's sidebar: Up and Down go on from the clicked row. + func noteRowClick(_ row: Int) { + guard takesKeyboard, row >= 0 else { return } + if window?.firstResponder === self { + coordinatorKeys?.beginKeyboard(at: row) + } else { + clickedRowForKeyboard = row + window?.makeFirstResponder(self) + } + } /// A table hands a click to a subview only when this says yes, and it says yes for controls alone: without it the /// row's SwiftUI button never saw a click (2026-10-15, the first live check of the table). @@ -211,7 +324,7 @@ final class MarkdownTreeTableView: NSTableView { /// One reused cell: a hosting view whose root view is swapped when the cell shows another row. final class MarkdownTreeTableCell: NSView { - private let host = NSHostingView(rootView: AnyView(EmptyView())) + private let host = MarkdownTreeCellHost(rootView: AnyView(EmptyView())) /// Creates a reusable cell whose hosting view fills the cell without sizing the window. init(identifier: NSUserInterfaceItemIdentifier) { @@ -248,3 +361,112 @@ final class MarkdownTreeTableCell: NSView { return max(ceil(probe.fittingSize.height), 1) } } + +/// A cell's hosting view: a click on the row tells the table, so the keyboard continues from that row. +final class MarkdownTreeCellHost: NSHostingView<AnyView> { + override func mouseDown(with event: NSEvent) { + var view: NSView? = superview + while let current = view, !(current is MarkdownTreeTableView) { view = current.superview } + if let table = view as? MarkdownTreeTableView { + table.noteRowClick(table.row(for: self)) + } + super.mouseDown(with: event) + } +} + +/// The coordinator side of the table's keyboard. +@MainActor +protocol MarkdownTreeKeyTarget: AnyObject { + func beginKeyboard(at clicked: Int?) + func endKeyboard() + func handle(_ key: MarkdownTreeKey) -> Bool +} + +/// A key the tree answers. +enum MarkdownTreeKey: Equatable { + case up, down, left, right + /// Return or Enter. + case open + /// ⌘↓: a folder becomes the root, a file opens. + case navigate + /// ⌘↑: the folder above the root. + case parent + case escape + + init?(_ event: NSEvent) { + let command = event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) + switch event.keyCode { + case 126: self = command ? .parent : .up + case 125: self = command ? .navigate : .down + case 123: self = .left + case 124: self = .right + case 36, 76: self = .open + case 53: self = .escape + default: return nil + } + } +} + +/// What a key does to the rows: pure, so it is tested without a window. +enum MarkdownTreeKeyAction: Equatable { + case none + case select(Int) + case toggle(Int) + case open(Int) + case navigate(Int) + case up + case clear +} + +/// The verbs the keyboard reaches: the host's own, so a key does what a click on the row does. +struct MarkdownTreeKeys { + /// Opens a file (Return on a file row). + var open: (FileTreeRow) -> Void + /// Opens or closes a folder (Right, Left, Return on a folder row). + var toggle: (FileTreeRow) -> Void + /// Makes a folder the root (⌘↓ on a folder row). + var navigate: (FileTreeRow) -> Void + /// The folder above the root (⌘↑). + var up: () -> Void + /// The row the keyboard starts on (the open file), or nil for the first row. + var start: ([FileTreeRow]) -> Int? + + /// Up and Down move, Right opens a folder or goes into it, Left closes it or goes to its parent, Return opens a + /// file or toggles a folder, ⌘↓ goes into a folder, ⌘↑ goes up, Esc lets the keyboard go. + static func action(for key: MarkdownTreeKey, rows: [FileTreeRow], cursor: Int?) -> MarkdownTreeKeyAction { + if key == .parent { return .up } + if key == .escape { return cursor == nil ? .none : .clear } + guard !rows.isEmpty else { return .none } + guard let index = cursor, rows.indices.contains(index) else { + return key == .down || key == .up ? .select(0) : .none + } + let row = rows[index] + switch key { + case .down: + return .select(min(index + 1, rows.count - 1)) + case .up: + return .select(max(index - 1, 0)) + case .right: + guard row.isFolder else { return .none } + if !row.expanded { return .toggle(index) } + let next = index + 1 + return next < rows.count && rows[next].depth > row.depth ? .select(next) : .none + case .left: + if row.isFolder, row.expanded { return .toggle(index) } + guard row.depth > 0 else { return .none } + let parent = rows[..<index].lastIndex { $0.depth == row.depth - 1 && $0.isFolder } + return parent.map(MarkdownTreeKeyAction.select) ?? .none + case .open: + return row.isFolder ? .toggle(index) : .open(index) + case .navigate: + return row.isFolder ? .navigate(index) : .open(index) + case .parent, .escape: + return .none + } + } + + /// A row as VoiceOver says it when the keyboard lands on it. + static func spoken(_ row: FileTreeRow) -> String { + row.isFolder ? "\(row.name), folder, \(row.expanded ? "expanded" : "collapsed")" : row.name + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownWebView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownWebView.swift index 59dec99f87..51ba0f7de7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownWebView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Views/MarkdownWebView.swift @@ -225,6 +225,7 @@ struct MarkdownWebView: NSViewRepresentable { /// (`MarkdownWebSpare.recycle`), so the next tab switch renders into a loaded page instead of building one. static func dismantleNSView(_ web: WKWebView, coordinator: Coordinator) { coordinator.renderReady = nil + coordinator.hideNotice() coordinator.web = nil if coordinator.parent.proxy?.web === web { coordinator.parent.proxy?.web = nil @@ -301,9 +302,12 @@ struct MarkdownWebView: NSViewRepresentable { c.lastDiffMarks = diffMarks c.applyDiffMarks() } - if c.lastScrollTarget != scrollTarget, let t = scrollTarget, !t.isEmpty { + // nil is remembered too, so the same heading asked for again after a reset jumps again. + if c.lastScrollTarget != scrollTarget { c.lastScrollTarget = scrollTarget - c.eval("window.gnScrollToSlug(\(Self.jsString(MarkdownBlocks.slugify(t))))") + if let t = scrollTarget, !t.isEmpty { + c.scrollToHeading(t) + } } if c.lastHighlightToken != highlightToken { c.lastHighlightToken = highlightToken @@ -338,6 +342,12 @@ struct MarkdownWebView: NSViewRepresentable { return MarkdownMedia.Context(folder: documentURL.deletingLastPathComponent(), vaultRoot: vault) } + /// A picture the page loads from the web (`https:`), which the viewer never opens. + static func isRemoteSource(_ src: String) -> Bool { + let lower = src.trimmingCharacters(in: .whitespaces).lowercased() + return lower.hasPrefix("https:") || lower.hasPrefix("http:") + } + static func jsString(_ s: String) -> String { // Encode as a JSON string literal — safe for arbitrary content. let data = try? JSONSerialization.data(withJSONObject: [s]) @@ -425,6 +435,11 @@ struct MarkdownWebView: NSViewRepresentable { var lastLineHighlight: LineHighlight? var lastGitMarks: [GitMark] = [] var lastDiffMarks: [DiffMark] = [] + /// The notice over the page (`MarkdownPageNotice`), and what it shows. + var noticeHost: MarkdownPageNoticeHost? + var currentNotice: MarkdownPageNotice? + var noticeToken = 0 + var noticeFrameWatch: NSObjectProtocol? func applyDiffMarks() { eval("window.gnDiff([\(lastDiffMarks.map(\.json).joined(separator: ","))])") @@ -527,11 +542,17 @@ struct MarkdownWebView: NSViewRepresentable { let mediaJSON = (try? JSONSerialization.data(withJSONObject: map)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" web?.evaluateJavaScript( "window.gnRender(\(MarkdownWebView.jsString(body)),\(props),\(editable),\(vocabulary),\(mediaJSON),\(parent.lineNumbers),\(tag))" - ) { [weak self] _, error in + ) { [weak self] result, error in if let error { MarkdownWebView.report("render \(tag) call failed: \(error.localizedDescription)") } else { self?.renderReady?() + // A render that drew takes a failure notice away (Retry worked, or the text was fixed). + if (result as? String) == "ok" { + MainActor.assumeIsolated { + if self?.currentNotice?.kind == .failure { self?.hideNotice() } + } + } } } confirmRender(tag) @@ -578,6 +599,9 @@ struct MarkdownWebView: NSViewRepresentable { } guard self.resends < 3, let markdown = self.lastMarkdown else { MarkdownWebView.report("render \(tag) never reached the page (it has \(arrived)), giving up") + MainActor.assumeIsolated { + self.show(.failure("Render \(tag) never reached the page after 3 sends (the page has \(arrived)).")) + } return } self.resends += 1 @@ -591,12 +615,37 @@ struct MarkdownWebView: NSViewRepresentable { /// is sent again once it is ready. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { ready = false + processEnds += 1 + // A page whose process ends again before it ever got ready would only loop: the reader gets Retry. + guard processEnds < 2 else { + MarkdownWebView.report("web process ended again before the page was ready, giving up") + MainActor.assumeIsolated { + show(.failure("The page's web process ended twice before the page was ready.")) + } + return + } MarkdownWebView.report("web process ended, reloading the page") webView.loadHTMLString(MarkdownWebView.template, baseURL: URL(string: "\(MarkdownWebView.assetScheme)://asset/")) armReadyWatchdog() } private var watchdogReloads = 0 + /// Web process ends since the page was last ready. + private var processEnds = 0 + + /// Retry on the failure notice: the template again, from a clean count, and the text once it is ready. + @MainActor + func retryPage() { + hideNotice() + guard let web else { return } + watchdogReloads = 0 + processEnds = 0 + resends = 0 + ready = false + MarkdownWebView.report("retry: reloading the page") + web.loadHTMLString(MarkdownWebView.template, baseURL: URL(string: "\(MarkdownWebView.assetScheme)://asset/")) + armReadyWatchdog() + } /// A page that is not ready 5 s after it started loading is loaded again, at most twice. func armReadyWatchdog() { @@ -604,6 +653,9 @@ struct MarkdownWebView: NSViewRepresentable { guard let self, !self.ready, let web = self.web else { return } guard self.watchdogReloads < 2 else { MarkdownWebView.report("page still not ready after \(self.watchdogReloads) reloads") + MainActor.assumeIsolated { + self.show(.failure("The page was not ready after \(self.watchdogReloads) reloads of 5 s each.")) + } return } self.watchdogReloads += 1 @@ -618,6 +670,7 @@ struct MarkdownWebView: NSViewRepresentable { func becameReady(_ via: String) { guard !ready else { return } ready = true + processEnds = 0 if let vars = lastCSSVariables { applyTheme(vars) } @@ -643,10 +696,17 @@ struct MarkdownWebView: NSViewRepresentable { // A [[Note#Heading]] click opens a new pane: its target arrived // before the page was ready and would otherwise be dropped. if let t = lastScrollTarget, !t.isEmpty { - eval("window.gnScrollToSlug(\(MarkdownWebView.jsString(MarkdownBlocks.slugify(t))))") + scrollToHeading(t) } } + /// A heading by its text or its slug (`examples-1` for the second "Examples"). The page names a heading it + /// does not have (`deadAnchor`), so the click that asked for it gets an answer. + func scrollToHeading(_ target: String) { + let label = target.replacingOccurrences(of: MarkdownOutline.repeatMark, with: "") + eval("window.gnScrollToSlug(\(MarkdownWebView.jsString(MarkdownBlocks.slugify(target))),\(MarkdownWebView.jsString(label)))") + } + // JS bridge. func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) { // Only the page's own script talks to Swift. Frames are stripped and @@ -662,7 +722,21 @@ struct MarkdownWebView: NSViewRepresentable { func n(_ key: String) -> CGFloat { CGFloat(r[key] as? Double ?? 0) * zoom } parent.onGitMark?(index, NSRect(x: n("x"), y: n("y"), width: max(4, n("w")), height: max(4, n("h")))) case "renderError": - MarkdownWebView.report("render failed: \(body["message"] as? String ?? "?") \(body["stack"] as? String ?? "")") + let message = body["message"] as? String ?? "?" + let stack = body["stack"] as? String ?? "" + MarkdownWebView.report("render failed: \(message) \(stack)") + MainActor.assumeIsolated { show(.failure(stack.isEmpty ? message : "\(message)\n\(stack)")) } + case "deadAnchor": + let label = (body["label"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? body["slug"] as? String ?? "" + MainActor.assumeIsolated { show(.noHeading(label)) } + case "image.refused": + let src = body["src"] as? String ?? "" + MainActor.assumeIsolated { show(MarkdownWebView.isRemoteSource(src) ? .remotePicture(src) : .pictureGone) } + case "propsRemoved": + let name = body["key"] as? String ?? "" + let notice: MarkdownPageNotice = (body["item"] as? String).map { .itemRemoved($0, from: name) } + ?? .propertyRemoved(name) + MainActor.assumeIsolated { show(notice) } case "pageError": MarkdownWebView.report("page error line \(body["line"] as? Int ?? 0): \(body["message"] as? String ?? "?")") case "scroll": @@ -672,9 +746,19 @@ struct MarkdownWebView: NSViewRepresentable { case "section": if let slug = body["slug"] as? String { parent.onActiveSection(slug) } case "openurl": - if let s = body["url"] as? String, let url = MarkdownWebView.externalURL(s) { NSWorkspace.shared.open(url) } + guard let s = body["url"] as? String else { break } + if let url = MarkdownWebView.externalURL(s) { + NSWorkspace.shared.open(url) + } else { + MainActor.assumeIsolated { show(.blockedLink(s)) } + } case "openrel": - if let href = body["href"] as? String, MarkdownWebView.isLocalHref(href) { parent.onRelativeLink?(href) } + guard let href = body["href"] as? String, MarkdownWebView.isLocalHref(href) else { break } + if let open = parent.onRelativeLink { + open(href) + } else { + MainActor.assumeIsolated { show(.noFileLinks) } + } case "json": // Only a token this render registered from the document's own references. guard let token = body["token"] as? String else { break } @@ -896,7 +980,13 @@ img{max-width:100%;height:auto;border-radius:6px;} .dd-md-embed-stub{display:inline-flex;gap:8px;align-items:center;background:var(--dd-bg-panel); border:1px dashed var(--dd-border-strong);border-radius:6px;padding:2px 10px;color:var(--dd-text-dim);font-size:.9em;} .dd-md-embed-icon{color:var(--dd-accent);} -.gn-img-missing{opacity:.7;border-style:dashed;} +.gn-img-missing{opacity:.85;border-style:dashed;} +#gn-article.gn-is-empty::after{content:"This note is empty.";display:block;color:var(--dd-text-dim);font-style:italic;} +.gn-img-pending{display:inline-block;min-width:120px;min-height:64px;border-radius:6px;background:var(--dd-bg-panel);vertical-align:middle;} +#gn-article img[data-gn-img]{cursor:zoom-in;} +#gn-article img[data-gn-img]:focus-visible{outline:2px solid var(--dd-accent);outline-offset:2px;} +a.dd-md-embed-stub{cursor:pointer;} +a.dd-md-embed-stub:hover{border-color:var(--dd-accent);color:var(--dd-accent);} .gn-json-chip{display:inline-flex;gap:6px;align-items:center;background:var(--dd-bg-panel);border:1px solid var(--dd-border); border-radius:6px;padding:1px 8px;color:var(--dd-text);font:inherit;font-size:.92em;cursor:pointer;text-decoration:none;} .gn-json-chip:hover{border-color:var(--dd-accent);color:var(--dd-accent);} @@ -978,14 +1068,15 @@ mark.gn-find-current{background:rgba(250,204,21,.75);color:#0c0e10;} .gn-prop-value .gn-chip{display:inline-flex;align-items:center;gap:3px;background:var(--dd-bg-elevated);border:1px solid var(--dd-border); border-radius:4px;padding:1px 6px;font-size:.92em;color:var(--dd-text);max-width:100%;} .gn-prop-tags .gn-chip{background:var(--dd-tag-bg);color:var(--dd-tag-text);border-color:rgba(94,234,212,.18);border-radius:999px;} -.gn-chip-x{cursor:pointer;color:var(--dd-text-faint);margin-left:2px;line-height:1;} -.gn-chip-x:hover{color:var(--dd-text);} +.gn-chip-x{cursor:pointer;color:var(--dd-text-faint);margin:-3px -5px -3px 0;padding:3px 5px;border:0;border-radius:4px;background:transparent;font:inherit;line-height:1;} +.gn-chip-x:hover,.gn-chip-x:focus-visible{color:var(--dd-text);background:rgba(127,127,127,.18);} +.gn-chip-x:focus-visible{outline:1px solid var(--dd-accent);outline-offset:0;} .gn-prop-value .gn-chip-in{flex:1 1 80px;} .gn-prop-value a{border-bottom:1px dashed var(--dd-link-underline);} -.gn-prop-remove{flex:none;opacity:0;border:0;background:transparent;color:var(--dd-text-faint);cursor:pointer;font-size:15px;line-height:1;padding:3px 5px;border-radius:4px;} -.gn-prop:hover .gn-prop-remove{opacity:1;} .gn-prop-remove:hover{color:var(--dd-text);background:var(--dd-bg-elevated);} +.gn-prop-remove{flex:none;opacity:0;border:0;background:transparent;color:var(--dd-text-faint);cursor:pointer;font-size:15px;line-height:1;padding:4px 7px;min-width:24px;min-height:24px;border-radius:4px;} +.gn-prop:hover .gn-prop-remove,.gn-prop:focus-within .gn-prop-remove{opacity:1;} .gn-prop-remove:hover{color:var(--dd-text);background:var(--dd-bg-elevated);} +.gn-prop-remove:focus-visible{outline:1px solid var(--dd-accent);outline-offset:-1px;} .gn-props-add{margin:4px 0 0 4px;border:0;background:transparent;color:var(--dd-text-faint);cursor:pointer;font:inherit;font-size:.92em;padding:3px 6px;border-radius:4px;} -.gn-render-error{margin:24px 0;padding:12px 14px;border:1px solid rgba(248,113,113,.5);border-radius:8px;background:rgba(248,113,113,.08);color:var(--dd-text);font-size:.95em;} #gn-git-layer{position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;z-index:2;} .gn-git-bar{position:absolute;width:3px;border:0;padding:0;margin:0;border-radius:2px;pointer-events:auto;cursor:pointer;} .gn-git-bar:focus-visible{outline:2px solid var(--dd-text);outline-offset:4px;} @@ -1025,6 +1116,8 @@ h1:hover > .gn-anchor,h2:hover > .gn-anchor,h3:hover > .gn-anchor,h4:hover > .gn #gn-toast{position:absolute;z-index:60;pointer-events:none;padding:2px 8px;border-radius:6px;transform:translateY(-50%); font:600 11.5px/1.5 var(--dd-sans);color:var(--dd-bg);background:var(--dd-accent);opacity:0;transition:opacity .15s ease-out;} #gn-toast.gn-on{opacity:1;} +/* Read by VoiceOver only: what a button did ("Copied"). */ +#gn-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;} .gn-code{margin:1.15em 0;background:var(--dd-code-bg);border:1px solid var(--dd-border);border-radius:8px;overflow:hidden;} .gn-code > pre{margin:0;border:0;border-radius:0;} .gn-code-bar{display:flex;align-items:center;justify-content:space-between;gap:8px;height:30px;padding:0 6px 0 14px; @@ -1053,7 +1146,7 @@ body.gn-nogrips .gn-sticky-in{max-width:none;} /* A jump leaves the bar's height free above the heading, so the bar never covers it. */ body.gn-sticky-on h1,body.gn-sticky-on h2,body.gn-sticky-on h3,body.gn-sticky-on h4,body.gn-sticky-on h5,body.gn-sticky-on h6{ scroll-margin-top:calc(var(--gn-sticky-h) + 12px);} -@media (prefers-reduced-motion:reduce){.gn-anchor,#gn-toast,.gn-code-copy,#gn-sticky{transition:none;}} +@media (prefers-reduced-motion:reduce){.gn-anchor,#gn-toast,.gn-code-copy,#gn-sticky,details > summary::before{transition:none;}} @media (prefers-reduced-transparency:reduce){#gn-sticky{background:var(--dd-bg);-webkit-backdrop-filter:none;backdrop-filter:none;}} @media print{#gn-sticky,#gn-toast,.gn-anchor,.gn-code-copy{display:none !important;}} body.gn-export #gn-sticky,body.gn-export #gn-toast,body.gn-export .gn-anchor,body.gn-export .gn-code-copy{display:none !important;} @@ -1061,7 +1154,7 @@ body.gn-export #gn-sticky,body.gn-export #gn-toast,body.gn-export .gn-anchor,bod </head> <body> <div id="gn-column"><div class="gn-grip gn-grip-l" role="separator" aria-orientation="vertical" aria-label="Reading width" aria-keyshortcuts="ArrowLeft ArrowRight Enter" tabindex="0"></div><article id="gn-article"></article><div class="gn-grip gn-grip-r" role="separator" aria-orientation="vertical" aria-label="Reading width" aria-keyshortcuts="ArrowLeft ArrowRight Enter" tabindex="0"></div></div> -<nav id="gn-sticky" aria-label="Current section"><div class="gn-sticky-in"></div></nav><div id="gn-toast" role="status" aria-live="polite"></div> +<nav id="gn-sticky" aria-label="Current section"><div class="gn-sticky-in"></div></nav><div id="gn-toast" role="status" aria-live="polite"></div><div id="gn-live" role="status" aria-live="polite"></div> <script nonce="__GN_NONCE__"> // mermaid.min.js (UMD) already set globalThis.mermaid synchronously above. if(window.mermaid){ @@ -1080,6 +1173,10 @@ if(window.mermaid){ (function(){ function gnEsc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');} function gnSlug(s){return String(s).toLowerCase().replace(/[^a-z0-9\s-]/g,'').trim().replace(/\s+/g,'-');} +// Heading ids are unique per render: the second "Examples" is examples-1, the third examples-2, and a suffix +// already taken moves on. MarkdownBlocks.uniqueSlugs does the same for the outline, so a row names one heading. +var gnSlugSeen={}; +function gnUniqueSlug(s){var c=s,n=0;while(Object.prototype.hasOwnProperty.call(gnSlugSeen,c)){n++;c=s+'-'+n;}gnSlugSeen[c]=1;return c;} function gnPost(m){try{window.webkit.messageHandlers.bridge.postMessage(m);}catch(e){}} var HLJS_ALIAS={ts:"typescript",tsx:"typescript",js:"javascript",jsx:"javascript",sh:"bash",shell:"bash",zsh:"bash",yml:"yaml",rb:"ruby",py:"python",rs:"rust",toml:"ini",conf:"ini",text:"plaintext",log:"plaintext"}; var GN_ALIAS={note:"note",abstract:"abstract",summary:"abstract",tldr:"abstract",info:"info",todo:"todo", @@ -1108,7 +1205,7 @@ var gnValidateLink=md.validateLink;md.validateLink=function(url){return /^file:/ md.renderer.rules.heading_open=function(tokens,idx,options,env,self){ var inline=tokens[idx+1]; var text=(inline&&inline.content)?inline.content:''; - tokens[idx].attrSet('id',gnSlug(text)); + tokens[idx].attrSet('id',gnUniqueSlug(gnSlug(text))); return self.renderToken(tokens,idx,options); }; // A file shown as one code block (json, yaml, a source file, a log) numbers @@ -1184,9 +1281,12 @@ md.renderer.rules.wikilink=function(tokens,idx){ +(size?' width="'+size[1]+'"'+(size[2]?' height="'+size[2]+'"':''):'')+'>'; } if(hit&&hit.kind==='json')return gnJSONChip(hit,meta.alias||meta.target); + if(meta.embed&&hit&&hit.kind==='pending')return gnPendingHTML(meta.target); + if(meta.embed&&GN_IMG_EXT.test(meta.target))return gnMissingImage(meta.target).outerHTML; if(meta.embed){ - var missing=GN_IMG_EXT.test(meta.target)?' gn-img-missing" title="Not found: '+gnEsc(meta.target):''; - return '<span class="dd-md-embed-stub'+missing+'" data-target="'+gnEsc(meta.target)+'"><span class="dd-md-embed-icon">⧉</span> '+gnEsc(label)+'</span>'; + // `![[Other note]]`: drawn as a stub, and a click opens the note like its wikilink. + var eref=meta.target+(meta.heading?'#'+meta.heading:''); + return '<a href="#" class="dd-wikilink dd-md-embed-stub" data-wikilink="'+gnEsc(eref)+'" title="Open '+gnEsc(meta.target)+'"><span class="dd-md-embed-icon" aria-hidden="true">⧉</span> '+gnEsc(label)+'</a>'; } var ref=meta.target+(meta.heading?'#'+meta.heading:''); return '<a href="#" class="dd-wikilink" data-wikilink="'+gnEsc(ref)+'">'+gnEsc(label)+'</a>'; @@ -1288,9 +1388,16 @@ function gnJSONChip(hit,label){ return '<button type="button" class="gn-json-chip" aria-expanded="false" data-gn-json="'+gnEsc(hit.token)+'" title="'+gnEsc(hit.name)+'">' +'<span class="gn-json-icon">{ }</span> '+gnEsc(label||hit.name)+'</button>'; } -function gnMissingImage(ref,alt){ - var s=document.createElement('span');s.className='dd-md-embed-stub gn-img-missing';s.title='Not found: '+ref; - s.innerHTML='<span class="dd-md-embed-icon">⧉</span> ';s.appendChild(document.createTextNode(alt||ref));return s; +// A picture that is not there says so in its own words, not only in a tooltip, so VoiceOver reads it too. +function gnMissingImage(ref,alt,why){ + var text=(why||'Picture not found')+': '+(alt||ref); + var s=document.createElement('span');s.className='dd-md-embed-stub gn-img-missing';s.title=ref; + s.setAttribute('role','img');s.setAttribute('aria-label',text); + s.innerHTML='<span class="dd-md-embed-icon" aria-hidden="true">⧉</span> ';s.appendChild(document.createTextNode(text));return s; +} +// The vault scan has not answered yet: a quiet box, no "not found", until Swift renders again. +function gnPendingHTML(ref){ + return '<span class="gn-img-pending" role="img" aria-label="Loading picture '+gnEsc(ref)+'"></span>'; } function gnApplyMedia(frag){ var imgs=frag.querySelectorAll('img');var viewable=0; @@ -1299,9 +1406,18 @@ function gnApplyMedia(frag){ img.removeAttribute('data-gn-ref');img.removeAttribute('srcset');img.removeAttribute('data-gn-img'); var hit=gnMedia[gnMediaKey(ref)]; if(hit&&hit.kind==='image'){img.setAttribute('src',hit.src);} + else if(hit&&hit.kind==='pending'){var tp=document.createElement('template');tp.innerHTML=gnPendingHTML(ref);img.parentNode.replaceChild(tp.content.firstChild,img);continue;} else if(!/^(https?:|data:image\/)/i.test(ref.trim())){img.parentNode.replaceChild(gnMissingImage(ref,img.getAttribute('alt')),img);continue;} + // A file over the size limit, one deleted since the render, a web picture that does not load: the pill + // with the cause, never the browser's broken-picture glyph. + // The element stays (hidden), so the page keeps what the note wrote. + (function(im,r){im.addEventListener('error',function(){if(!im.parentNode||im.gnFailed)return;im.gnFailed=true; + im.style.display='none';im.parentNode.insertBefore(gnMissingImage(r,im.getAttribute('alt'),'Picture could not be loaded'),im.nextSibling);});})(img,ref); // The image viewer opens only what the page loads itself: a registered vault picture or an embedded one. - if(/^(gnmedia:|data:image\/)/i.test(img.getAttribute('src')||''))img.setAttribute('data-gn-img',String(viewable++)); + // Return or Space opens it too, so the keyboard and VoiceOver reach it. + if(/^(gnmedia:|data:image\/)/i.test(img.getAttribute('src')||'')){img.setAttribute('data-gn-img',String(viewable++)); + img.setAttribute('tabindex','0');img.setAttribute('role','button'); + img.setAttribute('aria-label','Open picture'+((img.getAttribute('alt')||'').trim()?' '+img.getAttribute('alt').trim():''));} // `![caption|300](x.png)`: Obsidian's size suffix on the alt text. var alt=img.getAttribute('alt')||'';var size=/\|\s*(\d+)(?:x(\d+))?\s*$/.exec(alt); if(size){img.setAttribute('alt',alt.slice(0,size.index).trim());img.setAttribute('width',size[1]);if(size[2])img.setAttribute('height',size[2]);} @@ -1353,7 +1469,12 @@ window.gnShowJSON=function(token,text){ var GN_SCHEME=/^[a-z][a-z0-9+.-]*:/i; function gnWireLinks(){ var wl=document.querySelectorAll('a.dd-wikilink[data-wikilink]'); - for(var i=0;i<wl.length;i++){(function(a){a.addEventListener('click',function(e){e.preventDefault();var t=a.getAttribute('data-wikilink');if(t.charAt(0)==='#'){window.gnScrollToSlug(gnSlug(t.slice(1)));return;}gnPost({type:'wikilink',target:t});});})(wl[i]);} + for(var i=0;i<wl.length;i++){(function(a){a.addEventListener('click',function(e){e.preventDefault();var t=a.getAttribute('data-wikilink');if(t.charAt(0)==='#'){window.gnScrollToSlug(gnSlug(t.slice(1)),t.slice(1));return;}gnPost({type:'wikilink',target:t});});})(wl[i]);} + // `[Setup](#setup)`: the page's own heading, answered like a wikilink's when it is not there. + var hl=document.querySelectorAll('#gn-article a[href^="#"]:not(.dd-wikilink)'); + for(var h=0;h<hl.length;h++){(function(a){var frag=a.getAttribute('href').slice(1);if(!frag)return; + a.addEventListener('click',function(e){e.preventDefault();var t=frag;try{t=decodeURIComponent(frag);}catch(x){} + window.gnScrollToSlug(gnSlug(t),t);});})(hl[h]);} var xl=document.querySelectorAll('a[href]:not([href^="#"]):not(.dd-wikilink)'); for(var j=0;j<xl.length;j++){(function(a){a.addEventListener('click',function(e){e.preventDefault(); var href=a.getAttribute('href').trim();var scheme=GN_SCHEME.exec(href); @@ -1417,6 +1538,7 @@ function gnPageAfterRender(){ } function gnHeadingText(h){return (h.textContent||'').replace(/\s+/g,' ').trim();} function gnCopied(btn){ + var live=document.getElementById('gn-live');if(live){live.textContent='';live.textContent='Copied';} btn.setAttribute('data-label','Copied');btn.setAttribute('aria-label','Copied');btn.classList.add('gn-done'); clearTimeout(btn.gnT); btn.gnT=setTimeout(function(){btn.setAttribute('data-label','Copy');btn.setAttribute('aria-label','Copy code');btn.classList.remove('gn-done');},1400); @@ -1535,6 +1657,10 @@ function gnPropText(v){ // [[Note]] inside a text value is a link, like in Obsidian. return gnEsc(v).replace(/\[\[([^\]\|#]+)(?:#[^\]\|]*)?(?:\|([^\]]+))?\]\]/g,function(m,t,a){return '<a href="#" class="dd-wikilink" data-wikilink="'+gnEsc(t.trim())+'">'+gnEsc(a||t.trim())+'</a>';}); } +// The rows before the last removal, for the Undo on Swift's "Property removed" notice. +var gnPropsBefore=null; +function gnPropsKeep(){gnPropsBefore=JSON.parse(JSON.stringify(gnProps));} +window.gnPropsUndo=function(){if(!gnPropsBefore)return;gnProps=gnPropsBefore;gnPropsBefore=null;gnPropsPost();}; function gnPropsPost(){ var items=gnProps.map(function(p){return {key:p.key,kind:p.kind,value:p.value||'',items:p.items||[],inline:!!p.inline};}); gnPost({type:'properties',items:items}); @@ -1598,8 +1724,10 @@ function gnPropRow(p,i){ gnPropValue(val,p); row.appendChild(keyBox);row.appendChild(val); if(gnPropsEditable){ - var rm=document.createElement('button');rm.className='gn-prop-remove';rm.title='Remove property';rm.textContent='×'; - rm.addEventListener('click',function(){gnProps.splice(gnProps.indexOf(p),1);gnPropsPost();}); + var rm=document.createElement('button');rm.type='button';rm.className='gn-prop-remove';rm.title='Remove property';rm.textContent='×'; + rm.setAttribute('aria-label','Remove property '+(p.key||'(unnamed)')); + rm.addEventListener('click',function(){gnPropsKeep();gnProps.splice(gnProps.indexOf(p),1);gnPropsPost(); + gnPost({type:'propsRemoved',key:p.key||''});}); row.appendChild(rm); } return row; @@ -1633,8 +1761,10 @@ function gnPropValue(val,p){ function commit(){gnPropsPost();} if(p.kind==='list'||p.kind==='tags'){ (p.items||[]).forEach(function(it,j){var c=document.createElement('span');c.className='gn-chip'; - c.innerHTML='<span>'+(p.kind==='tags'?'#':'')+gnPropText(it)+'</span><span class="gn-chip-x" title="Remove">×</span>'; - c.querySelector('.gn-chip-x').addEventListener('click',function(){p.items.splice(j,1);gnPropsFocus={key:p.key,what:'.gn-chip-in'};commit();}); + c.innerHTML='<span>'+(p.kind==='tags'?'#':'')+gnPropText(it)+'</span><button type="button" class="gn-chip-x" title="Remove">×</button>'; + var x=c.querySelector('.gn-chip-x');x.setAttribute('aria-label','Remove '+(p.kind==='tags'?'tag ':'')+it); + x.addEventListener('click',function(){gnPropsKeep();p.items.splice(j,1);gnPropsFocus={key:p.key,what:'.gn-chip-in'};commit(); + gnPost({type:'propsRemoved',key:p.key||'',item:String(it)});}); val.appendChild(c);}); if(!ro){var inp=document.createElement('input');inp.type='text';inp.className='gn-chip-in';inp.placeholder=p.items&&p.items.length?'':(p.kind==='tags'?'Add tag…':'Add item…'); inp.setAttribute('spellcheck','false'); @@ -1657,7 +1787,8 @@ function gnPropValue(val,p){ inp.addEventListener('keydown',function(e){ if(gnSugKey(e))return; if(e.key==='Enter'||e.key===','){e.preventDefault();addItem(typed(),true);} - else if(e.key==='Backspace'&&!inp.value&&p.items&&p.items.length){p.items.pop();gnPropsFocus={key:p.key,what:'.gn-chip-in'};commit();}}); + else if(e.key==='Backspace'&&!inp.value&&p.items&&p.items.length){gnPropsKeep();var gone=p.items.pop();gnPropsFocus={key:p.key,what:'.gn-chip-in'};commit(); + gnPost({type:'propsRemoved',key:p.key||'',item:String(gone)});}}); inp.addEventListener('blur',function(){addItem(typed(),false);}); val.appendChild(inp);} return; @@ -1764,6 +1895,7 @@ function gnRenderNow(markdown,props,editable,vocabulary,media,lines){ gnMedia=media||{}; var ex=gnExtractCallouts(markdown); gnLineMap=ex.lineMap; + gnSlugSeen={}; var html=md.render(ex.text); gnLineMap=null; for(var i=0;i<ex.callouts.length;i++){ @@ -1779,6 +1911,9 @@ function gnRenderNow(markdown,props,editable,vocabulary,media,lines){ var nav=gnNav; var art=document.getElementById('gn-article'); art.textContent='';art.appendChild(gnApplyMedia(gnSummaryMarkdown(gnSanitize(html)))); + // An empty note says so, so it never looks like a page that failed to draw. Drawn by CSS: the find and a copy + // of the page never see it. + art.classList.toggle('gn-is-empty',!String(markdown||'').trim()); gnRenderProps(props,editable); gnSugClose(); gnWireLinks();gnWireJSON();window.gnRunMermaid();gnLastSlug=''; @@ -1805,14 +1940,15 @@ function gnRenderNow(markdown,props,editable,vocabulary,media,lines){ // The 7th argument is Swift's tag for this render. The page keeps the last tag it got, also when the render // threw, so Swift can tell a render that never arrived from one that arrived and failed. window.gnRenderedTag=''; +// A render that throws keeps whatever the article shows (the last good text, when the throw came before it was +// replaced) and Swift puts "This page could not be drawn." with Retry over the page; the raw error sits behind +// its Details. The return value tells Swift the render drew ("ok"), which takes such a notice away. window.gnRender=function(){ window.gnRenderedTag=String(arguments[6]||''); - try{gnRenderNow.apply(null,arguments);} + try{gnRenderNow.apply(null,arguments);return 'ok';} catch(e){ - var art=document.getElementById('gn-article'); - if(art){art.textContent='';var box=document.createElement('div');box.className='gn-render-error'; - box.textContent='This document could not be rendered: '+String(e&&e.message||e);art.appendChild(box);} gnPost({type:'renderError',message:String(e&&e.message||e),stack:String(e&&e.stack||'').slice(0,2000)}); + return 'failed'; } }; window.addEventListener('error',function(e){gnPost({type:'pageError',message:String(e.message||''),line:e.lineno||0});}); @@ -1972,14 +2108,27 @@ document.addEventListener('mousedown',function(e){ var im=gnViewerImage(e.target);if(im)gnPost({type:'image.press',src:im.getAttribute('src')||''}); }); document.addEventListener('dblclick',function(e){ + var im=gnViewerImage(e.target); + if(!im){ + // A picture the viewer does not open (a web picture): the double-click still gets an answer. + var other=e.target&&e.target.closest?e.target.closest('#gn-article img'):null; + if(other){e.preventDefault();gnPost({type:'image.refused',src:other.getAttribute('src')||''});} + return; + } + e.preventDefault();gnOpenViewer(im); +}); +document.addEventListener('keydown',function(e){ + if(e.key!=='Enter'&&e.key!==' ')return; var im=gnViewerImage(e.target);if(!im)return; - e.preventDefault(); + e.preventDefault();gnOpenViewer(im); +}); +function gnOpenViewer(im){ var rows=Array.prototype.slice.call(document.querySelectorAll('#gn-article tr')); var all=document.querySelectorAll('#gn-article img[data-gn-img]');var images=[]; for(var i=0;i<all.length;i++){var tr=all[i].closest('tr'); images.push({n:+all[i].getAttribute('data-gn-img'),src:all[i].getAttribute('src')||'',alt:all[i].getAttribute('alt')||'',row:tr?'r'+rows.indexOf(tr):null});} gnPost({type:'image.open',n:+im.getAttribute('data-gn-img'),images:images,rect:gnViewerRect(im)}); -}); +} window.gnImageRect=function(n){var im=document.querySelector('#gn-article img[data-gn-img="'+(+n)+'"]');return JSON.stringify(im?gnViewerRect(im):null);}; // The page as a rendered diff (Swift's DiffMark list, new body lines plus the old text): added blocks tinted green, // changed ones blue with their old version above them struck through in red, removed text struck through where it @@ -2152,7 +2301,12 @@ window.gnHighlightLines=function(a,b,mode,scroll){ }; window.gnScrollTo=function(px){var sc=document.scrollingElement||document.documentElement;sc.scrollTop=Math.max(0,px||0);}; window.gnScrollTop=function(){var sc=document.scrollingElement||document.documentElement;return sc.scrollTop;}; -window.gnScrollToSlug=function(slug){var el=document.getElementById(slug);if(el){gnNav++;el.scrollIntoView({behavior:'smooth',block:'start'});}}; +// `label` is the heading as the link or the outline named it, for the answer when the note has no such heading. +window.gnScrollToSlug=function(slug,label){ + var el=slug?document.getElementById(slug):null; + if(el&&document.getElementById('gn-article').contains(el)){gnNav++;el.scrollIntoView({behavior:gnScrollBehavior(),block:'start'});return true;} + gnPost({type:'deadAnchor',slug:String(slug||''),label:String(label||slug||'')});return false; +}; // Find bar: wrap every occurrence in a <mark>, then walk them. The marks are // plain DOM, so a re-render drops them and Swift re-issues the search. // `opts` comes from Swift (MarkdownFindCommand.jsOptions): matchCase, @@ -2290,7 +2444,7 @@ window.gnFindStep=function(delta){ var cur=ms[gnFindIdx]; cur.classList.add('gn-find-current'); gnNav++; - cur.scrollIntoView({behavior:'smooth',block:'center'}); + cur.scrollIntoView({behavior:gnScrollBehavior(),block:'center'}); gnPlaceTicks(); gnPost({type:'find',index:gnFindIdx+1,count:ms.length}); }; @@ -2345,7 +2499,7 @@ window.gnHighlight=function(q){ var els=document.querySelectorAll('#gn-article p,#gn-article li,#gn-article h1,#gn-article h2,#gn-article h3,#gn-article td'); for(var i=0;i<els.length;i++){ if((els[i].textContent||'').toLowerCase().indexOf(q)!==-1){ - els[i].scrollIntoView({behavior:'smooth',block:'center'}); + els[i].scrollIntoView({behavior:gnScrollBehavior(),block:'center'}); (function(el){el.classList.remove('gn-flash');void el.offsetWidth;el.classList.add('gn-flash'); setTimeout(function(){el.classList.remove('gn-flash');},1300);})(els[i]); break; diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocument.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocument.swift index aec2573932..4788b67455 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocument.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocument.swift @@ -115,8 +115,11 @@ final class MarkdownDocument: ObservableObject, Identifiable { do { try draft.write(to: url, atomically: true, encoding: .utf8) } catch { - saveError = error.localizedDescription - return .failed(error.localizedDescription) + // One plain sentence for the reader; the system's own wording goes to the log. + GenesisKit.log("markdown: save of \(url.path) failed: \(error.localizedDescription)") + let message = "The note could not be saved. Check that the file can be written and the disk has room, then save again." + saveError = message + return .failed(message) } saveError = nil missing = false diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift index 4a85c1eb2c..78e0ecbbf0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentWindow.swift @@ -184,6 +184,33 @@ public final class MarkdownDocumentWindowController: NSWindowController, NSWindo @objc public func reopenClosedTab(_ sender: Any?) { model.reopenLastClosed() } @objc public func showKeyboardShortcuts(_ sender: Any?) { KeymapHelpWindow.show(MarkdownKeymap.shared) } + /// The menu bar's items for the window commands (`MarkdownViewerMenu.command`): the item names its command, and + /// the same closure the key runs does the work. + @objc public func performCommand(_ sender: Any?) { + guard let id = (sender as? NSMenuItem)?.representedObject as? String else { return } + menuActions[id]?() + } + + private lazy var menuActions: [String: () -> Void] = Dictionary( + MarkdownKeymap.windowActions(model: model, window: window) { [weak self] in self?.closeSelectedTabOrWindow() }, + uniquingKeysWith: { first, _ in first } + ) + + /// Whether a window command's menu item can run now. + func canPerform(_ id: String) -> Bool { + switch id { + case "markdown.file.open", "markdown.view.files", "markdown.view.outline", "markdown.find.files": + return true + case "markdown.file.pdf", "markdown.file.print": + return model.selected != nil && model.canExport + case "markdown.find.open": + return model.selected != nil && model.supportsFind + default: + if id.hasPrefix("markdown.tab.select") || id == "markdown.tab.closeOthers" { return model.docs.count > 1 } + return model.selected != nil + } + } + /// Green-button zoom / title-bar double-click fill the visible screen, /// then restore. Without this, AppKit fits the WKWebView and the window /// barely moves. @@ -265,6 +292,9 @@ extension MarkdownDocumentWindowController: NSMenuItemValidation { case #selector(goForward(_:)): return model.canGoForward case #selector(nextTab(_:)), #selector(previousTab(_:)): return model.docs.count > 1 case #selector(reopenClosedTab(_:)): return model.canReopenClosedTab + case #selector(performCommand(_:)): + if item.representedObject as? String == "markdown.tab.closeOthers" { item.title = model.closeOthersTitle } + return (item.representedObject as? String).map(canPerform) ?? false default: return true } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+Git.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+Git.swift index c431e3f0c8..daa902436b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+Git.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+Git.swift @@ -64,7 +64,10 @@ extension MarkdownDocumentsModel { self.revisionCompare = nil switch result { case .success(let text): self.revisionView = MarkdownRevision(docID: id, commit: commit, text: text) - case .failure(let error): self.exportError = "Could not read revision: \(error)" + case .failure(let error): + // One sentence; git's own words behind Details. + self.post(MarkdownNotice("This version of the note could not be read.", tone: .error, + more: String(describing: error))) } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+TabGroups.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+TabGroups.swift new file mode 100644 index 0000000000..97b67f71ea --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel+TabGroups.swift @@ -0,0 +1,194 @@ +// +// MarkdownDocumentsModel+TabGroups.swift +// GenesisKit +// +// The tab groups of one window: computed by `MarkdownTabGrouping` from the open tabs, the rule and the reader's own +// edits (`GenesisKitMarkdownConfiguration.tabGroupState`), and the in-place actions the tab bar's menus and drags call. +// + +import Foundation + +/// The inputs of the last grouping and its result, so a tab bar pass that changed none of them reuses it. +final class MarkdownTabGroupMemo { + struct Inputs: Equatable { + var ids: [String] + var rule: MarkdownTabGroupRule + var state: MarkdownTabGroupState + var minimum: Int + var splitAbove: Int + var day: Int + } + + var inputs: Inputs? + var groups: [MarkdownTabGroup] = [] +} + +extension MarkdownDocumentsModel { + /// Whether the tab bar shows groups now: a rule is chosen and enough tabs are open. + var groupsTabs: Bool { + configuration.tabGrouping != .off && docs.count >= max(1, configuration.tabGroupFrom) + } + + /// The open tabs as groups, or none while `groupsTabs` is false. + var tabGroups: [MarkdownTabGroup] { + guard groupsTabs else { return [] } + let config = configuration + let inputs = MarkdownTabGroupMemo.Inputs( + ids: docs.map(\.id), rule: config.tabGrouping, state: config.tabGroupState, + minimum: config.tabGroupMinimum, splitAbove: config.tabGroupSplitAbove, + day: Calendar.current.ordinality(of: .day, in: .era, for: Date()) ?? 0) + if tabGroupMemo.inputs == inputs { return tabGroupMemo.groups } + let groups = MarkdownTabGrouping.groups( + docs.map { MarkdownTabGroupItem(id: $0.id, path: $0.url.path, title: $0.title) }, + rule: inputs.rule, state: inputs.state, minimumSize: inputs.minimum, splitAbove: inputs.splitAbove) + tabGroupMemo.inputs = inputs + tabGroupMemo.groups = groups + return groups + } + + /// The tab order the bar shows, for Next Tab and Previous Tab. + var visibleTabOrder: [String] { + let groups = tabGroups + guard !groups.isEmpty else { return docs.map(\.id) } + return MarkdownTabGrouping.visualOrder(groups, collapsed: configuration.tabGroupState.collapsed, + selected: selected?.id) + } + + func group(of tabID: String) -> MarkdownTabGroup? { + tabGroups.first { $0.tabIDs.contains(tabID) } + } + + // MARK: Collapse + + func toggleGroup(_ id: String) { + editGroups { state in + if state.collapsed.contains(id) { state.collapsed.remove(id) } else { state.collapsed.insert(id) } + } + } + + /// ⌥-click on a group: this one open, every other one folded. + func focusGroup(_ id: String) { + let others = Set(tabGroups.map(\.id)).subtracting([id]) + editGroups { state in state.collapsed = others } + } + + func setAllGroups(collapsed: Bool) { + let all = Set(tabGroups.map(\.id)) + editGroups { state in state.collapsed = collapsed ? all : [] } + } + + // MARK: Names and order + + func renameGroup(_ id: String, to title: String) { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + editGroups { state in state.names[id] = trimmed.isEmpty ? nil : trimmed } + } + + /// Moves a group before another one, or to the end with nil. The order is kept for groups that come back. + func moveGroup(_ id: String, before target: String?) { + guard id != target else { return } + var order = tabGroups.map(\.id) + order.removeAll { $0 == id } + let slot = target.flatMap { order.firstIndex(of: $0) } ?? order.endIndex + order.insert(id, at: slot) + let current = configuration.tabGroupState.order + editGroups { state in state.order = order + current.filter { !order.contains($0) } } + } + + /// One step left or right, from a group's menu. + func shiftGroup(_ id: String, by offset: Int) { + let ids = tabGroups.map(\.id) + guard let index = ids.firstIndex(of: id) else { return } + let target = index + offset + guard ids.indices.contains(target) else { return } + moveGroup(id, before: offset < 0 ? ids[target] : (target + 1 < ids.count ? ids[target + 1] : nil)) + } + + // MARK: Moving tabs between groups + + /// Puts a tab in a group by hand; nil gives it back to the rule. + func assign(_ tabID: String, to groupID: String?) { + editGroups { state in state.assigned[tabID] = groupID } + } + + /// A new group holding this tab, named after it until the reader renames it. + @discardableResult + func newGroup(with tabID: String, title: String? = nil) -> String { + let id = MarkdownTabGrouping.manualPrefix + UUID().uuidString.prefix(8).lowercased() + let name = title ?? "New Group" + editGroups { state in + state.assigned[tabID] = id + state.names[id] = name + state.collapsed.remove(id) + } + return id + } + + /// A tab dropped on another tab: before or after it (the half of the tab under the pointer), into that tab's + /// group. + func dropTab(_ dragged: String, beside target: String, after: Bool) { + guard dragged != target else { return } + if groupsTabs, let destination = group(of: target), group(of: dragged)?.id != destination.id { + assign(dragged, to: destination.id) + } + guard after else { + moveTab(dragged, before: target) + return + } + // After the target is before whatever follows it; the target's group keeps the dragged tab beside it. + let next = docs.firstIndex { $0.id == target }.flatMap { index in + docs[(index + 1)...].first { $0.id != dragged }?.id + } + moveTab(dragged, before: next) + } + + /// A group dropped on a chip or on one of another group's tabs: before or after that group. + func moveGroup(_ id: String, beside target: String, after: Bool) { + guard id != target else { return } + guard after else { + moveGroup(id, before: target) + return + } + let ids = tabGroups.map(\.id).filter { $0 != id } + let next = ids.firstIndex(of: target).flatMap { ids.indices.contains($0 + 1) ? ids[$0 + 1] : nil } + moveGroup(id, before: next) + } + + /// A tab dropped on a group's chip: it joins the group at its end and the group opens. + func dropTab(_ dragged: String, onGroup groupID: String) { + assign(dragged, to: groupID) + editGroups { state in state.collapsed.remove(groupID) } + let after = tabGroups.first { $0.id == groupID }?.tabIDs.last { $0 != dragged } + if let after, let index = docs.firstIndex(where: { $0.id == after }) { + moveTab(dragged, before: docs.indices.contains(index + 1) ? docs[index + 1].id : nil) + } + } + + /// Dissolves a group the reader made, or takes back every hand move into an automatic one. + func ungroup(_ groupID: String) { + editGroups { state in + state.assigned = state.assigned.filter { $0.value != groupID } + state.names[groupID] = nil + state.collapsed.remove(groupID) + state.order.removeAll { $0 == groupID } + } + } + + /// Closes every tab of a group as one action (`closeTabs`: one Undo); a dirty one asks first, as a single + /// close does. + func closeGroup(_ groupID: String, confirm: @MainActor (MarkdownDocument) -> UnsavedChoice = MarkdownDocumentsModel.askUnsaved) { + guard let group = tabGroups.first(where: { $0.id == groupID }) else { return } + let tabs = group.tabIDs.compactMap { id in docs.first { $0.id == id } } + closeTabs(tabs, confirm: confirm) + } + + /// One write per action: each assignment to the state publishes and re-runs the bar. + private func editGroups(_ change: (inout MarkdownTabGroupState) -> Void) { + var state = configuration.tabGroupState + change(&state) + // Hand moves of tabs that are no longer open are dropped, so the saved state cannot grow without end. + let open = Set(docs.map(\.id)) + state.assigned = state.assigned.filter { open.contains($0.key) } + if state != configuration.tabGroupState { configuration.tabGroupState = state } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift index 3fa0779fcb..1004a509ca 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsModel.swift @@ -51,6 +51,8 @@ final class MarkdownDocumentsModel: ObservableObject { /// The viewer's tunables (preview and wrapped tabs, hover previews, contrast, toolbar labels). The /// process-wide one; a test hands in its own. The window forwards its changes to this model. var configuration = GenesisKitMarkdownConfiguration.shared + /// The last grouping of the tabs and what it was computed from (`MarkdownDocumentsModel+TabGroups`). + let tabGroupMemo = MarkdownTabGroupMemo() /// Reading view, source, or both (⌘E / ⌘\), per window, not per file. /// Remembered between launches like the rest of the chrome. @Published var modeRaw: String = MarkdownViewMode.preview.rawValue { @@ -110,11 +112,73 @@ final class MarkdownDocumentsModel: ObservableObject { func acceptDrop(_ urls: [URL]) { let (tabs, external) = MarkdownDrop.split(urls) if !tabs.isEmpty { open(tabs) } - for url in external { NSWorkspace.shared.open(url) } - if let folder = MarkdownDrop.firstFolder(urls) { + let folder = MarkdownDrop.firstFolder(urls) + if let folder { filesRoot = folder.standardizedFileURL.path showFiles = true } + // A document goes to its app; anything that could run (an app, a script, an executable) waits for Open + // Anyway, since a stray drop must not start a program (UX audit W14). + var sent: [(app: String, url: URL)] = [] + var held: [URL] = [] + for url in external { + if let app = defaultApp(url), MarkdownDocumentLinks.mayOpenWithDefaultApp(url, app: app) { + openExternally(url) + sent.append((app.name, url)) + } else { + held.append(url) + } + } + let folders = urls.filter { url in + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + }.count + let gone = urls.count - tabs.count - external.count - folders + // One note for the whole drop: what opened, what went where, what is gone, what waits (UX audit D7). Tabs + // alone need none: the tab is on screen. + guard let text = Self.dropSummary(opened: tabs, sent: sent, folder: folder, gone: gone, held: held) else { return } + guard !held.isEmpty else { + post(MarkdownNotice(text, tone: gone > 0 ? .warning : .info)) + return + } + let label = held.count == 1 ? "Open Anyway" : "Open \(held.count) Anyway" + post(MarkdownNotice(text, tone: .warning, actions: [ + .button(label, symbol: "arrow.up.forward.app") { [weak self] in + self?.dismissNotice() + held.forEach { self?.openExternally($0) } + }, + ])) + } + + /// The sentence after a drop, or nil when it only opened tabs. "Opened 2 notes, sent 1 file to Preview." + static func dropSummary(opened: [URL], sent: [(app: String, url: URL)], folder: URL?, gone: Int, held: [URL]) -> String? { + guard !sent.isEmpty || folder != nil || gone > 0 || !held.isEmpty else { return nil } + func count(_ n: Int, _ word: String) -> String { "\(n) \(word)\(n == 1 ? "" : "s")" } + var parts: [String] = [] + if !opened.isEmpty { + let notes = opened.allSatisfy { $0.pathExtension.lowercased() == "md" } + parts.append("opened " + count(opened.count, notes ? "note" : "file")) + } + var apps: [String] = [] + for item in sent where !apps.contains(item.app) { apps.append(item.app) } + for app in apps { + let files = sent.filter { $0.app == app } + let what = files.count == 1 ? "\u{201C}\(files[0].url.lastPathComponent)\u{201D}" : count(files.count, "file") + parts.append("sent \(what) to \(app)") + } + if let folder { parts.append("showed \(folder.lastPathComponent) in the sidebar") } + if gone > 0 { parts.append(count(gone, "file") + (gone == 1 ? " was" : " were") + " not found") } + var sentences: [String] = [] + if !parts.isEmpty { + let joined = parts.count > 1 ? parts.dropLast().joined(separator: ", ") + " and " + parts.last! : parts[0] + sentences.append(joined.prefix(1).uppercased() + joined.dropFirst() + ".") + } + if held.count == 1 { + sentences.append("\u{201C}\(held[0].lastPathComponent)\u{201D} is not a file this viewer shows, and opening it can start a program.") + } else if held.count > 1 { + sentences.append("\(held.count) files are not files this viewer shows, and opening them can start a program.") + } + return sentences.joined(separator: " ") } /// "Go to Location…" (⇧⌘G): the sheet, and the folder (or a link's file) the file tree last revealed. @@ -416,6 +480,13 @@ final class MarkdownDocumentsModel: ObservableObject { } /// Closed tabs, newest last, so ⌘⇧T walks back through them. @Published private(set) var recentlyClosed: [URL] = [] + /// How many of the newest `recentlyClosed` entries one batch close (a group, every other tab) left: ⌘⇧T and the + /// note's Undo reopen them together. 0 after a single close. + private(set) var closedBatchCount = 0 + /// While `closeTabs` runs: the files it closed so far. + private var closingBatch: [URL]? + /// How many closed tabs ⌘⇧T keeps; a batch larger than this is kept whole. + static let reopenLimit = 100 /// File → Open Recent (MarkdownRecents.swift), kept in this window's defaults. private(set) var recents = MarkdownRecents() { didSet { @@ -465,8 +536,57 @@ final class MarkdownDocumentsModel: ObservableObject { @Published var commitSheet: MarkdownCommitSheet? /// The live reading web view, for PDF export and print. let webProxy = MarkdownWebProxy() - /// Last export failure, shown in the toolbar until the next action. - @Published var exportError: String? + /// The window's one message (`MarkdownNoticeSlot`): a save failure, a replace count, a closed batch of tabs. + @Published private(set) var notice: MarkdownNotice? + private var noticeClear: Task<Void, Never>? + + /// Shows `notice` in place of the one before, announces it to VoiceOver, and clears it after its lifetime + /// (an error stays until it is closed). + func post(_ notice: MarkdownNotice) { + self.notice = notice + GenAnnounce.post(notice.text, priority: notice.tone == .error ? .high : .medium) + noticeClear?.cancel() + guard let lifetime = notice.lifetime else { return } + let id = notice.id + noticeClear = Task { [weak self] in + try? await Task.sleep(for: .seconds(lifetime)) + guard !Task.isCancelled else { return } + self?.dismissNotice(id) + } + } + + /// Save as PDF… (⇧⌘S, File menu, toolbar): the reading page of the selected tab, a failure in the slot. + func exportPDF() { + guard let doc = selected else { return } + exportError = nil + MarkdownExport.savePDF(from: webProxy, note: doc.url, in: NSApp.keyWindow) { [weak self] in self?.exportError = $0 } + } + + /// Print… (⌘P, File menu, toolbar). + func printDocument() { + guard let doc = selected else { return } + exportError = nil + webProxy.printDocument(title: doc.title, in: NSApp.keyWindow) { [weak self] in self?.exportError = $0 } + } + + /// Closes the shown note; with an id, only while that note is still the one shown. + func dismissNotice(_ id: UUID? = nil) { + guard let notice, id == nil || notice.id == id else { return } + self.notice = nil + } + + /// The shown error's sentence. Setting a text posts it as an error; setting nil closes an error (a success + /// stays for its own lifetime). + var exportError: String? { + get { notice?.tone == .error ? notice?.text : nil } + set { + if let newValue { + post(MarkdownNotice(newValue, tone: .error)) + } else if notice?.tone == .error { + dismissNotice() + } + } + } /// Where ⌘F looks. With the editor on screen it searches the source, which /// is also the only place a replace makes sense; otherwise it marks matches @@ -602,12 +722,24 @@ final class MarkdownDocumentsModel: ObservableObject { in: doc.displayText, query: findQuery, options: findOptions, within: activeSourceScope, with: replaceText ) guard result.count > 0 else { return } + let original = doc.displayText doc.edit(result.text) growFindScope(by: (result.text as NSString).length - before) findCount = 0 findIndex = 0 sourceSelection = nil - exportError = "Replaced \(result.count) \(result.count == 1 ? "match" : "matches")" + // Undo on the note as well as ⌘Z (UX audit E12): it puts the text back while nothing was typed since. + post(MarkdownNotice("Replaced \(result.count) \(result.count == 1 ? "match" : "matches")", tone: .success, actions: [ + .button("Undo", symbol: "arrow.uturn.backward") { [weak self, weak doc] in + guard let self, let doc else { return } + self.dismissNotice() + guard doc.displayText == result.text else { + self.post(MarkdownNotice("The text changed after the replace, so use Edit, Undo instead.", tone: .warning)) + return + } + doc.edit(original) + }, + ])) } func show(_ range: NSRange) { @@ -717,11 +849,51 @@ final class MarkdownDocumentsModel: ObservableObject { persistTabs() } - /// Next Tab / Previous Tab (`MarkdownKeymap`, ⇧⌘ with the two keys right of P): wrapping at the ends. + /// Next Tab / Previous Tab (`MarkdownKeymap`, ⇧⌘ with the two keys right of P): wrapping at the ends, in the + /// order the tab bar shows them (groups in their order, a collapsed group's tabs skipped). func selectTab(offset: Int) { - guard docs.count > 1, let current = docs.firstIndex(where: { $0.id == selected?.id }) else { return } - let next = ((current + offset) % docs.count + docs.count) % docs.count - select(docs[next].id) + let order = visibleTabOrder + guard order.count > 1, let current = order.firstIndex(where: { $0 == selected?.id }) else { return } + let next = ((current + offset) % order.count + order.count) % order.count + select(order[next]) + } + + /// ⌘1 to ⌘8: the tab at that place in the bar's order; ⌘9 the last tab. + func selectTab(number: Int) { + let order = visibleTabOrder + guard !order.isEmpty, number >= 1 else { return } + let index = number >= 9 ? order.count - 1 : number - 1 + guard order.indices.contains(index) else { return } + select(order[index]) + } + + /// Move Tab Left or Right (the tab's menu and its VoiceOver actions): one place in the bar's order, across a + /// group's edge into the next group, as a drag there would. The keyboard twin of a tab drag (UX audit W6). + func shiftTab(_ id: String, by offset: Int) { + let order = visibleTabOrder + guard let index = order.firstIndex(of: id) else { return } + let target = index + offset + guard order.indices.contains(target) else { return } + dropTab(id, beside: order[target], after: offset > 0) + } + + func canShiftTab(_ id: String, by offset: Int) -> Bool { + let order = visibleTabOrder + guard let index = order.firstIndex(of: id) else { return false } + return order.indices.contains(index + offset) + } + + /// Moves a tab before another one, or to the end with nil (a drag in the tab bar). The order is saved like any + /// other tab change. + func moveTab(_ id: String, before target: String?) { + guard id != target, let from = docs.firstIndex(where: { $0.id == id }) else { return } + var reordered = docs + let doc = reordered.remove(at: from) + let slot = target.flatMap { target in reordered.firstIndex { $0.id == target } } ?? reordered.endIndex + reordered.insert(doc, at: slot) + guard reordered.map(\.id) != docs.map(\.id) else { return } + docs = reordered + persistTabs() } /// ⌃⇧C, the toolbar, and a file row's Copy Path: the open note's path. @@ -734,10 +906,35 @@ final class MarkdownDocumentsModel: ObservableObject { /// which is already on screen whenever `diskChange` is set. func saveSelected() { guard let doc = selected else { return } - switch doc.save() { - case .saved, .nothingToSave, .blockedByDiskChange: break - case .failed(let message): exportError = "Save failed: \(message)" + _ = save(doc) + } + + /// Saves `doc` and says so when it did not work: a failure as an error note with Try Again, a save refused + /// because the file changed on disk with the tab selected, where its conflict banner is. Every save the reader + /// starts goes through here: ⌘S, and Save in the unsaved-changes prompt of a tab, the window or ⌘Q, which used to + /// leave the tab open with no word (UX audit W1). + @discardableResult + func save(_ doc: MarkdownDocument) -> MarkdownDocument.SaveOutcome { + let outcome = doc.save() + switch outcome { + case .saved, .nothingToSave: + break + case .blockedByDiskChange: + if selectedId != doc.id { select(doc.id) } + post(MarkdownNotice( + "\u{201C}\(doc.title)\u{201D} was not saved: the file changed on disk. Pick a version in the banner, then save again.", + tone: .error)) + case .failed(let message): + if selectedId != doc.id { select(doc.id) } + post(MarkdownNotice(message, tone: .error, actions: [ + .button("Try Again", symbol: "arrow.clockwise") { [weak self, weak doc] in + guard let self, let doc else { return } + self.dismissNotice() + self.save(doc) + }, + ])) } + return outcome } /// Closing a dirty tab asks first. Returns false when the reader cancelled. `remember: false` keeps @@ -751,7 +948,7 @@ final class MarkdownDocumentsModel: ObservableObject { if doc.isDirty { switch confirm(doc) { case .save: - if doc.save() != .saved { return false } + if save(doc) != .saved { return false } case .discard: break case .cancel: return false } @@ -762,6 +959,38 @@ final class MarkdownDocumentsModel: ObservableObject { enum UnsavedChoice { case save, discard, cancel } + /// Closes several tabs as one action (Close Tabs in Group, Close Other Tabs). Each dirty tab asks first, and a + /// cancel stops there. Two or more closed tabs leave a note with Undo, and ⌘⇧T reopens them all at once: before, + /// ⌘⇧T brought back one tab per press and the stack forgot all but 20 (UX audit W3). Returns false on a cancel. + @discardableResult + func closeTabs( + _ tabs: [MarkdownDocument], + confirm: @MainActor (MarkdownDocument) -> UnsavedChoice = MarkdownDocumentsModel.askUnsaved + ) -> Bool { + closingBatch = [] + var finished = true + for doc in tabs where docs.contains(where: { $0.id == doc.id }) { + guard close(doc, confirm: confirm) else { + finished = false + break + } + } + let closed = closingBatch ?? [] + closingBatch = nil + closedBatchCount = closed.count + if closed.count > 1 { + let count = closed.count + post(MarkdownNotice("Closed \(count) tabs.", tone: .info, actions: [ + .button("Reopen \(count) Tabs", symbol: "arrow.uturn.backward") { [weak self] in + guard let self else { return } + self.dismissNotice() + self.reopenLastClosed() + }, + ])) + } + return finished + } + /// Closing the whole window (or quitting): every unsaved tab is saved or /// discarded, but no tab is removed, so the session reopens with the same /// tabs and the same selection. Returns false when the reader cancelled, @@ -777,7 +1006,7 @@ final class MarkdownDocumentsModel: ObservableObject { select(doc.id) switch confirm(doc) { case .save: - if doc.save() != .saved { return false } + if save(doc) != .saved { return false } case .discard: doc.discardDraft() case .cancel: @@ -816,8 +1045,11 @@ final class MarkdownDocumentsModel: ObservableObject { recentlyClosed.append(doc.url) recents.closed(doc.url) } - if recentlyClosed.count > 20 { - recentlyClosed.removeFirst() + if remember { + if closingBatch != nil { closingBatch?.append(doc.url) } else { closedBatchCount = 0 } + } + if recentlyClosed.count > max(Self.reopenLimit, closingBatch?.count ?? 0) { + recentlyClosed.removeFirst(recentlyClosed.count - max(Self.reopenLimit, closingBatch?.count ?? 0)) } let position = docs.firstIndex { $0.id == doc.id } docs.removeAll { $0.id == doc.id } @@ -833,6 +1065,17 @@ final class MarkdownDocumentsModel: ObservableObject { /// ⌘⇧T. A file that is open again already (reopened by hand, or by a new /// `genesis open`) is dropped from the stack rather than re-selected twice. func reopenLastClosed() { + if closedBatchCount > 1 { + let batch = Array(recentlyClosed.suffix(closedBatchCount)) + recentlyClosed.removeLast(batch.count) + closedBatchCount = 0 + let closed = batch.filter { url in !docs.contains { $0.url == url } } + if !closed.isEmpty { + open(closed) + return + } + } + closedBatchCount = 0 while let url = recentlyClosed.popLast() { if docs.contains(where: { $0.url == url }) { continue @@ -899,6 +1142,9 @@ final class MarkdownDocumentsModel: ObservableObject { openExternally(url) return } else if MarkdownDocumentLinks.hasScheme(target) { + // A click that does nothing looks broken: say which kind of link was refused (UX audit W8). + let scheme = target.prefix { $0 != ":" } + post(MarkdownNotice("Links that start with \u{201C}\(scheme):\u{201D} are not opened from a note.", tone: .warning)) return } // `Note#Heading` (or a bare `#Heading`, this note) jumps to the heading @@ -912,6 +1158,7 @@ final class MarkdownDocumentsModel: ObservableObject { target, folder: doc.folder, vaultRoot: GenesisKit.host?.markdownVaultRoot ) else { GenesisKit.log("markdown: link \(target) resolves to nothing") + post(MarkdownNotice("Nothing found for \u{201C}\(target)\u{201D}.", tone: .warning)) return } let route = MarkdownLinkRoute.decide(url, fragment: fragment) @@ -919,7 +1166,10 @@ final class MarkdownDocumentsModel: ObservableObject { switch route { case let .note(note, heading, lines)?: open([note]) - if let lines { reveal(lines) } else if let heading { scrollTarget = heading } + // The same heading twice in a row still jumps: the repeat mark makes the target a new value. + if let lines { reveal(lines) } else if let heading { + scrollTarget = scrollTarget == heading ? heading + MarkdownOutline.repeatMark : heading + } case let .viewer(file, lines)?: open([file]) if let lines { reveal(lines) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift index fa0ba46813..3d25e22384 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownDocumentsView.swift @@ -27,6 +27,8 @@ struct MarkdownDocumentsView: View { } @State private var filesDragStart: Double? + /// A Finder drag is over the window: the content gets an accent outline (UX audit W10). + @State private var dropTargeted = false /// The sidebar's right edge: drag to resize, double-click for the standard width, arrows when focused. private var filesDivider: some View { @@ -93,7 +95,10 @@ struct MarkdownDocumentsView: View { WindowTitlebar.performDoubleClick(on: window) } } - Rectangle().fill(model.theme.border).frame(height: 1) + // The wrapped bar draws its own bottom line: the divider that sets its height. + if !model.configuration.wrapTabs { + Rectangle().fill(model.theme.border).frame(height: 1) + } } if let doc = model.selected { HStack(spacing: 0) { @@ -121,10 +126,12 @@ struct MarkdownDocumentsView: View { } if model.showGit && model.configuration.git { gitDivider - MarkdownGitSidebar(doc: doc, model: model) + MarkdownGitSidebar(doc: doc, model: model, theme: model.theme) + .equatable() .frame(width: model.gitWidth) } } + .overlay(alignment: .topTrailing) { MarkdownNoticeSlot(model: model) } MarkdownStatusBar(doc: doc, model: model) } else { // A bare grey line gave no way forward. Say what the window @@ -136,7 +143,7 @@ struct MarkdownDocumentsView: View { Text("No document open") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(model.theme.text) - Text("Drop a Markdown file here, open one, or run genesis open <file> in a terminal.") + Text("Drop a Markdown file here, or open one.") .font(.system(size: 12)) .foregroundStyle(model.theme.textSecondary) .multilineTextAlignment(.center) @@ -145,10 +152,12 @@ struct MarkdownDocumentsView: View { .buttonStyle(.genHover(accent: model.theme.accent)) .font(.system(size: 13, weight: .medium)) .foregroundStyle(model.theme.accent) - .instantTooltip("Choose Markdown files to open as tabs") + .instantTooltip(MarkdownKeymap.hint("Choose Markdown files to open as tabs", "markdown.file.open")) .accessibilityIdentifier("markdown-viewer-open-files") + MarkdownEmptyRecents(model: model) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topTrailing) { MarkdownNoticeSlot(model: model) } } } // Paints the whole window but takes no clicks in the toolbar row: its empty part belongs to @@ -171,6 +180,18 @@ struct MarkdownDocumentsView: View { .dropDestination(for: URL.self) { urls, _ in model.acceptDrop(urls) return true + } isTargeted: { targeted in + if dropTargeted != targeted { dropTargeted = targeted } + } + .overlay { + if dropTargeted { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(model.theme.accent.opacity(0.8), lineWidth: 2) + .padding(.top, MarkdownDocumentToolbar.height) + .padding(3) + .allowsHitTesting(false) + .accessibilityHidden(true) + } } // The window's keys run through `MarkdownKeymap.registerWindowCommands`, in Genesis.app's process as well // as the viewer's own. @@ -181,32 +202,109 @@ struct MarkdownDocumentsView: View { } } +/// The empty window's next step besides Open…: the last few files that are still on disk, one button each +/// (UX audit W23). +private struct MarkdownEmptyRecents: View { + @ObservedObject var model: MarkdownDocumentsModel + + private var files: [URL] { + model.recents.entries.lazy + .filter { FileManager.default.fileExists(atPath: $0.path) } + .prefix(5) + .map { URL(fileURLWithPath: $0.path) } + } + + var body: some View { + let files = files + if !files.isEmpty { + VStack(spacing: 4) { + Text("Recent").font(.system(size: 11, weight: .semibold)).foregroundStyle(model.theme.textMuted) + .padding(.top, 8) + ForEach(files, id: \.self) { file in + Button { model.open([file]) } label: { + HStack(spacing: 6) { + Text(MarkdownDocument.title(for: file)).foregroundStyle(model.theme.text) + Text(file.deletingLastPathComponent().lastPathComponent) + .foregroundStyle(model.theme.textSecondary) + } + .font(.system(size: 12)) + .lineLimit(1) + .padding(.horizontal, 8).padding(.vertical, 3) + .contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + .instantTooltip(file.path) + .accessibilityLabel("Open \(MarkdownDocument.title(for: file)), in \(file.deletingLastPathComponent().lastPathComponent)") + } + } + .frame(maxWidth: 340) + } + } +} + +/// How tall the wrapped tab bar may be. +enum MarkdownTabBarHeight { + /// One row of tabs with the bar's padding. + static let minimum: Double = 40 + static let maximum: Double = 640 + /// One arrow-key or VoiceOver step: half a row. + static let keyboardStep: Double = 16 + + static func clamped(_ height: Double) -> Double { min(max(height, minimum), maximum) } + + /// The tallest the bar shows: the dragged height, else the row count (0 rows is no limit). + @MainActor static func limit(_ configuration: GenesisKitMarkdownConfiguration) -> CGFloat { + if configuration.tabBarHeight > 0 { return CGFloat(clamped(configuration.tabBarHeight)) } + let rows = configuration.wrapTabsMaxRows + return rows > 0 ? CGFloat(rows) * 32 + 8 : 10_000 + } +} + /// The tab row: one scrolling strip, or every tab on as many rows as it takes (`wrapTabs`). Either way the -/// selected tab is on screen. A right-click on its empty part offers Wrap Tabs. +/// selected tab is on screen. With enough tabs open they fall into groups (`MarkdownTabGrouping`): a chip per group +/// that folds it, drags that move tabs and groups, and menus that rename, regroup and close. A right-click on its +/// empty part offers Wrap Tabs and the grouping rule. private struct MarkdownTabBar: View { @ObservedObject var model: MarkdownDocumentsModel @ObservedObject var configuration: GenesisKitMarkdownConfiguration + /// The wrapped content's own height, and the bar's shown height when a drag of its bottom edge began. + @State private var contentHeight: CGFloat = 0 + @State private var dragStart: Double? var body: some View { let _ = RenderProbe.hit("md.tabBar") // Read once per pass: `selected` searches the tabs, and asking it per tab made a pass N² (2026-10-15). let selectedID = model.selected?.id + let groups = model.tabGroups Group { - if configuration.wrapTabs { - MarkdownTabFlow(spacing: 2, rowSpacing: 2) { - ForEach(model.docs) { doc in - tab(doc, selectedID: selectedID) + if groups.isEmpty { + if configuration.wrapTabs { + capped(selectedID: selectedID) { + MarkdownTabFlow(spacing: 2, rowSpacing: 2) { + ForEach(model.docs) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + } else { + strip(selectedID: selectedID) + } + } else if configuration.wrapTabs { + capped(selectedID: selectedID) { + if configuration.tabGroupRows { + wrapped(groups, selectedID: selectedID) + } else { + flowing(groups, selectedID: selectedID) } } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) } else { - strip(selectedID: selectedID) + groupedStrip(groups, selectedID: selectedID) } } .contentShape(Rectangle()) .contextMenu { + MarkdownTabGroupingMenu(configuration: configuration, notices: model) + Divider() MarkdownWrapTabsToggle(configuration: configuration) } .accessibilityIdentifier("markdown-tab-bar") @@ -245,6 +343,356 @@ private struct MarkdownTabBar: View { } } } + + // MARK: Groups + + private var collapsed: Set<String> { configuration.tabGroupState.collapsed } + + /// The tabs a group shows: all of them, or only the selected one while it is folded, so the tab you read never + /// disappears into a folded group. + private func shown(_ group: MarkdownTabGroup, selectedID: String?) -> [MarkdownDocument] { + let ids = collapsed.contains(group.id) ? group.tabIDs.filter { $0 == selectedID } : group.tabIDs + let byID = Dictionary(model.docs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return ids.compactMap { byID[$0] } + } + + private func chip(_ group: MarkdownTabGroup) -> some View { + MarkdownTabGroupChip(group: group, collapsed: collapsed.contains(group.id), model: model) + } + + /// The wrapped bar: at most `wrapTabsMaxRows` rows (about 32 pt each) or the height dragged on its bottom edge, + /// scrolling past that with a fade at the cut edge, and following the selected tab. Shorter content keeps its own + /// height (`FittedScroll`). + private func capped<Content: View>(selectedID: String?, @ViewBuilder content: @escaping () -> Content) -> some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + FittedScroll(maxHeight: MarkdownTabBarHeight.limit(configuration), measured: { contentHeight = $0 }) { + content() + .padding(.horizontal, 8) + .padding(.top, 6) + .padding(.bottom, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: selectedID, initial: true) { _, id in + guard let id else { return } + proxy.scrollTo(id, anchor: nil) + } + } + heightDivider + } + } + + /// The bar's bottom line: the sidebars' divider laid flat. A drag sets the bar's tallest height, a double-click + /// goes back to the row count, the arrows step half a row. + private var heightDivider: some View { + let shown = min(MarkdownTabBarHeight.limit(configuration), contentHeight) + return MarkdownSplitDivider( + orientation: .horizontal, + line: NSColor(model.theme.border), + accent: NSColor(model.theme.accent), + onBegin: { dragStart = shown }, + onDrag: { dy in configuration.tabBarHeight = MarkdownTabBarHeight.clamped((dragStart ?? shown) + dy) }, + onEnd: { dragStart = nil }, + onReset: { configuration.tabBarHeight = 0 }, + onStep: { direction in + configuration.tabBarHeight = MarkdownTabBarHeight.clamped(shown + Double(direction) * MarkdownTabBarHeight.keyboardStep) + }, + label: "Tab bar height", + valueText: "\(Int(shown)) points tall" + ) + .frame(height: 5) + .frameProbe("md.tabBarDivider") + .accessibilityIdentifier("markdown-tab-bar-divider") + } + + /// Wrapped, the default: every group's chip and its tabs flow on, one group after another. + private func flowing(_ groups: [MarkdownTabGroup], selectedID: String?) -> some View { + MarkdownTabFlow(spacing: 2, rowSpacing: 2) { + ForEach(groups) { group in + chip(group).padding(.leading, group.id == groups.first?.id ? 0 : 6) + ForEach(shown(group, selectedID: selectedID)) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + } + + /// Wrapped with "Each Group on Its Own Rows": an open group starts its own rows with its chip; folded groups next + /// to each other share a row. + private func wrapped(_ groups: [MarkdownTabGroup], selectedID: String?) -> some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(MarkdownTabGroupRows.rows(groups, collapsed: collapsed)) { row in + MarkdownTabFlow(spacing: 2, rowSpacing: 2) { + ForEach(row.groups) { group in + chip(group) + ForEach(shown(group, selectedID: selectedID)) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + } + } + } + + private func groupedStrip(_ groups: [MarkdownTabGroup], selectedID: String?) -> some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(groups) { group in + chip(group).padding(.leading, group.id == groups.first?.id ? 0 : 8) + ForEach(shown(group, selectedID: selectedID)) { doc in + tab(doc, selectedID: selectedID).id(doc.id) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .onChange(of: selectedID, initial: true) { _, id in + guard let id else { return } + proxy.scrollTo(id, anchor: nil) + } + } + } +} + +/// Which groups share a row in the wrapped bar: every open group is a row of its own, and a run of folded groups +/// is one row of chips. +enum MarkdownTabGroupRows { + struct Row: Identifiable, Equatable { + var groups: [MarkdownTabGroup] + var id: String { groups.map(\.id).joined(separator: "|") } + } + + static func rows(_ groups: [MarkdownTabGroup], collapsed: Set<String>) -> [Row] { + var rows: [Row] = [] + for group in groups { + if collapsed.contains(group.id), let last = rows.last, last.groups.allSatisfy({ collapsed.contains($0.id) }) { + rows[rows.count - 1].groups.append(group) + } else { + rows.append(Row(groups: [group])) + } + } + return rows + } +} + +/// A group's chip: its colour, a chevron, its name and its count. A click folds or opens it, ⌥-click shows only +/// this group, a double-click renames it. Drop a tab on it to move the tab in; drag it onto another chip to reorder. +private struct MarkdownTabGroupChip: View { + let group: MarkdownTabGroup + let collapsed: Bool + let model: MarkdownDocumentsModel + @State private var renaming = false + @State private var name = "" + @FocusState private var nameFocused: Bool + @State private var dropSide: MarkdownTabDropSide? + @State private var width: CGFloat = 0 + + private var color: Color { MarkdownTabGroupChip.color(for: group.id) } + + var body: some View { + Button(action: click) { + HStack(spacing: 5) { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .rotationEffect(.degrees(collapsed ? 0 : 90)) + Text(group.title).font(.system(size: 11.5, weight: .semibold)).lineLimit(1) + Text(verbatim: "\(group.tabIDs.count)") + .font(.system(size: 10.5, weight: .medium)).monospacedDigit() + .foregroundStyle(.secondary) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule().fill(color.opacity(dropSide == .into ? 0.32 : 0.16))) + .overlay(Capsule().strokeBorder(color.opacity(dropSide == .into ? 0.9 : 0.4), lineWidth: 1)) + .foregroundStyle(color) + .contentShape(Capsule()) + } + .buttonStyle(.genHoverPlain()) + .padding(.vertical, 2) + .instantTooltip(tooltip) + .accessibilityLabel("\(group.title) group, \(group.tabIDs.count) \(group.tabIDs.count == 1 ? "tab" : "tabs"), \(collapsed ? "folded" : "open")") + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("markdown-tab-group-" + group.id) + .draggable(MarkdownTabDrag.begin(.group(group.id))) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 } + .onDrop(of: [.utf8PlainText, .plainText], delegate: MarkdownTabDropDelegate( + width: width, chip: true, side: $dropSide, perform: drop)) + .markdownTabDropLine(dropSide) + .contextMenu { menu } + // The field takes the focus when the popover opens, and the name is kept on Return and on any other way + // the popover closes (a click outside); Esc puts the old name back (UX audit W17). + .popover(isPresented: $renaming, arrowEdge: .bottom) { + TextField("Group name", text: $name) + .textFieldStyle(.roundedBorder) + .frame(width: 220) + .padding(12) + .focused($nameFocused) + .onAppear { nameFocused = true } + .onSubmit { renaming = false } + .onExitCommand { + name = group.title + renaming = false + } + } + .onChange(of: renaming) { _, open in + guard !open else { return } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, trimmed != group.title { model.renameGroup(group.id, to: trimmed) } + } + } + + private var tooltip: TooltipContent { + TooltipContent(title: group.title + (group.detail.map { " · " + $0 } ?? ""), bullets: [ + "Click folds or opens the group, ⌥-click shows only this group", + "Double-click renames it", + "Drag a tab onto it to move the tab in; drag the group to reorder", + ]) + } + + /// A tab on the chip goes into the group; a group lands before or after this one. + private func drop(_ drag: MarkdownTabDrag, _ side: MarkdownTabDropSide) { + switch drag { + case .tab(let id): model.dropTab(id, onGroup: group.id) + case .group(let id): model.moveGroup(id, beside: group.id, after: side == .after) + } + } + + private func click() { + if MouseClick.current >= 2 { + // The first click of the double-click already folded or opened it: put that back, then rename. + model.toggleGroup(group.id) + startRename() + } else if NSEvent.modifierFlags.contains(.option) { + model.focusGroup(group.id) + } else { + model.toggleGroup(group.id) + } + } + + private func startRename() { + name = group.title + renaming = true + } + + @ViewBuilder private var menu: some View { + Button("Rename Group…", action: startRename) + Button(collapsed ? "Open Group" : "Fold Group") { model.toggleGroup(group.id) } + Button("Show Only This Group") { model.focusGroup(group.id) } + Button("Open All Groups") { model.setAllGroups(collapsed: false) } + Button("Fold All Groups") { model.setAllGroups(collapsed: true) } + Divider() + Button("Move Group Left") { model.shiftGroup(group.id, by: -1) } + Button("Move Group Right") { model.shiftGroup(group.id, by: 1) } + if group.manual || model.configuration.tabGroupState.names[group.id] != nil { + Button(group.manual ? "Ungroup" : "Undo Changes to This Group") { model.ungroup(group.id) } + } + Divider() + Button("Close \(group.tabIDs.count == 1 ? "Tab" : "\(group.tabIDs.count) Tabs") in Group") { + model.closeGroup(group.id) + } + Divider() + MarkdownTabGroupingMenu(configuration: model.configuration, notices: model) + } + + /// A stable colour per group id, so a group keeps its colour across launches and reorders. + static func color(for id: String) -> Color { + let palette: [Color] = [.blue, .purple, .pink, .orange, .teal, .green, .indigo, .mint, .cyan, .yellow, .red, .brown] + let hash = id.unicodeScalars.reduce(UInt32(5381)) { ($0 &* 33) &+ $1.value } + return palette[Int(hash % UInt32(palette.count))] + } +} + +/// What a drag in the tab bar carries: a tab or a group, as text with a prefix, so a drop knows which it got. +enum MarkdownTabDrag: Equatable { + case tab(String) + case group(String) + + static func tab(_ id: String) -> String { "genesis-tab:" + id } + static func group(_ id: String) -> String { "genesis-tab-group:" + id } + + static func parse(_ text: String) -> MarkdownTabDrag? { + if text.hasPrefix("genesis-tab-group:") { return .group(String(text.dropFirst("genesis-tab-group:".count))) } + if text.hasPrefix("genesis-tab:") { return .tab(String(text.dropFirst("genesis-tab:".count))) } + return nil + } +} + +/// "Group Tabs By" and "Group From", in the tab bar's menu, every chip's and every tab's. +struct MarkdownTabGroupingMenu: View { + @ObservedObject var configuration: GenesisKitMarkdownConfiguration + /// Where the reset's Undo note goes. + var notices: MarkdownDocumentsModel? + + var body: some View { + Picker("Group Tabs By", selection: $configuration.tabGrouping) { + ForEach(MarkdownTabGroupRule.allCases, id: \.self) { Text($0.title).tag($0) } + } + // An option its parent switch makes moot is dimmed, and its parent is named in the title (UX audit W21). + let grouping = configuration.tabGrouping != .off + Picker(grouping ? "Group From" : "Group From (Grouping Is Off)", selection: $configuration.tabGroupFrom) { + Text("Always").tag(0) + Text("6 Open Tabs").tag(6) + Text("10 Open Tabs").tag(10) + Text("20 Open Tabs").tag(20) + } + .disabled(!grouping) + Toggle(configuration.wrapTabs && grouping ? "Each Group on Its Own Rows" : "Each Group on Its Own Rows (Needs Wrap Tabs and Grouping)", + isOn: $configuration.tabGroupRows) + .disabled(!configuration.wrapTabs || !grouping) + Picker(configuration.wrapTabs ? "Wrapped Tabs Show" : "Wrapped Tabs Show (Wrap Tabs Is Off)", + selection: $configuration.wrapTabsMaxRows) { + Text("2 Rows").tag(2) + Text("4 Rows").tag(4) + Text("6 Rows").tag(6) + Text("Every Row").tag(0) + } + .disabled(!configuration.wrapTabs) + if configuration.tabBarHeight > 0 { + Button("Reset Tab Bar Height") { configuration.tabBarHeight = 0 } + } + if configuration.tabGroupState != MarkdownTabGroupState() { + Button("Reset Group Names, Order and Moves") { resetGroups() } + } + } +} + +extension MarkdownTabGroupingMenu { + /// Every hand edit to the groups goes at once, so the reset says so and offers Undo (UX audit W22). + private func resetGroups() { + let before = configuration.tabGroupState + configuration.tabGroupState = MarkdownTabGroupState() + guard let model = notices else { return } + model.post(MarkdownNotice("Group names, order and moves were reset.", tone: .info, actions: [ + .button("Undo", symbol: "arrow.uturn.backward") { [weak model, configuration] in + configuration.tabGroupState = before + model?.dismissNotice() + }, + ])) + } +} + +/// "Move to Group" in a tab's menu: the groups there are, a new group, or back to the rule. +private struct MarkdownTabMoveMenu: View { + let doc: MarkdownDocument + let model: MarkdownDocumentsModel + + var body: some View { + let groups = model.tabGroups + let current = model.group(of: doc.id)?.id + Menu("Move to Group") { + // With the detail (the folder above, the project) two groups of one name can be told apart (UX audit W20). + ForEach(groups.filter { $0.id != current }) { group in + Button(group.title + (group.detail.map { " · " + $0 } ?? "")) { model.dropTab(doc.id, onGroup: group.id) } + } + if groups.count > 1 { Divider() } + Button("New Group with This Tab") { model.newGroup(with: doc.id) } + if model.configuration.tabGroupState.assigned[doc.id] != nil { + Button("Back to Its Own Group") { model.assign(doc.id, to: nil) } + } + } + } } /// "Wrap Tabs", in the tab bar's menu and in every tab's. @@ -268,6 +716,8 @@ private struct MarkdownDocumentTab: View, Equatable { let preview: Bool let fill: Color @ObservedObject private var configuration = GenesisKitMarkdownConfiguration.shared + @State private var dropSide: MarkdownTabDropSide? + @State private var width: CGFloat = 0 static func == (left: MarkdownDocumentTab, right: MarkdownDocumentTab) -> Bool { left.doc === right.doc && left.model === right.model && left.active == right.active @@ -279,7 +729,10 @@ private struct MarkdownDocumentTab: View, Equatable { // A tab that previews on hover shows its path in the preview's header instead: a tooltip there // would sit under the preview. let previews = !active && model.configuration.hoverPreview - HStack(spacing: 6) { + // No gap and no outer tap gesture: the select button's label carries the tab's left and vertical padding and + // the close button's carries the right, so the whole drawn tab shows the hover and the hand, and every click + // lands on a button (UX audit W15). + HStack(spacing: 0) { Button { selectOrKeep(via: "button") } label: { @@ -299,43 +752,65 @@ private struct MarkdownDocumentTab: View, Equatable { GitFileMark(file: doc.url) } } + .padding(.leading, 12) + .padding(.trailing, 3) + .padding(.vertical, 7) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) + .instantTooltip(previews ? "" : tooltip) .accessibilityLabel(accessibilityTitle) .accessibilityAddTraits(active ? .isSelected : []) + .accessibilityAction(named: "Move Left") { model.shiftTab(doc.id, by: -1) } + .accessibilityAction(named: "Move Right") { model.shiftTab(doc.id, by: 1) } Button { MarkdownDocumentWindow.input.notice("\(MarkdownTabLog.note(action: "close", index: tabIndex), privacy: .public)") model.close(doc) } label: { + // A 22 pt target around the 9 pt glyph (UX audit W11). With the 9 pt right padding inside the label + // and no gap before it, the glyph's centre and the tab's width stay where the old 16 pt frame, 6 pt + // gap and 12 pt padding put them. Image(systemName: "xmark") .font(.system(size: 9, weight: .bold)) - // A 9pt glyph alone was a 9×9pt target. - .frame(width: 16, height: 16) + .opacity(0.6) + .frame(width: 22, height: 22) + .padding(.trailing, 9) + .padding(.vertical, 3) .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) - .opacity(0.6) - .instantTooltip("Close tab") + .instantTooltip(MarkdownKeymap.hint("Close tab", "markdown.tab.close")) .accessibilityLabel("Close \(doc.title)") } - .padding(.horizontal, 12) - .padding(.vertical, 7) .background(active ? fill : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) - .onTapGesture { selectOrKeep(via: "tap") } .contextMenu { if preview { Button("Keep Tab") { model.keep(doc) } Divider() } MarkdownTabMenu(doc: doc, model: model) + Button("Move Tab Left") { model.shiftTab(doc.id, by: -1) } + .disabled(!model.canShiftTab(doc.id, by: -1)) + Button("Move Tab Right") { model.shiftTab(doc.id, by: 1) } + .disabled(!model.canShiftTab(doc.id, by: 1)) Divider() + if model.groupsTabs { + MarkdownTabMoveMenu(doc: doc, model: model) + } + MarkdownTabGroupingMenu(configuration: model.configuration, notices: model) MarkdownWrapTabsToggle(configuration: model.configuration) } - .instantTooltip(previews ? "" : doc.url.path) + .draggable(MarkdownTabDrag.begin(.tab(doc.id))) { + Text(doc.title).font(.system(size: 12, weight: .medium)).padding(.horizontal, 12).padding(.vertical, 7) + } + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 } + .onDrop(of: [.utf8PlainText, .plainText], delegate: MarkdownTabDropDelegate( + width: width, chip: false, side: $dropSide, perform: drop)) + .markdownTabDropLine(dropSide) .markdownHoverPreview(active ? nil : doc.url, model: model) + .frameProbe("md.tab." + doc.id) } /// A click selects. The second click of a double-click also keeps a preview tab, as in an editor; @@ -344,14 +819,31 @@ private struct MarkdownDocumentTab: View, Equatable { model.docs.firstIndex { $0.id == doc.id } ?? 0 } + /// A tab lands before or after this one, in its group; a group moves before or after this tab's group. + private func drop(_ drag: MarkdownTabDrag, _ side: MarkdownTabDropSide) { + switch drag { + case .tab(let id): + model.dropTab(id, beside: doc.id, after: side == .after) + case .group(let id): + guard let target = model.group(of: doc.id)?.id else { return } + model.moveGroup(id, beside: target, after: side == .after) + } + } + private func selectOrKeep(via gesture: String) { MarkdownDocumentWindow.input.notice("\(MarkdownTabLog.note(action: "select", index: tabIndex, gesture: gesture), privacy: .public)") model.select(doc.id) - if (NSApp.currentEvent?.clickCount ?? 1) >= 2 { + if MouseClick.current >= 2 { model.keep(doc) } } + /// The path, and for the italic preview tab what italic means (UX audit W24). + private var tooltip: String { + guard preview else { return doc.url.path } + return "Preview tab: the next file you open from the sidebar replaces it. Double-click to keep it.\n" + doc.url.path + } + private var accessibilityTitle: String { var parts = [doc.title] if preview { parts.append("preview") } @@ -428,12 +920,20 @@ enum MarkdownPaneContent { self.pixels = CGFloat(MediaThumbnailCache.bucket(forPixels: pixels)) } + /// The file part of the request: what makes a shown picture wrong, as opposed to only the wrong size. + var file: PictureFile { PictureFile(url: url, modifiedAt: modifiedAt) } + /// Loads the picture through the shared off-main decoder and thumbnail cache. func load() async -> MarkdownHoverImage.Picture { await MarkdownHoverImage.picture(url: url, pixels: pixels, draft: nil) } } + struct PictureFile: Hashable { + let url: URL + let modifiedAt: Date? + } + /// Whether the current pane renders a web page whose completion must precede the tab swap. static func waitsForPage(_ doc: MarkdownDocument, model: MarkdownDocumentsModel) -> Bool { guard model.renderer == .web, !(doc.missing && !doc.isDirty), !showsBinary(doc, model: model), @@ -454,6 +954,16 @@ private struct MarkdownDocumentPane: View { @StateObject private var gutter = GitGutterModel() @StateObject private var blame = AgentBlameModel() @State private var binaryPicture: MarkdownHoverImage.Picture = .loading + /// The file and date the shown picture was decoded from: a new size of the same file keeps it on screen while + /// the new size decodes (UX audit W9). + @State private var binaryPictureFile: MarkdownPaneContent.PictureFile? + /// Try Again after a failed decode. + @State private var binaryPictureAttempt = 0 + + private var showsPicture: Bool { + if case .ready = binaryPicture { return true } + return false + } @Environment(\.displayScale) private var displayScale @ObservedObject private var configuration = GenesisKitMarkdownConfiguration.shared @@ -564,18 +1074,28 @@ private struct MarkdownDocumentPane: View { .accessibilityLabel(doc.url.lastPathComponent) .accessibilityIdentifier("markdown-binary-image") } else if case .failed = binaryPicture { - pathButton + VStack(spacing: 8) { + Text("The picture could not be shown") + .font(.headline) + pathButton + Button("Try Again") { binaryPictureAttempt += 1 } + .buttonStyle(.genHover(accent: model.theme.accent)) + .foregroundStyle(model.theme.accent) + .accessibilityIdentifier("markdown-binary-retry") + } } else { ProgressView().controlSize(.small) } } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) - .task(id: request) { - binaryPicture = .loading + .task(id: [AnyHashable(request), AnyHashable(binaryPictureAttempt)]) { + // Only another file (or a changed one) takes the picture away; a new size decodes behind it. + if binaryPictureFile != request.file || !showsPicture { binaryPicture = .loading } let picture = await request.load() guard !Task.isCancelled else { return } binaryPicture = picture + binaryPictureFile = request.file } } } else { @@ -727,7 +1247,9 @@ private struct MarkdownDocumentPane: View { onWikilink: { model.follow($0, from: doc) }, highlightQuery: nil, highlightToken: 0, - rich: model.renderer == .nativeRich + rich: model.renderer == .nativeRich, + zoom: model.zoom, + theme: model.theme ) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift index 0d23f755ab..c8a0979dd9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownKeymap.swift @@ -29,12 +29,17 @@ public enum MarkdownKeymap { KeymapCommand("markdown.tab.reopen", "Reopen Closed Tab", group: "Tabs", keys: [.character("t", [.shift, .command])]), KeymapCommand("markdown.tab.next", "Next Tab", group: "Tabs", keys: [.code(30, [.shift, .command])]), KeymapCommand("markdown.tab.previous", "Previous Tab", group: "Tabs", keys: [.code(33, [.shift, .command])]), + KeymapCommand("markdown.tab.closeOthers", "Close Other Tabs", group: "Tabs", + detail: "Every tab but the one on screen. Reopen Closed Tab brings them all back.", keys: []), + ] + tabNumberCommands + [ // History KeymapCommand("markdown.history.back", "Back", group: "History", detail: "The note you read before this one; a closed tab opens again.", keys: [.code(33, .command)]), KeymapCommand("markdown.history.forward", "Forward", group: "History", keys: [.code(30, .command)]), // Files + KeymapCommand("markdown.file.open", "Open…", group: "Files", detail: "Markdown and text files, several at once.", + keys: [.character("o", .command)]), KeymapCommand("markdown.file.save", "Save", group: "Files", keys: [.character("s", .command)]), KeymapCommand("markdown.file.reload", "Reload from Disk", group: "Files", keys: [.character("r", .command)]), KeymapCommand("markdown.file.reveal", "Reveal in Finder", group: "Files", keys: [.character("r", [.shift, .command])]), @@ -84,6 +89,13 @@ public enum MarkdownKeymap { scope: sourceScope), KeymapCommand("markdown.edit.task", "Task On or Off", group: "Source Editing", keys: [.character("x", [.shift, .command])], scope: sourceScope), + // The gutter's bars from the keyboard (UX audit E13): by position, the two keys right of P, as Back and + // Forward and the tab keys are; ⌥⌘ is free on both. + KeymapCommand("markdown.edit.nextChange", "Next Change", group: "Source Editing", + detail: "Moves to the next changed lines and opens their card.", + keys: [.code(30, [.option, .command])], scope: sourceScope), + KeymapCommand("markdown.edit.previousChange", "Previous Change", group: "Source Editing", + keys: [.code(33, [.option, .command])], scope: sourceScope), KeymapCommand("markdown.edit.indent", "Indent Lines", group: "Source Editing", keys: [.code(48)], scope: sourceScope, configurable: false), KeymapCommand("markdown.edit.outdent", "Outdent Lines", group: "Source Editing", keys: [.code(48, .shift)], @@ -117,28 +129,52 @@ public enum MarkdownKeymap { scope: .app, configurable: false), ]) + /// ⌘1 to ⌘8 select the tab at that place in the bar's order, ⌘9 the last one (as in a browser). + static let tabNumberCommands: [KeymapCommand] = (1...9).map { number in + KeymapCommand(tabNumberCommand(number), number == 9 ? "Last Tab" : "Tab \(number)", group: "Tabs", + keys: [.character(String(number), .command)]) + } + + static func tabNumberCommand(_ number: Int) -> String { "markdown.tab.select\(number)" } + /// The window's own commands, held for as long as the window lives. Everything the model can do alone runs here; - /// the toolbar adds Save as PDF and Print (`.keymapAction`), the find bar its own keys while it is open. + /// the find bar adds its own keys while it is open. static func registerWindowCommands( model: MarkdownDocumentsModel, window: NSWindow, keymap: Keymap = shared, dispatcher: KeymapDispatcher = .shared, closeTab: @escaping () -> Void ) -> [KeymapRegistration] { - let actions: [(String, () -> Void)] = [ + windowActions(model: model, window: window, keymap: keymap, closeTab: closeTab) + .map { id, run in dispatcher.register(id, in: keymap, window: window, perform: run) } + } + + /// What each window command does: the keys (`registerWindowCommands`) and the menu bar + /// (`MarkdownDocumentWindowController.performCommand`) run the same closure. + static func windowActions( + model: MarkdownDocumentsModel, window: NSWindow?, keymap: Keymap = shared, closeTab: @escaping () -> Void + ) -> [(String, () -> Void)] { + let tabs: [(String, () -> Void)] = (1...9).map { number in + (tabNumberCommand(number), { model.selectTab(number: number) }) + } + return tabs + [ ("markdown.tab.close", closeTab), + ("markdown.tab.closeOthers", { if let doc = model.selected { model.closeOthers(doc) } }), ("markdown.tab.reopen", { model.reopenLastClosed() }), ("markdown.tab.next", { model.selectTab(offset: 1) }), ("markdown.tab.previous", { model.selectTab(offset: -1) }), ("markdown.history.back", { model.goBack() }), ("markdown.history.forward", { model.goForward() }), + ("markdown.file.open", { MarkdownDocumentsView.chooseFiles(into: model) }), ("markdown.file.save", { model.saveSelected() }), + ("markdown.file.pdf", { model.exportPDF() }), + ("markdown.file.print", { model.printDocument() }), ("markdown.file.reload", { model.selected?.reload() }), ("markdown.file.reveal", { if let url = model.selected?.url { NSWorkspace.shared.activateFileViewerSelecting([url]) } }), ("markdown.file.copyPath", { model.copySelectedPath() }), ("markdown.file.goto", { model.goToOpen = true }), - ("markdown.view.files", { withAnimation(GenAnimation.quick) { model.showFiles.toggle() } }), - ("markdown.view.outline", { withAnimation(GenAnimation.quick) { model.showOutline.toggle() } }), + ("markdown.view.files", { GenMotion.perform(expanding: !model.showFiles, reduceMotion: GenMotion.systemReduceMotion) { model.showFiles.toggle() } }), + ("markdown.view.outline", { GenMotion.perform(expanding: !model.showOutline, reduceMotion: GenMotion.systemReduceMotion) { model.showOutline.toggle() } }), ("markdown.view.source", { model.toggleSource() }), ("markdown.view.split", { model.toggleSplit() }), ("markdown.view.bigger", { model.zoomStep(1) }), @@ -149,7 +185,6 @@ public enum MarkdownKeymap { ("markdown.window.close", { [weak window] in window?.performClose(nil) }), ("markdown.help.shortcuts", { KeymapHelpWindow.show(keymap) }), ] - return actions.map { id, run in dispatcher.register(id, in: keymap, window: window, perform: run) } } /// The source editor's command for a key press, if it has one (`MarkdownSourceEditor`). @@ -165,14 +200,21 @@ public enum MarkdownKeymap { textView: NSTextView, window: NSWindow, keymap: Keymap = shared, dispatcher: KeymapDispatcher = .shared, perform: @escaping (String) -> Void ) -> [KeymapRegistration] { - ["markdown.edit.bold", "markdown.edit.italic", "markdown.edit.link", "markdown.edit.task"].map { id in - dispatcher.register(id, in: keymap, window: window, isEnabled: { [weak textView] in - guard let textView, let window = textView.window else { return false } - return window.firstResponder === textView - }) { + let focused: () -> Bool = { [weak textView] in + guard let textView, let window = textView.window else { return false } + return window.firstResponder === textView + } + let edits = ["markdown.edit.bold", "markdown.edit.italic", "markdown.edit.link", "markdown.edit.task"].map { id in + dispatcher.register(id, in: keymap, window: window, isEnabled: focused) { perform(id) } } + let hunks = [("markdown.edit.nextChange", 1), ("markdown.edit.previousChange", -1)].map { id, step in + dispatcher.register(id, in: keymap, window: window, isEnabled: focused) { [weak textView] in + _ = (textView as? SourceTextView)?.openHunk(step: step) + } + } + return edits + hunks } /// "Save (⌘S)" with the key in force. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownLayout.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownLayout.swift index a89fad1665..60a8ee3d81 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownLayout.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownLayout.swift @@ -112,8 +112,9 @@ enum MarkdownSidebarWidth { /// `MarkdownWindowPublishCostTests` compares it with that measurement (603 points, 743 with all three sidebars at their /// standard widths, 2026-10-14). enum MarkdownWindowMinimum { - /// Measured minimum width of the toolbar row, in points. - static let toolbarRow: Double = 603 + /// Measured minimum width of the toolbar row, in points (606 since the reading size and the renderer name became + /// 11 pt text, UX audit E15; `MarkdownWindowPublishCostTests` measures it). + static let toolbarRow: Double = 606 /// Minimum reading-pane width retained beside the visible sidebars. static let pane: Double = 10 /// Minimum content height retained by the viewer window. @@ -143,7 +144,8 @@ enum MarkdownWindowMinimum { } } -/// The draggable boundary between the editor and the preview. +/// The draggable boundary between the editor and the preview, the sidebars and their neighbours, and (`.horizontal`) +/// the wrapped tab bar and the page under it. /// /// AppKit rather than SwiftUI on purpose. A SwiftUI `onHover` + `NSCursor.push` /// misses the cursor change often, and an overlay strip over a hairline loses @@ -151,17 +153,22 @@ enum MarkdownWindowMinimum { /// wins hit testing. A view with its own tracking area, `cursorUpdate` and /// mouse handling behaves the way a split divider is supposed to. struct MarkdownSplitDivider: NSViewRepresentable { + /// `.vertical`: an upright line dragged sideways (the sidebars, the split). `.horizontal`: a line along the bottom + /// edge dragged up and down, so the strip reads as part of the bar above it (the tab bar). + enum Orientation { case vertical, horizontal } + + var orientation: Orientation = .vertical var line: NSColor var accent: NSColor /// Mouse went down: remember where the divider was. var onBegin: () -> Void /// Points moved since that mouse-down, in window coordinates (stable while - /// the divider itself moves under the pointer). + /// the divider itself moves under the pointer). Right and down are positive. var onDrag: (Double) -> Void var onEnd: () -> Void /// Double click: back to an even split. var onReset: () -> Void - /// Arrow keys and VoiceOver increment/decrement: -1 left, +1 right. + /// Arrow keys and VoiceOver increment/decrement: -1 left or up, +1 right or down. var onStep: (Int) -> Void = { _ in } /// The editor's share, spoken as the divider's value. var ratio: Double = MarkdownSplit.defaultRatio @@ -181,6 +188,7 @@ struct MarkdownSplitDivider: NSViewRepresentable { } private func apply(_ view: DividerView) { + view.orientation = orientation view.line = line view.accent = accent view.onBegin = onBegin @@ -194,6 +202,7 @@ struct MarkdownSplitDivider: NSViewRepresentable { } final class DividerView: NSView { + var orientation: Orientation = .vertical var line: NSColor = .separatorColor var accent: NSColor = .controlAccentColor var onBegin: (() -> Void)? @@ -206,11 +215,11 @@ struct MarkdownSplitDivider: NSViewRepresentable { var valueText: String? private var hot = false { didSet { if hot != oldValue { needsDisplay = true } } } - private var startX: CGFloat = 0 + private var start: CGFloat = 0 private var area: NSTrackingArea? /// The drag log (`logDrag`): where the divider was drawn when the drag began, the last pointer offset, the /// number of moves and the largest gap between the drawn divider and the pointer. - private var drawnStartX: CGFloat = 0 + private var drawnStart: CGFloat = 0 private var lastPointerDX: CGFloat = 0 private var moves = 0 private var largestGap: CGFloat = 0 @@ -219,6 +228,10 @@ struct MarkdownSplitDivider: NSViewRepresentable { /// view that does not claim its own mouse-down, instead of resizing. override var mouseDownCanMoveWindow: Bool { false } + /// A press on the divider of a window in the background drags at once, as NSSplitView's dividers do; without + /// this the first press only brought the window forward. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func updateTrackingAreas() { super.updateTrackingAreas() if let area { removeTrackingArea(area) } @@ -233,12 +246,14 @@ struct MarkdownSplitDivider: NSViewRepresentable { // Both routes to the same cursor: the tracking area answers while the // pointer moves, the cursor rect answers after a scroll or a resize. - override func cursorUpdate(with event: NSEvent) { NSCursor.resizeLeftRight.set() } - override func resetCursorRects() { addCursorRect(bounds, cursor: .resizeLeftRight) } + override func cursorUpdate(with event: NSEvent) { cursor.set() } + override func resetCursorRects() { addCursorRect(bounds, cursor: cursor) } + + private var cursor: NSCursor { orientation == .vertical ? .resizeLeftRight : .resizeUpDown } override func mouseEntered(with event: NSEvent) { hot = true - NSCursor.resizeLeftRight.set() + cursor.set() } override func mouseExited(with event: NSEvent) { @@ -258,9 +273,12 @@ struct MarkdownSplitDivider: NSViewRepresentable { override func draw(_ dirtyRect: NSRect) { let lit = hot || focused - let width: CGFloat = lit ? 3 : 1 + let thickness: CGFloat = lit ? 3 : 1 (lit ? accent : line).setFill() - NSRect(x: (bounds.width - width) / 2, y: 0, width: width, height: bounds.height).fill() + switch orientation { + case .vertical: NSRect(x: (bounds.width - thickness) / 2, y: 0, width: thickness, height: bounds.height).fill() + case .horizontal: NSRect(x: 0, y: isFlipped ? bounds.height - thickness : 0, width: bounds.width, height: thickness).fill() + } } override func mouseDown(with event: NSEvent) { @@ -268,8 +286,8 @@ struct MarkdownSplitDivider: NSViewRepresentable { onReset?() return } - startX = event.locationInWindow.x - drawnStartX = drawnX + start = position(event) + drawnStart = drawn lastPointerDX = 0 moves = 0 largestGap = 0 @@ -278,22 +296,30 @@ struct MarkdownSplitDivider: NSViewRepresentable { } override func mouseDragged(with event: NSEvent) { - NSCursor.resizeLeftRight.set() + cursor.set() // How far the divider drawn now is from where the previous move asked it to be. - largestGap = max(largestGap, abs((drawnX - drawnStartX) - lastPointerDX)) + largestGap = max(largestGap, abs((drawn - drawnStart) - lastPointerDX)) moves += 1 - lastPointerDX = event.locationInWindow.x - startX + lastPointerDX = position(event) - start onDrag?(Double(lastPointerDX)) } override func mouseUp(with event: NSEvent) { hot = bounds.contains(convert(event.locationInWindow, from: nil)) onEnd?() - logDrag(pointerDX: event.locationInWindow.x - startX) + logDrag(pointerDX: position(event) - start) } - /// The divider's middle in window coordinates. - private var drawnX: CGFloat { convert(NSPoint(x: bounds.midX, y: 0), to: nil).x } + /// The pointer along the drag axis, growing to the right or downwards (window y grows upwards). + private func position(_ event: NSEvent) -> CGFloat { + orientation == .vertical ? event.locationInWindow.x : -event.locationInWindow.y + } + + /// The divider's middle in window coordinates, on the same axis as `position`. + private var drawn: CGFloat { + let middle = convert(NSPoint(x: bounds.midX, y: bounds.midY), to: nil) + return orientation == .vertical ? middle.x : -middle.y + } /// One line per drag in the host's log, so a resize that felt "not 1:1" can be read afterwards (Martin, /// 2026-10-15: "pls just keep the logger for the resize so you can debug it later if i catch that again"). @@ -302,7 +328,7 @@ struct MarkdownSplitDivider: NSViewRepresentable { private func logDrag(pointerDX: CGFloat) { guard moves > 0 else { return } window?.contentView?.layoutSubtreeIfNeeded() - let dividerDX = drawnX - drawnStartX + let dividerDX = drawn - drawnStart let overlays = (window?.contentView as? MarkdownWindowContentView)?.overlayDrift() ?? 0 let line = String(format: "markdown: divider drag \"%@\": %d moves, pointer %+.0f pt, divider %+.0f pt, largest gap %.0f pt, overlay drift %.0f pt", label, moves, Double(pointerDX), Double(dividerDX), Double(largestGap), Double(overlays)) @@ -341,8 +367,10 @@ struct MarkdownSplitDivider: NSViewRepresentable { override func keyDown(with event: NSEvent) { switch event.keyCode { - case 123: onStep?(-1) // left arrow - case 124: onStep?(1) // right arrow + case 123 where orientation == .vertical: onStep?(-1) // left arrow + case 124 where orientation == .vertical: onStep?(1) // right arrow + case 126 where orientation == .horizontal: onStep?(-1) // up arrow + case 125 where orientation == .horizontal: onStep?(1) // down arrow case 36, 76: onReset?() // return, enter default: super.keyDown(with: event) } @@ -353,7 +381,9 @@ struct MarkdownSplitDivider: NSViewRepresentable { override func accessibilityLabel() -> String? { label } override func accessibilityValue() -> Any? { valueText ?? "Editor \(Int((ratio * 100).rounded())) percent" } override func accessibilityHelp() -> String? { - "Left and right arrows move the divider, Return resets it to an even split." + orientation == .vertical + ? "Left and right arrows move the divider, Return resets it to an even split." + : "Up and down arrows move the divider, Return resets it." } override func accessibilityPerformIncrement() -> Bool { onStep?(1) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownNotice.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownNotice.swift new file mode 100644 index 0000000000..014fe60299 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownNotice.swift @@ -0,0 +1,80 @@ +// +// MarkdownNotice.swift +// GenesisKit +// +// The window's one message slot (UX audit 2026-10-25, W2, E2, D1). Before, a failed save, "Replaced 3 matches" and a +// revision error shared one 9.5 pt line in the toolbar: cut to one line, in the accent colour, never cleared and never +// spoken. Now each message carries its tone, a success fades by itself, an error stays whole until it is closed (the +// raw text behind Details), and every message is announced to VoiceOver. +// + +import SwiftUI + +/// One message for the reader, with the tone the code that wrote it knows. +struct MarkdownNotice: Identifiable { + enum Tone: Equatable { + /// Something the reader asked for worked ("Replaced 3 matches"). + case success + /// Something happened that the reader should know about ("Closed 12 tabs"). + case info + /// Something did not happen, but nothing is lost ("Nothing found for [[Plan]]"). + case warning + /// Something failed and needs the reader ("The note could not be saved"). + case error + + /// The shared pill has no info look of its own: an info note reads as a success. + var pill: NoticePill.Tone { + switch self { + case .success, .info: return .success + case .warning: return .warning + case .error: return .error + } + } + } + + let id = UUID() + let text: String + /// The raw text behind the sentence (stderr, the system's error), shown after a click on Details. + var more: String? + let tone: Tone + /// At most one or two: "Undo", "Open in Preview". + var actions: [NoticeFix] = [] + + init(_ text: String, tone: Tone, more: String? = nil, actions: [NoticeFix] = []) { + self.text = text + self.tone = tone + self.more = more + self.actions = actions + } + + /// How long a note stays before it clears by itself; nil for an error, which stays until it is closed. About + /// 1 s plus 50 ms per word (the checklist's reading time), at least 3 s, and at least 8 s with a button on it so + /// there is time to reach it. + var lifetime: TimeInterval? { + guard tone != .error else { return nil } + let words = text.split(whereSeparator: \.isWhitespace).count + let reading = 1 + 0.05 * Double(words) + return max(actions.isEmpty ? 3 : 8, reading) + } +} + +/// The slot, laid over the top of the document area so it is near what the reader is looking at. One note at a +/// time: a new one replaces the old. +struct MarkdownNoticeSlot: View { + @ObservedObject var model: MarkdownDocumentsModel + + var body: some View { + if let notice = model.notice { + NoticePill(text: notice.text, more: notice.more, tone: notice.tone.pill, fixes: notice.actions) { + model.dismissNotice(notice.id) + } + .id(notice.id) + .frame(maxWidth: 440, alignment: .trailing) + .background(model.theme.chrome.opacity(0.92), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 8, y: 2) + .padding(10) + .transition(.opacity) + .accessibilityIdentifier("markdown-notice") + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownRecents.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownRecents.swift index 6c48abe261..3ea873c004 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownRecents.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownRecents.swift @@ -75,10 +75,12 @@ final class MarkdownRecentMenu: NSObject, NSMenuDelegate { item.isEnabled = exists item.toolTip = entry.path let note = exists ? MarkdownRecents.note(for: entry, isOpen: isOpen, now: now) : "missing" + // The folder tells two `index.md` apart; orange only for a file that is gone (UX audit W25). + let folder = ((entry.path as NSString).deletingLastPathComponent as NSString).lastPathComponent let title = NSMutableAttributedString(string: name, attributes: [.font: NSFont.menuFont(ofSize: 0)]) - title.append(NSAttributedString(string: " \(note)", attributes: [ + title.append(NSAttributedString(string: " \(folder) · \(note)", attributes: [ .font: NSFont.menuFont(ofSize: NSFont.smallSystemFontSize), - .foregroundColor: isOpen ? NSColor.secondaryLabelColor : NSColor.systemOrange, + .foregroundColor: exists ? NSColor.secondaryLabelColor : NSColor.systemOrange, ])) item.attributedTitle = title if !isOpen, exists { @@ -87,7 +89,7 @@ final class MarkdownRecentMenu: NSObject, NSMenuDelegate { menu.addItem(item) } menu.addItem(.separator()) - let clear = menu.addItem(withTitle: "Clear Closed", action: #selector(clearClosed(_:)), keyEquivalent: "") + let clear = menu.addItem(withTitle: "Clear Closed Files", action: #selector(clearClosed(_:)), keyEquivalent: "") clear.target = self } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownSettingsWindow.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownSettingsWindow.swift new file mode 100644 index 0000000000..9ba1a20e79 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownSettingsWindow.swift @@ -0,0 +1,214 @@ +import AppKit +import SwiftUI + +/// The viewer's Settings window (⌘,): tabs, hover previews, and the git integration with what agent attribution +/// costs. Tabs and previews joined on 2026-10-25 (UX audit W7): the tab bar's right-click menu had some of these, and +/// preview tabs, hover previews, their delay and the group sizes had no control at all. Until 2026-10-24 these +/// switches existed only as stored defaults, so attribution could not be turned off when its blame runs made the Mac +/// busy (one `tools agents blame` per open note on every repository change, 51 to 66 at once). +@MainActor +public final class MarkdownSettingsWindow: NSObject { + public static let shared = MarkdownSettingsWindow() + private var window: NSWindow? + + @objc public func show(_ sender: Any?) { + if let window { + window.makeKeyAndOrderFront(nil) + return + } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 560, height: 640), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.title = "Settings" + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: MarkdownSettingsView(configuration: .shared).titlebarZone()) + window.center() + window.setFrameAutosaveName("markdown.settings") + self.window = window + window.makeKeyAndOrderFront(nil) + } +} + +struct MarkdownSettingsView: View { + @ObservedObject var configuration: GenesisKitMarkdownConfiguration + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + MarkdownTabSettingsCard(configuration: configuration) + MarkdownPreviewSettingsCard(configuration: configuration) + MarkdownShareSettingsCard(configuration: configuration) + NativeSettingsCard("Editor", subtitle: "The source view, in Edit and Split.") { + NativeSettingsToggle("Suggest links and tags while typing", + detail: "The list of notes opens after [[ and the list of tags after #. Off: press Esc to open it.", + identifier: "markdown.settings.completeWhileTyping", isOn: $configuration.completeWhileTyping) + } + NativeSettingsCard("Git", subtitle: "Shown for notes inside a git repository.") { + NativeSettingsToggle("Git integration", + detail: "The file's state, the gutter, the Changes mode, the history and the tree marks.", + identifier: "markdown.settings.git", isOn: $configuration.git) + Divider() + NativeSettingsToggle("Changed-line gutter", detail: "Bars beside the lines that differ from HEAD.", + identifier: "markdown.settings.gitGutter", isOn: $configuration.gitGutter) + .disabled(!configuration.git) + Divider() + NativeSettingsToggle("File tree marks", detail: "M, A, U and D on rows and tabs, a dot on folders with changes.", + identifier: "markdown.settings.gitTreeDecorations", isOn: $configuration.gitTreeDecorations) + .disabled(!configuration.git) + Divider() + NativeSettingsToggle( + "Agent attribution", + detail: "Names the agent session and turn that wrote each changed line, on the gutter card and in the Changes mode.", + identifier: "markdown.settings.gitAgentAttribution", isOn: $configuration.gitAgentAttribution) + .disabled(!configuration.git || !configuration.gitGutter) + AttributionCostNote() + } + } + .padding(20) + } + .frame(minWidth: 480, minHeight: 380) + } +} + +/// Tabs: how they flow, the preview tab, and the grouping rule with its sizes. The same keys as the tab bar's menu. +struct MarkdownTabSettingsCard: View { + @ObservedObject var configuration: GenesisKitMarkdownConfiguration + + var body: some View { + let grouping = configuration.tabGrouping != .off + NativeSettingsCard("Tabs", subtitle: "The row of open files above the page.") { + NativeSettingsToggle("Preview tabs", + detail: "A single click in the file sidebar opens an italic tab that the next click replaces. A double-click or an edit keeps it.", + identifier: "markdown.settings.previewTabs", isOn: $configuration.previewTabs) + Divider() + NativeSettingsToggle("Wrap tabs", detail: "Tabs go onto more rows instead of scrolling sideways.", + identifier: "markdown.settings.wrapTabs", isOn: $configuration.wrapTabs) + Divider() + NativeSettingsRow("Wrapped tabs show", + detail: configuration.wrapTabs ? "More rows scroll, with a fade at the edge." : "Needs Wrap tabs.") { + Picker("Wrapped tabs show", selection: $configuration.wrapTabsMaxRows) { + Text("2 rows").tag(2) + Text("4 rows").tag(4) + Text("6 rows").tag(6) + Text("Every row").tag(0) + } + .labelsHidden().fixedSize() + .accessibilityIdentifier("markdown.settings.wrapTabsMaxRows") + } + .disabled(!configuration.wrapTabs) + Divider() + NativeSettingsRow("Group tabs by", detail: "With many tabs open, tabs fall into groups with a chip that folds them.") { + Picker("Group tabs by", selection: $configuration.tabGrouping) { + ForEach(MarkdownTabGroupRule.allCases, id: \.self) { Text($0.title).tag($0) } + } + .labelsHidden().fixedSize() + .accessibilityIdentifier("markdown.settings.tabGrouping") + } + Divider() + NativeSettingsRow("Group from", detail: grouping ? "Fewer open tabs stay one plain row." : "Needs Group tabs by.") { + Picker("Group from", selection: $configuration.tabGroupFrom) { + Text("Always").tag(0) + Text("6 open tabs").tag(6) + Text("10 open tabs").tag(10) + Text("20 open tabs").tag(20) + } + .labelsHidden().fixedSize() + .accessibilityIdentifier("markdown.settings.tabGroupFrom") + } + .disabled(!grouping) + Divider() + NativeSettingsRow("Smallest group", + detail: grouping ? "A group with fewer tabs joins Other." : "Needs Group tabs by.") { + stepper("Smallest group", value: $configuration.tabGroupMinimum, in: 1...10, unit: "tab", + identifier: "markdown.settings.tabGroupMinimum") + } + .disabled(!grouping) + Divider() + NativeSettingsRow("Split a project group above", + detail: configuration.tabGrouping == .smart ? "A Project group with more tabs splits by its next folder." : "Needs Group tabs by Project.") { + stepper("Split a project group above", value: $configuration.tabGroupSplitAbove, in: 4...60, unit: "tab", + identifier: "markdown.settings.tabGroupSplitAbove") + } + .disabled(configuration.tabGrouping != .smart) + Divider() + NativeSettingsToggle("Each group on its own rows", + detail: configuration.wrapTabs && grouping ? "Wrapped tabs start a new row for each open group." : "Needs Wrap tabs and Group tabs by.", + identifier: "markdown.settings.tabGroupRows", isOn: $configuration.tabGroupRows) + .disabled(!configuration.wrapTabs || !grouping) + } + } + + private func stepper(_ title: String, value: Binding<Int>, in range: ClosedRange<Int>, unit: String, + identifier: String) -> some View { + Stepper(value: value, in: range) { + Text(verbatim: "\(value.wrappedValue) \(value.wrappedValue == 1 ? unit : unit + "s")") + .font(.system(size: 12)).monospacedDigit() + } + .fixedSize() + .accessibilityLabel(title) + .accessibilityValue("\(value.wrappedValue)") + .accessibilityIdentifier(identifier) + } +} + +/// Sharing and export: where a share link opens, and whether a saved PDF brings Finder forward (UX audit D11, D12). +struct MarkdownShareSettingsCard: View { + @ObservedObject var configuration: GenesisKitMarkdownConfiguration + + var body: some View { + NativeSettingsCard("Sharing and export", subtitle: "The Share and PDF buttons in the toolbar.") { + NativeSettingsRow("Open share links in", + detail: "The link is always copied. Brave falls back to the system browser when it is not installed.") { + Picker("Open share links in", selection: $configuration.shareOpensIn) { + ForEach(MarkdownShareBrowser.allCases, id: \.self) { Text($0.title).tag($0) } + } + .labelsHidden().fixedSize() + .accessibilityIdentifier("markdown.settings.shareOpensIn") + } + Divider() + NativeSettingsToggle("Show a saved PDF in Finder", + detail: "Finder comes to the front with the file selected. Off: a note says where it was saved.", + identifier: "markdown.settings.revealSavedPDF", isOn: $configuration.revealSavedPDF) + } + } +} + +/// Hover previews: a floating preview of a note after the pointer rests on a link or a tab, and how long it waits. +struct MarkdownPreviewSettingsCard: View { + @ObservedObject var configuration: GenesisKitMarkdownConfiguration + + var body: some View { + NativeSettingsCard("Previews", subtitle: "What resting the pointer on a link or a tab shows.") { + NativeSettingsToggle("Hover previews", + detail: "A floating preview of the note opens when the pointer rests on a link or a tab.", + identifier: "markdown.settings.hoverPreview", isOn: $configuration.hoverPreview) + Divider() + NativeSettingsSlider("Hover delay", + detail: configuration.hoverPreview ? "How long the pointer rests before the preview opens." : "Needs Hover previews.", + identifier: "markdown.settings.hoverPreviewDelay", + value: $configuration.hoverPreviewDelay, in: 0.2...3, step: 0.1, + ticks: [0.5, 1, 1.5, 2, 2.5], width: 180, + format: { String(format: "%.1f s", $0) }) + .disabled(!configuration.hoverPreview) + } + } +} + +/// What attribution costs, under its switch (Martin, 2026-10-24: "make sure the settings warn about these settings"). +private struct AttributionCostNote: View { + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange).accessibilityHidden(true) + // What happens, in plain words; the command it runs is in the log (UX audit W26). + Text(""" + Each time a repository changes, this looks up which agent wrote each changed line of the notes open \ + in it, at most every 5 seconds. With many notes open in a busy repository, such as a vault that \ + agents write to, it can slow the Mac down. Turn it off if the Mac feels slow; the gutter keeps working. + """) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("markdown.settings.attributionCost") + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabDrop.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabDrop.swift new file mode 100644 index 0000000000..18619ebc34 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabDrop.swift @@ -0,0 +1,116 @@ +// +// MarkdownTabDrop.swift +// GenesisKit +// +// Drag and drop in the tab bar. The half of a tab or a group chip under the pointer says where a drop lands +// (before or after it), and a line on that edge shows it before the drop. A tab held over a chip goes into the +// group, and the chip lights up instead. +// + +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// Where a drop on a tab or a chip lands. +enum MarkdownTabDropSide: Equatable { + case before, after, into +} + +extension MarkdownTabDrag { + /// What the drag in flight carries. A drop target cannot read the payload before the drop, so the source + /// records it when the drag starts; the drop itself still reads the real payload. + @MainActor static var active: MarkdownTabDrag? + + /// The payload of a drag that starts now (`.draggable` evaluates it at the start of the drag). + @MainActor static func begin(_ drag: MarkdownTabDrag) -> String { + active = drag + switch drag { + case .tab(let id): return tab(id) + case .group(let id): return group(id) + } + } + + /// The tab-bar drag in flight, or nil for any other drag. The drag pasteboard holds the dragged text when it + /// can be read during the drag, and that wins: plain text from a page or another app is no tab drag, even when an + /// earlier tab drag that ended outside the bar left `active` set. Text not readable yet falls back to `active` + /// (UX audit W13). + @MainActor static func inFlight(pasteboard: NSPasteboard = NSPasteboard(name: .drag)) -> MarkdownTabDrag? { + if let text = pasteboard.string(forType: .string) { return parse(text) } + return active + } + + /// The side a drop at `x` lands on, in a target `width` wide. `chip`: a tab held over a chip goes into it. + static func side(x: CGFloat, width: CGFloat, chip: Bool, dragging: MarkdownTabDrag?) -> MarkdownTabDropSide { + if chip, case .tab = dragging { return .into } + return x < width / 2 ? .before : .after + } +} + +/// One drop target: a tab or a group chip. It reports the side under the pointer while a drag moves over it. +struct MarkdownTabDropDelegate: DropDelegate { + let width: CGFloat + let chip: Bool + @Binding var side: MarkdownTabDropSide? + let perform: @MainActor (MarkdownTabDrag, MarkdownTabDropSide) -> Void + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.utf8PlainText, .plainText]) && MainActor.assumeIsolated { MarkdownTabDrag.inFlight() != nil } + } + + func dropEntered(info: DropInfo) { + update(info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + update(info) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + side = nil + } + + func performDrop(info: DropInfo) -> Bool { + let x = info.location.x + let landing = side ?? MarkdownTabDrag.side(x: x, width: width, chip: chip, dragging: nil) + side = nil + guard let provider = info.itemProviders(for: [.utf8PlainText, .plainText]).first else { return false } + _ = provider.loadObject(ofClass: String.self) { text, _ in + guard let text, let drag = MarkdownTabDrag.parse(text) else { + Task { @MainActor in MarkdownTabDrag.active = nil } + return + } + Task { @MainActor [chip, width, perform] in + // A tab on a chip goes in; the side was guessed from the recorded drag, so read it again here. + let resolved: MarkdownTabDropSide = chip + ? MarkdownTabDrag.side(x: x, width: width, chip: true, dragging: drag) + : landing + MarkdownTabDrag.active = nil + perform(drag, resolved) + } + } + return true + } + + private func update(_ info: DropInfo) { + let next = MarkdownTabDrag.side( + x: info.location.x, width: width, chip: chip, dragging: MainActor.assumeIsolated { MarkdownTabDrag.inFlight() }) + if side != next { side = next } + } +} + +extension View { + /// The insertion line on the edge a drop would land on; nothing for `.into` or no drag. + func markdownTabDropLine(_ side: MarkdownTabDropSide?) -> some View { + overlay(alignment: side == .after ? .trailing : .leading) { + if side == .before || side == .after { + Capsule() + .fill(Color.accentColor) + .frame(width: 3) + .padding(.vertical, 2) + .offset(x: side == .after ? 3 : -3) + .allowsHitTesting(false) + } + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabGrouping.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabGrouping.swift new file mode 100644 index 0000000000..bc5b42d366 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownTabGrouping.swift @@ -0,0 +1,248 @@ +// +// MarkdownTabGrouping.swift +// GenesisKit +// +// Groups the open tabs into named, collapsible groups (Martin, 2026-10-23: 72 tabs wrapped into nine rows). The engine +// is pure: tabs, a rule and the reader's own edits in, ordered groups out. Both hosts (GenesisTools' markdown face and +// Genesis Markdown.app) get it through `MarkdownDocumentsView`, and a host may call it for its own lists. +// + +import Foundation + +/// How tabs fall into groups by themselves. The reader's own moves and names win over every rule. +public enum MarkdownTabGroupRule: String, CaseIterable, Codable, Sendable { + /// One flat row of tabs. + case off + /// The top folder under the folder every open tab shares; a group past `splitAbove` tabs splits by its next folder. + case smart + /// The folder each file sits in. + case folder + /// The date a note's name starts with (`2026-10-23-…`): today, yesterday, this week, this month, earlier, undated. + case day + /// The file's kind: notes, data, logs, diffs. + case kind + + public var title: String { + switch self { + case .off: return "Off" + case .smart: return "Project" + case .folder: return "Folder" + case .day: return "Date in Name" + case .kind: return "Kind" + } + } +} + +/// One open tab as the engine sees it. +public struct MarkdownTabGroupItem: Equatable, Sendable { + public var id: String + public var path: String + public var title: String + + public init(id: String, path: String, title: String) { + self.id = id + self.path = path + self.title = title + } +} + +/// A group of tabs, in the order the tab bar shows them. +public struct MarkdownTabGroup: Identifiable, Equatable, Sendable { + public var id: String + public var title: String + /// Where the group is, for a title two folders share ("Notes" in two projects) and for the chip's tooltip. + public var detail: String? + public var tabIDs: [String] + /// Made by the reader (a new group, or tabs moved into it); never merged into "Other". + public var manual: Bool +} + +/// What the reader changed in place: group order, collapsed groups, names, and tabs moved by hand. +public struct MarkdownTabGroupState: Codable, Equatable, Sendable { + public var order: [String] = [] + public var collapsed: Set<String> = [] + public var names: [String: String] = [:] + /// Tab id to group id. A tab moved into a group stays there while that group exists under the current rule, or + /// always when the group is one the reader made (`manual:` ids). + public var assigned: [String: String] = [:] + + public init() {} +} + +public enum MarkdownTabGrouping { + public static let otherID = "other" + public static let manualPrefix = "manual:" + + /// The tabs as ordered groups. Empty for `.off`, which the tab bar shows as one flat row. + public static func groups( + _ items: [MarkdownTabGroupItem], rule: MarkdownTabGroupRule, state: MarkdownTabGroupState, + minimumSize: Int = 2, splitAbove: Int = 12, now: Date = Date(), calendar: Calendar = .current + ) -> [MarkdownTabGroup] { + guard rule != .off, !items.isEmpty else { return [] } + let automatic = keys(items, rule: rule, splitAbove: splitAbove, minimumSize: minimumSize, now: now, + calendar: calendar) + let automaticIDs = Set(automatic.values.map(\.id)) + var members: [String: [String]] = [:] + var info: [String: (title: String, detail: String?, manual: Bool)] = [:] + var firstSeen: [String] = [] + for item in items { + let key: (id: String, title: String, detail: String?, manual: Bool) + if let wanted = state.assigned[item.id], + wanted.hasPrefix(manualPrefix) || automaticIDs.contains(wanted) || wanted == otherID { + let known = automatic.values.first { $0.id == wanted } + key = (wanted, known?.title ?? (wanted == otherID ? "Other" : "Group"), known?.detail, + wanted.hasPrefix(manualPrefix)) + } else { + let auto = automatic[item.id] ?? (otherID, "Other", nil) + key = (auto.id, auto.title, auto.detail, false) + } + if members[key.id] == nil { firstSeen.append(key.id) } + members[key.id, default: []].append(item.id) + info[key.id] = (key.title, key.detail, key.manual || (info[key.id]?.manual ?? false)) + } + // A one-tab automatic group is noise in the bar: it joins "Other". The reader's own groups always stay. + for id in firstSeen where id != otherID { + guard let tabs = members[id], tabs.count < minimumSize, info[id]?.manual == false, + state.names[id] == nil, !tabs.contains(where: { state.assigned[$0] == id }) else { continue } + members[otherID, default: []].append(contentsOf: tabs) + members[id] = nil + if !firstSeen.contains(otherID) { firstSeen.append(otherID) } + info[otherID] = ("Other", nil, false) + } + let order = Dictionary(state.order.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first }) + let ids = firstSeen.filter { members[$0] != nil }.enumerated().sorted { left, right in + func rank(_ id: String, _ index: Int) -> (Int, Int) { + if let placed = order[id] { return (0, placed) } + return id == otherID ? (2, 0) : (1, index) + } + return rank(left.element, left.offset) < rank(right.element, right.offset) + }.map(\.element) + let position = Dictionary(items.enumerated().map { ($1.id, $0) }, uniquingKeysWith: { first, _ in first }) + return ids.map { id in + MarkdownTabGroup( + id: id, title: state.names[id] ?? info[id]?.title ?? "Group", detail: info[id]?.detail, + tabIDs: (members[id] ?? []).sorted { (position[$0] ?? 0) < (position[$1] ?? 0) }, + manual: info[id]?.manual ?? false) + } + } + + /// The order Next Tab and Previous Tab walk: the groups' order, tabs in a collapsed group skipped unless selected. + public static func visualOrder(_ groups: [MarkdownTabGroup], collapsed: Set<String>, selected: String?) -> [String] { + groups.flatMap { group in + collapsed.contains(group.id) ? group.tabIDs.filter { $0 == selected } : group.tabIDs + } + } + + // MARK: Automatic keys + + typealias Key = (id: String, title: String, detail: String?) + + static func keys( + _ items: [MarkdownTabGroupItem], rule: MarkdownTabGroupRule, splitAbove: Int, minimumSize: Int = 2, now: Date, + calendar: Calendar + ) -> [String: Key] { + switch rule { + case .off: return [:] + case .folder: + return Dictionary(items.map { item in + let folder = (item.path as NSString).deletingLastPathComponent + return (item.id, ("dir:" + folder, (folder as NSString).lastPathComponent, + parentName(folder))) + }, uniquingKeysWith: { first, _ in first }) + case .day: + return Dictionary(items.map { ($0.id, dayKey($0.title, now: now, calendar: calendar)) }, + uniquingKeysWith: { first, _ in first }) + case .kind: + return Dictionary(items.map { ($0.id, kindKey($0.path)) }, uniquingKeysWith: { first, _ in first }) + case .smart: + return smartKeys(items, splitAbove: splitAbove, minimumSize: minimumSize) + } + } + + /// Folders below the folder every tab shares: the first level names the group; a group larger than `splitAbove` + /// whose tabs sit in different folders one level deeper splits by that level, up to three times. + static func smartKeys(_ items: [MarkdownTabGroupItem], splitAbove: Int, minimumSize: Int = 2) -> [String: Key] { + let folders = items.map { item in + ((item.path as NSString).deletingLastPathComponent as NSString).pathComponents + } + var common = folders.first ?? [] + for components in folders.dropFirst() { + common = Array(zip(common, components).prefix { $0 == $1 }.map(\.0)) + } + // Depth of the component that names each tab's group; tabs right in the common folder stay at it. + var depth = Array(repeating: common.count, count: items.count) + func key(_ index: Int) -> String { + let components = folders[index] + return NSString.path(withComponents: Array(components.prefix(min(depth[index] + 1, components.count)))) + } + // A big group goes one level down when that level divides it into at least two real groups, or when the + // level holds one folder only (every note under `Vault/Notes/…`), which is passed through. A split into + // single tabs is refused: it would send the whole group to "Other" (72 vault notes did, 2026-10-23). A tab + // that sits higher than the level stays where it is. Bounded, as a path is. + for _ in 0 ..< 8 { + var byKey: [String: [Int]] = [:] + for index in items.indices { byKey[key(index), default: []].append(index) } + var moved = false + for (_, indexes) in byKey where indexes.count > splitAbove { + let deeper = indexes.filter { folders[$0].count > depth[$0] + 1 } + guard !deeper.isEmpty else { continue } + var sizes: [String: Int] = [:] + for index in deeper { sizes[folders[index][depth[index] + 1], default: 0] += 1 } + // A subfolder with tabs enough for a group of its own moves down; single tabs stay in the parent group. + let real = Set(sizes.filter { $0.value >= max(2, minimumSize) }.keys) + guard sizes.count == 1 || !real.isEmpty else { continue } + for index in deeper where sizes.count == 1 || real.contains(folders[index][depth[index] + 1]) { + depth[index] += 1 + } + moved = true + } + if !moved { break } + } + var result: [String: Key] = [:] + for (index, item) in items.enumerated() { + let path = key(index) + let components = (path as NSString).pathComponents + let title = components.last ?? path + let detail = components.count > 1 && depth[index] > common.count ? components[components.count - 2] : nil + result[item.id] = ("dir:" + path, title, detail) + } + return result + } + + static func dayKey(_ title: String, now: Date, calendar: Calendar) -> Key { + // The date in the name is a calendar day in the reader's calendar, not an instant in some other zone. + let parts = title.prefix(10).split(separator: "-").compactMap { Int($0) } + guard title.count >= 10, title.prefix(10).filter({ $0 == "-" }).count == 2, parts.count == 3, + let date = calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) else { + return ("day:none", "Undated", nil) + } + let today = calendar.startOfDay(for: now) + let day = calendar.startOfDay(for: date) + let days = calendar.dateComponents([.day], from: day, to: today).day ?? 0 + switch days { + case ...0: return ("day:today", "Today", nil) + case 1: return ("day:yesterday", "Yesterday", nil) + case 2 ..< 7: return ("day:week", "This Week", nil) + case 7 ..< 31: return ("day:month", "This Month", nil) + default: return ("day:earlier", "Earlier", nil) + } + } + + static func kindKey(_ path: String) -> Key { + switch (path as NSString).pathExtension.lowercased() { + case "md", "markdown", "mdx", "txt": return ("kind:notes", "Notes", nil) + case "json", "jsonl", "yaml", "yml", "toml", "plist": return ("kind:data", "Data", nil) + case "log": return ("kind:logs", "Logs", nil) + case "diff", "patch": return ("kind:diffs", "Diffs", nil) + case "": return ("kind:other", "No Extension", nil) + case let ext: return ("kind:" + ext, ext.uppercased(), nil) + } + } + + private static func parentName(_ folder: String) -> String? { + let parent = (folder as NSString).deletingLastPathComponent + let name = (parent as NSString).lastPathComponent + return name.isEmpty || name == "/" ? nil : name + } + +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift index d6a971ac3c..6aece4fc8c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Markdown/Window/MarkdownViewerApp.swift @@ -205,6 +205,9 @@ public enum MarkdownViewerMenu { setDefault.target = MarkdownDefaultAppDialog.shared appMenu.addItem(.separator()) } + let settings = appMenu.addItem(withTitle: "Settings…", action: #selector(MarkdownSettingsWindow.show(_:)), keyEquivalent: ",") + settings.target = MarkdownSettingsWindow.shared + appMenu.addItem(.separator()) appMenu.addItem(withTitle: "Hide \(appName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") let hideOthers = appMenu.addItem( withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h" @@ -216,17 +219,25 @@ public enum MarkdownViewerMenu { menu.addItem(submenu(appMenu, title: appName)) let file = NSMenu(title: "File") + command(file, "Open…", "markdown.file.open", keymap: keymap) // The files opened lately: an open one checked, a closed one with when it was closed (MarkdownRecents.swift). let recent = NSMenu(title: "Open Recent") recent.delegate = MarkdownRecentMenu.shared file.addItem(submenu(recent, title: "Open Recent")) file.addItem(.separator()) keyed(file, "Close Tab", #selector(MarkdownDocumentWindowController.closeSelectedTab(_:)), "markdown.tab.close", keymap: keymap) + command(file, "Close Other Tabs", "markdown.tab.closeOthers", keymap: keymap) keyed(file, "Close Window", #selector(NSWindow.performClose(_:)), "markdown.window.close", keymap: keymap) file.addItem(.separator()) keyed(file, "Go to Location…", #selector(MarkdownDocumentWindowController.goToLocation(_:)), "markdown.file.goto", keymap: keymap) file.addItem(.separator()) keyed(file, "Save", #selector(MarkdownDocumentWindowController.saveSelected(_:)), "markdown.file.save", keymap: keymap) + command(file, "Reload from Disk", "markdown.file.reload", keymap: keymap) + file.addItem(.separator()) + command(file, "Save as PDF…", "markdown.file.pdf", keymap: keymap) + command(file, "Print…", "markdown.file.print", keymap: keymap) + file.addItem(.separator()) + command(file, "Reveal in Finder", "markdown.file.reveal", keymap: keymap) keyed(file, "Copy Path", #selector(MarkdownDocumentWindowController.copyPath(_:)), "markdown.file.copyPath", keymap: keymap) menu.addItem(submenu(file, title: "File")) @@ -239,8 +250,24 @@ public enum MarkdownViewerMenu { edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + edit.addItem(.separator()) + command(edit, "Find…", "markdown.find.open", keymap: keymap) + command(edit, "Find in Files…", "markdown.find.files", keymap: keymap) menu.addItem(submenu(edit, title: "Edit")) + // Every keyed command has a menu item, so the menu bar lists what the keys do (UX audit W5). + let view = NSMenu(title: "View") + command(view, "Show or Hide Files", "markdown.view.files", keymap: keymap) + command(view, "Show or Hide Outline", "markdown.view.outline", keymap: keymap) + view.addItem(.separator()) + command(view, "Reading View or Source", "markdown.view.source", keymap: keymap) + command(view, "Editor and Preview Side by Side", "markdown.view.split", keymap: keymap) + view.addItem(.separator()) + command(view, "Bigger Text", "markdown.view.bigger", keymap: keymap) + command(view, "Smaller Text", "markdown.view.smaller", keymap: keymap) + command(view, "Actual Size", "markdown.view.actualSize", keymap: keymap) + menu.addItem(submenu(view, title: "View")) + let go = NSMenu(title: "Go") keyed(go, "Back", #selector(MarkdownDocumentWindowController.goBack(_:)), "markdown.history.back", keymap: keymap) keyed(go, "Forward", #selector(MarkdownDocumentWindowController.goForward(_:)), "markdown.history.forward", keymap: keymap) @@ -248,6 +275,10 @@ public enum MarkdownViewerMenu { keyed(go, "Next Tab", #selector(MarkdownDocumentWindowController.nextTab(_:)), "markdown.tab.next", keymap: keymap) keyed(go, "Previous Tab", #selector(MarkdownDocumentWindowController.previousTab(_:)), "markdown.tab.previous", keymap: keymap) keyed(go, "Reopen Closed Tab", #selector(MarkdownDocumentWindowController.reopenClosedTab(_:)), "markdown.tab.reopen", keymap: keymap) + go.addItem(.separator()) + for number in 1...9 { + command(go, number == 9 ? "Last Tab" : "Tab \(number)", MarkdownKeymap.tabNumberCommand(number), keymap: keymap) + } menu.addItem(submenu(go, title: "Go")) let window = NSMenu(title: "Window") @@ -271,6 +302,15 @@ public enum MarkdownViewerMenu { return item } + /// An item that runs a window command (`MarkdownKeymap.windowActions`) through the window controller, with the + /// command's key in force. + @discardableResult + private static func command(_ menu: NSMenu, _ title: String, _ command: String, keymap: Keymap) -> NSMenuItem { + let item = keyed(menu, title, #selector(MarkdownDocumentWindowController.performCommand(_:)), command, keymap: keymap) + item.representedObject = command + return item + } + /// A top-level menu by title. macOS adds its window items to the app's `windowsMenu` and its search field to /// `helpMenu`, so each is named, not taken by position (Help comes after Window). static func submenu(_ title: String, in menu: NSMenu) -> NSMenu? { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Navigation/NavigationHistory.swift b/src/macos/GenesisKit/Sources/GenesisKit/Navigation/NavigationHistory.swift new file mode 100644 index 0000000000..b847fcdb9e --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Navigation/NavigationHistory.swift @@ -0,0 +1,304 @@ +import Foundation + +// A browser's history plus a log: every place a window showed, who took it there (the person, an agent, a link) and +// why, with back and forward that walk it and a hop to any entry. The host owns the target type (the hub's is its +// link query) and the storage (`NavigationHistoryStore`); this type only decides the order. + +/// Who moved the window to a place. +public enum NavigationActor: String, Codable, CaseIterable, Sendable { + /// A click, a key or a menu in the window itself. + case user + /// An agent that asked for it (`tools hub nav open --actor agent`), usually with a note. + case agent + /// A link from outside the window (a browser, a notification, `tools hub open`) that named no actor. + case link + + public var title: String { + switch self { + case .user: return "User" + case .agent: return "Agent" + case .link: return "Link" + } + } +} + +/// One place in the log. +public struct NavigationEntry<Target: Codable & Hashable>: Identifiable, Codable, Hashable { + public var id: String + public var at: Date + public var target: Target + /// What the place is, for the arrows' tooltips and the history list ("PR #563 Fix the login"). + public var title: String + public var actor: NavigationActor + /// The agent session that asked for it, when an agent did. + public var agentSession: String? + /// Why the agent opened it ("the commit that broke the build"). + public var note: String? + + public init( + id: String = UUID().uuidString, at: Date = Date(), target: Target, title: String, + actor: NavigationActor = .user, agentSession: String? = nil, note: String? = nil + ) { + self.id = id + self.at = at + self.target = target + self.title = title + self.actor = actor + self.agentSession = agentSession + self.note = note + } +} + +extension NavigationEntry: Sendable where Target: Sendable {} + +/// Where a host keeps the log. `load` runs once at start, `record` after each new or changed entry; both may be +/// slow (a `tools` call), so the host runs them off the main thread. +public protocol NavigationHistoryStore<Target>: AnyObject { + associatedtype Target: Codable & Hashable + /// Every kept entry, in any order. + func load() throws -> [NavigationEntry<Target>] + /// Adds the entry, or replaces the one with its id, and keeps at most `keep` entries (the newest). + func record(_ entry: NavigationEntry<Target>, keep: Int) throws +} + +/// The log and the cursor in it. A value type with no clock and no I/O, so tests drive it. +/// +/// - `visit` adds an entry and moves to it; the places ahead of the cursor leave the forward stack, as in a +/// browser, but stay in the log, so a hop still reaches them. Nothing is ever replaced. +/// - `goBack` / `goForward` move the cursor; `hop(to:)` moves it to any entry. +/// - A provisional place (a mode whose list still loads) is shown but never kept: the next visit takes its +/// spot, so the history holds no stop with nothing in it. +public struct NavigationHistory<Target: Codable & Hashable>: Equatable { + public typealias Entry = NavigationEntry<Target> + + public static var defaultLimit: Int { 200 } + + /// Oldest first. + public private(set) var entries: [Entry] = [] + /// Ids, oldest first; the last is the place Back goes to. + public private(set) var backIDs: [String] = [] + /// Ids, farthest first; the last is the place Forward goes to. + public private(set) var forwardIDs: [String] = [] + public private(set) var currentID: String? + /// The place on screen is not in the log (see `visit(…provisional:)`). + public private(set) var currentTarget: Target? + /// How many entries the log keeps; the oldest go first. + public private(set) var limit: Int + + public init(limit: Int = Self.defaultLimit) { + self.limit = max(1, limit) + } + + /// A log read back from the store: every entry is behind the cursor, the newest is the current place. + public init(entries: [Entry], limit: Int = Self.defaultLimit) { + self.limit = max(1, limit) + let sorted = entries.sorted { $0.at < $1.at } + self.entries = Array(sorted.suffix(self.limit)) + backIDs = self.entries.dropLast().map(\.id) + currentID = self.entries.last?.id + currentTarget = self.entries.last?.target + } + + public var canGoBack: Bool { !backIDs.isEmpty } + public var canGoForward: Bool { !forwardIDs.isEmpty } + public var current: Entry? { currentID.flatMap(entry) } + public var backEntry: Entry? { backIDs.last.flatMap(entry) } + public var forwardEntry: Entry? { forwardIDs.last.flatMap(entry) } + /// Newest first, for a list. + public var newestFirst: [Entry] { entries.reversed() } + + public func entry(_ id: String) -> Entry? { + entries.last { $0.id == id } + } + + /// Where an entry sits relative to the cursor: negative behind it (−1 is Back), positive ahead, 0 for the + /// current place, nil for an entry that only the log holds (left by a visit after going back). + public func offset(of id: String) -> Int? { + if id == currentID { return 0 } + if let index = backIDs.lastIndex(of: id) { return index - backIDs.count } + if let index = forwardIDs.lastIndex(of: id) { return forwardIDs.count - index } + return nil + } + + /// A new place on screen. Returns the entry the store must write: the new one, an existing one an agent + /// annotated, or nil when nothing changed (the same place again, a provisional place, a return). + /// + /// - The same place as the current one: a user's visit is nothing; an agent's or a link's replaces the current + /// entry's who, note and id in place, so an agent opening what is already shown still leaves its note. + /// - After a provisional place: the new place takes its spot; when it is the place right behind, the cursor + /// goes back to it rather than logging a copy ("out to an empty mode and straight back"). + @discardableResult + public mutating func visit( + _ target: Target, title: String, actor: NavigationActor = .user, agentSession: String? = nil, + note: String? = nil, id: String? = nil, at: Date = Date(), provisional: Bool = false + ) -> Entry? { + if target == currentTarget, currentID != nil || provisional { + guard !provisional, let currentID, actor != .user, let index = entries.lastIndex(where: { $0.id == currentID }) + else { return nil } + var annotated = entries[index] + annotated.actor = actor + annotated.agentSession = agentSession + annotated.note = note + annotated.at = at + if !title.isEmpty { annotated.title = title } + if let id, id != currentID { + // The id the agent's CLI already stored, so the store keeps one row for it. + annotated.id = id + self.currentID = id + } + entries.remove(at: index) + entries.append(annotated) + return annotated + } + let wasProvisional = currentID == nil && currentTarget != nil + if provisional { + if !wasProvisional, let currentID { + backIDs.append(currentID) + forwardIDs.removeAll() + } + self.currentID = nil + currentTarget = target + trim() + return nil + } + if wasProvisional, let last = backIDs.last, entry(last)?.target == target { + backIDs.removeLast() + currentID = last + currentTarget = target + return nil + } + let made = Entry(id: id ?? UUID().uuidString, at: at, target: target, title: title, actor: actor, + agentSession: agentSession, note: note) + if let currentID { + backIDs.append(currentID) + } + forwardIDs.removeAll() + entries.removeAll { $0.id == made.id } + entries.append(made) + currentID = made.id + currentTarget = target + trim() + return made + } + + /// Adds an entry to the log without moving to it: an agent's place while agents may only add to the list. + @discardableResult + public mutating func record( + _ target: Target, title: String, actor: NavigationActor, agentSession: String? = nil, note: String? = nil, + id: String? = nil, at: Date = Date() + ) -> Entry { + let made = Entry(id: id ?? UUID().uuidString, at: at, target: target, title: title, actor: actor, + agentSession: agentSession, note: note) + entries.removeAll { $0.id == made.id } + entries.append(made) + trim() + return made + } + + public mutating func goBack() -> Entry? { + guard let previous = backIDs.popLast() else { return nil } + if let currentID { + forwardIDs.append(currentID) + } + return move(to: previous) + } + + public mutating func goForward() -> Entry? { + guard let next = forwardIDs.popLast() else { return nil } + if let currentID { + backIDs.append(currentID) + } + return move(to: next) + } + + /// Moves to any entry. One behind or ahead keeps the stacks a browser would (the places passed over stay on + /// the other side); an entry only the log holds goes on top, as a visit does, without a new entry. + public mutating func hop(to id: String) -> Entry? { + guard id != currentID, entry(id) != nil else { return nil } + if let index = backIDs.lastIndex(of: id) { + let passed = Array(backIDs[(index + 1)...]) + (currentID.map { [$0] } ?? []) + forwardIDs.append(contentsOf: passed.reversed()) + backIDs.removeSubrange(index...) + } else if let index = forwardIDs.lastIndex(of: id) { + let passed = (currentID.map { [$0] } ?? []) + forwardIDs[(index + 1)...].reversed() + backIDs.append(contentsOf: passed) + forwardIDs.removeSubrange(index...) + } else { + if let currentID { + backIDs.append(currentID) + } + forwardIDs.removeAll() + } + return move(to: id) + } + + /// Changes the cap; the oldest entries beyond it go. + public mutating func setLimit(_ limit: Int) { + self.limit = max(1, limit) + trim() + } + + /// Gives an entry a better title once its place has loaded (a PR's title arrives after its number). + public mutating func retitle(_ id: String, _ title: String) -> Entry? { + guard !title.isEmpty, let index = entries.lastIndex(where: { $0.id == id }), entries[index].title != title + else { return nil } + entries[index].title = title + return entries[index] + } + + /// Gives an entry the place its target turned out to be once shown (an agent's `pr=563` becomes the hub's own + /// form of that PR), so the place on screen and its entry stay equal and the next look at it adds nothing. + public mutating func retarget(_ id: String, to target: Target, title: String? = nil) -> Entry? { + guard let index = entries.lastIndex(where: { $0.id == id }) else { return nil } + let unchanged = entries[index].target == target && (title.map { $0.isEmpty || $0 == entries[index].title } ?? true) + guard !unchanged else { return nil } + entries[index].target = target + if let title, !title.isEmpty { entries[index].title = title } + if id == currentID { currentTarget = target } + return entries[index] + } + + /// Puts a log read back from the store behind what this window already did since it opened: entries it does + /// not hold yet, oldest first, become the oldest places Back reaches. When the log ends on the place this window + /// opened on (a relaunch on the place it left), the two are one entry, the log's: its id, who and note. Returns + /// the ids of this window's entries that merged away, which the host must not store. + @discardableResult + public mutating func adoptOlder(_ older: [Entry]) -> [String] { + let known = Set(entries.map(\.id)) + var fresh = older.filter { !known.contains($0.id) }.sorted { $0.at < $1.at } + guard !fresh.isEmpty else { return [] } + var merged: [String] = [] + if let newest = fresh.last, let oldest = entries.first, newest.target == oldest.target { + fresh.removeLast() + merged.append(oldest.id) + entries[0] = newest + backIDs = backIDs.map { $0 == oldest.id ? newest.id : $0 } + forwardIDs = forwardIDs.map { $0 == oldest.id ? newest.id : $0 } + if currentID == oldest.id { currentID = newest.id } + } + entries = fresh + entries + backIDs = fresh.map(\.id) + backIDs + if currentID == nil, currentTarget == nil, let last = backIDs.popLast() { + currentID = last + currentTarget = entry(last)?.target + } + trim() + return merged + } + + private mutating func move(to id: String) -> Entry? { + currentID = id + let landed = entry(id) + currentTarget = landed?.target + return landed + } + + private mutating func trim() { + guard entries.count > limit else { return } + let keep = currentID + let dropped = Set(entries.map(\.id).filter { $0 != keep }.prefix(entries.count - limit)) + entries.removeAll { dropped.contains($0.id) } + backIDs.removeAll { dropped.contains($0) } + forwardIDs.removeAll { dropped.contains($0) } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Navigation/NavigationHistoryList.swift b/src/macos/GenesisKit/Sources/GenesisKit/Navigation/NavigationHistoryList.swift new file mode 100644 index 0000000000..770923ea53 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Navigation/NavigationHistoryList.swift @@ -0,0 +1,166 @@ +import SwiftUI + +/// The history as a list, newest first: who went there (User, Agent, Link), the place, the agent's note, when, and +/// where it sits from the cursor. A click hops there (`NavigationHistory.hop(to:)`). Rows take values only, so the +/// list costs nothing while the host's model changes. +public struct NavigationHistoryList<Target: Codable & Hashable>: View { + let history: NavigationHistory<Target> + var maxHeight: CGFloat + let onHop: (String) -> Void + + public init(history: NavigationHistory<Target>, maxHeight: CGFloat = 420, onHop: @escaping (String) -> Void) { + self.history = history + self.maxHeight = maxHeight + self.onHop = onHop + } + + public var body: some View { + let rows = history.newestFirst + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Text("History").kicker() + Spacer(minLength: 8) + Text(verbatim: Plural.count(rows.count, "place")) + .font(.system(size: 10.5).monospacedDigit()) + .foregroundColor(KitPalette.faint) + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 6) + if rows.isEmpty { + EmptyState(symbol: "clock.arrow.circlepath", text: "No places yet", + detail: "Every session, worktree, PR or commit the hub shows lands here.") + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } else { + FittedScroll(maxHeight: maxHeight) { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(rows) { entry in + NavigationHistoryRow(entry: NavigationHistoryRowValue(entry, offset: history.offset(of: entry.id)), + onHop: onHop) + } + } + .padding(.horizontal, 6) + .padding(.bottom, 6) + } + } + } + } +} + +/// What one row shows; equal values draw the same. +struct NavigationHistoryRowValue: Equatable { + let id: String + let title: String + let actor: NavigationActor + let note: String? + let agentSession: String? + let at: Date + /// 0 for the place on screen, negative behind it, positive ahead, nil for a place only the log holds. + let offset: Int? + + init<Target>(_ entry: NavigationEntry<Target>, offset: Int?) { + id = entry.id + title = entry.title + actor = entry.actor + note = entry.note.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } + agentSession = entry.agentSession + at = entry.at + self.offset = offset + } + + var isCurrent: Bool { offset == 0 } + + /// "Back 2", "Forward 1", or nil for the current place and a place only the log holds. + var position: String? { + guard let offset, offset != 0 else { return nil } + return offset < 0 ? "Back \(-offset)" : "Forward \(offset)" + } + + var accessibilityText: String { + var parts = ["\(actor.title): \(title)"] + if let note { parts.append(note) } + if isCurrent { parts.append("shown now") } else if let position { parts.append(position) } + return parts.joined(separator: ", ") + } +} + +extension NavigationActor { + /// The badge colour: the person is quiet, an agent stands out, a link sits between. + var tint: Color { + switch self { + case .user: return KitPalette.dim + case .agent: return Color(red: 0.74, green: 0.58, blue: 1.0) + case .link: return KitPalette.renamed + } + } + + var symbol: String { + switch self { + case .user: return "person.fill" + case .agent: return "sparkles" + case .link: return "link" + } + } +} + +struct NavigationHistoryRow: View, Equatable { + let entry: NavigationHistoryRowValue + let onHop: (String) -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { lhs.entry == rhs.entry } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(entry.isCurrent ? KitTheme.accent : Color.clear) + .overlay(Circle().stroke(entry.isCurrent ? Color.clear : KitPalette.faint, lineWidth: 1)) + .frame(width: 7, height: 7) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Badge(entry.actor.title, color: entry.actor.tint, look: entry.actor == .user ? .tag : .tone, + symbol: entry.actor.symbol) + Text(verbatim: entry.title) + .font(.system(size: 12, weight: entry.isCurrent ? .semibold : .regular)) + .foregroundColor(KitPalette.text) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 6) + LiveAgo(date: entry.at, style: .brief) + .font(.system(size: 10.5).monospacedDigit()) + .foregroundColor(KitPalette.faint) + .fixedSize() + } + if let note = entry.note { + Text(verbatim: note) + .font(.system(size: 11)) + .foregroundColor(KitPalette.dim) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + if entry.isCurrent || entry.position != nil || entry.agentSession != nil { + HStack(spacing: 6) { + if entry.isCurrent { + Text("Shown now").font(.system(size: 10, weight: .medium)).foregroundColor(KitTheme.accent) + } else if let position = entry.position { + Text(verbatim: position).font(.system(size: 10)).foregroundColor(KitPalette.faint) + } + if let session = entry.agentSession { + Text(verbatim: "session \(session.prefix(8))") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(KitPalette.faint) + } + } + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .rowButton(cornerRadius: 6) { onHop(entry.id) } + .disabled(entry.isCurrent) + .accessibilityLabel(Text(verbatim: entry.accessibilityText)) + .accessibilityHint(entry.isCurrent ? Text("") : Text("Opens this place")) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift new file mode 100644 index 0000000000..ce6054df4d --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMark.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// A coding agent's logo as a shape: Claude's starburst, Codex's terminal cloud, Grok's slashed ring. Every surface +/// that names an agent by its mark draws it through this (`AIProviderGlyph`, `ProviderBadge`), so a change lands +/// everywhere at once. Paths from LobeHub's icon set (MIT, `@lobehub/icons-static-svg`), 24 × 24, even-odd fill. +public struct AIProviderMark: Shape { + public let kind: Kind + + public enum Kind: String, CaseIterable, Sendable { + case claude, codex, grok + + /// The mark for a provider id or alias (`claude`, `anthropic-sub`, `codex`, `openai-sub`, `grok`, `grok-sub`); + /// nil for one with no mark, which keeps its letter. + public init?(provider: String) { + switch provider.lowercased() { + case "claude", "anthropic", "anthropic-sub": self = .claude + case "codex", "openai", "openai-sub": self = .codex + case "grok", "xai", "grok-sub": self = .grok + default: return nil + } + } + + /// The brand colour of the mark itself (Claude's terracotta; Codex and Grok are drawn in white). + public var brandColor: Color { + switch self { + case .claude: return Color(red: 0.851, green: 0.467, blue: 0.341) + case .codex, .grok: return Color.white.opacity(0.92) + } + } + } + + public init(_ kind: Kind) { + self.kind = kind + } + + public func path(in rect: CGRect) -> Path { + let unit = Self.paths[kind] ?? Path() + let scale = min(rect.width, rect.height) / 24 + let dx = rect.minX + (rect.width - 24 * scale) / 2 + let dy = rect.minY + (rect.height - 24 * scale) / 2 + return unit.applying(CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: dx, ty: dy)) + } + + /// Parsed once: these paths are drawn in every session row. + static let paths: [Kind: Path] = [ + .claude: SVGPath.parse( + "M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"), + .codex: SVGPath.parse( + "M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"), + .grok: SVGPath.parse( + "M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"), + ] +} + +/// How a provider glyph is drawn. +public enum AIProviderGlyphStyle: Sendable { + /// The mark in its provider colour on a tinted rounded square: row leaders, rails. + case tinted + /// The mark alone, in its provider colour: inline in text and tight spaces. + case plain + /// The mark in dark on a solid provider-coloured square (`ProviderBadge`). + case filled +} + +/// One provider glyph in any style; a provider with no mark keeps its letter. +struct AIProviderGlyphBody: View { + let provider: String + let letter: String + let color: Color + let size: CGFloat + let style: AIProviderGlyphStyle + + var body: some View { + let kind = AIProviderMark.Kind(provider: provider) + let inset = style == .plain ? 0 : size * 0.18 + ZStack { + switch style { + case .tinted: + RoundedRectangle(cornerRadius: size / 4).fill(color.opacity(0.18)) + case .filled: + RoundedRectangle(cornerRadius: size * 0.28).fill(color) + case .plain: + EmptyView() + } + if let kind { + AIProviderMark(kind) + .fill(style == .filled ? Color.black.opacity(0.8) : color, style: FillStyle(eoFill: true)) + .padding(inset) + } else { + Text(verbatim: letter) + .font(.system(size: size * 0.6, weight: .bold, design: .rounded)) + .foregroundStyle(style == .filled ? Color.black.opacity(0.8) : color) + } + } + .frame(width: size, height: size) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift index f76b8b309b..641e8ea70b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Providers/AIProviderMeta.swift @@ -51,23 +51,22 @@ public enum AIProviders { } } -/// The provider's one-letter glyph in its tinted box: the leader of an account row, and -/// the marker on a session row that is not Claude's. +/// The provider's mark (`AIProviderMark`) in its tinted box: the leader of an account row, and the marker on a session +/// row. A provider with no mark keeps its letter. `style` picks the tinted box, the bare mark or a solid badge. public struct AIProviderGlyph: View { public var meta: AIProviderMeta public var size: CGFloat + public var style: AIProviderGlyphStyle - public init(meta: AIProviderMeta, size: CGFloat = 16) { + public init(meta: AIProviderMeta, size: CGFloat = 16, style: AIProviderGlyphStyle = .tinted) { self.meta = meta self.size = size + self.style = style } public var body: some View { - Text(meta.glyph) - .font(.system(size: size * 0.625, weight: .bold, design: .monospaced)) - .frame(width: size, height: size) - .background(meta.color.opacity(0.18), in: RoundedRectangle(cornerRadius: size / 4)) - .foregroundStyle(meta.color) + AIProviderGlyphBody(provider: meta.alias, letter: meta.glyph, color: meta.color, size: size, style: style) + .accessibilityElement() .accessibilityLabel(meta.displayName) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift new file mode 100644 index 0000000000..a15ec22f3f --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift @@ -0,0 +1,426 @@ +import AppKit +import SwiftUI + +/// What the recording controls show. A value, so a host (or a test) can draw any moment of a recording. +public struct RecordingControlsState: Equatable { + public var phase: RecordingPhase + public var elapsedMs: Double + public var limitMs: Double + public var target: RecordingTarget? + /// `user`, `limit` or `recorder` while paused. + public var pauseReason: String? + public var notice: String? + /// Why the start takes long: the recorder compiles after an update, or no first frame came yet. + public var preparing: String? + /// The host's name for stopping and keeping the movie: "Stop and paste", "Stop and save". + public var useTitle: String + public var useSymbol: String + /// Discard and Restart ask once inside the controls first (`RecordingSettings.confirmsDiscard`). + public var confirmsDiscard: Bool + + public init(phase: RecordingPhase, elapsedMs: Double = 0, limitMs: Double = 600_000, target: RecordingTarget? = nil, + pauseReason: String? = nil, notice: String? = nil, preparing: String? = nil, useTitle: String, + useSymbol: String = "stop.fill", confirmsDiscard: Bool = true) { + self.phase = phase + self.elapsedMs = elapsedMs + self.limitMs = limitMs + self.target = target + self.pauseReason = pauseReason + self.notice = notice + self.preparing = preparing + self.useTitle = useTitle + self.useSymbol = useSymbol + self.confirmsDiscard = confirmsDiscard + } +} + +/// What the recording controls do. Each closure is one of Martin's actions: pause or resume, switch what is recorded, +/// restart, stop and use the movie, stop and discard it. +public struct RecordingControlsActions { + public var pause: () -> Void + public var resume: () -> Void + public var switchTarget: () -> Void + public var restart: () -> Void + public var use: () -> Void + public var discard: () -> Void + public var dismissNotice: () -> Void + /// The person dragged the controls somewhere else. + public var moved: () -> Void + + public init(pause: @escaping () -> Void, resume: @escaping () -> Void, switchTarget: @escaping () -> Void, + restart: @escaping () -> Void, use: @escaping () -> Void, discard: @escaping () -> Void, + dismissNotice: @escaping () -> Void = {}, moved: @escaping () -> Void = {}) { + self.pause = pause + self.resume = resume + self.switchTarget = switchTarget + self.restart = restart + self.use = use + self.discard = discard + self.dismissNotice = dismissNotice + self.moved = moved + } +} + +/// A recording's elapsed time as `m:ss`, or `h:mm:ss` from an hour on. +public enum RecordingClock { + public static func format(_ milliseconds: Double) -> String { + let total = max(0, Int(milliseconds / 1000)) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let seconds = total % 60 + return hours > 0 + ? String(format: "%d:%02d:%02d", hours, minutes, seconds) + : String(format: "%d:%02d", minutes, seconds) + } +} + +/// The controls' state as something to observe, so a host sets the view up once and only the values change (its +/// own state, such as an open discard question, survives the clock's ticks). +@MainActor +final class RecordingControlsModel: ObservableObject { + @Published var state: RecordingControlsState + + init(state: RecordingControlsState) { + self.state = state + } +} + +struct RecordingControlsHost: View { + @ObservedObject var model: RecordingControlsModel + let actions: RecordingControlsActions + + var body: some View { + RecordingControlsView(state: model.state, actions: actions) + } +} + +/// The icon of a running app by pid, looked up once per pid: the controls redraw every second while recording. +@MainActor +enum RecordingAppIcon { + private static var cache: [Int32: NSImage] = [:] + + static func image(pid: Int32?) -> NSImage? { + guard let pid, pid > 0 else { return nil } + if let cached = cache[pid] { return cached } + guard let icon = NSRunningApplication(processIdentifier: pid)?.icon else { return nil } + cache[pid] = icon + return icon + } +} + +/// The small floating controls of a running recording: the recorded time with a red dot, what is recorded (a click +/// switches it), pause or resume, restart, the host's "stop and use" action and stop and discard. The leading part +/// (grip, dot and time) moves the window. Generic: the host names the use action and decides what it does. +public struct RecordingControlsView: View { + private let state: RecordingControlsState + private let actions: RecordingControlsActions + @State private var confirming: Confirmation? + @GenReduceMotion private var reduceMotion + + private enum Confirmation: Equatable { case discard, restart } + + public init(state: RecordingControlsState, actions: RecordingControlsActions) { + self.state = state + self.actions = actions + } + + private var busy: Bool { state.phase == .finishing || !state.phase.isActive } + private var paused: Bool { state.phase == .paused } + /// Under a minute left before the length limit, the clock says so. + private var remainingMs: Double? { + let left = state.limitMs - state.elapsedMs + return state.phase == .recording && left < 60_000 ? max(0, left) : nil + } + + public var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + status + if let confirming { + confirmation(confirming) + .transition(.opacity) + } else { + targetChip + controls + .transition(.opacity) + } + } + if state.phase == .starting, let preparing = state.preparing { + preparingRow(preparing) + } + if let notice = state.notice { + noticeRow(notice) + } + } + .padding(.leading, 6) + .padding(.trailing, 8) + .padding(.vertical, 6) + .frame(minHeight: 44) + .modifier(NativeGlassShapeSurface(shape: RoundedRectangle(cornerRadius: 14, style: .continuous))) + .environment(\.colorScheme, .dark) + .animation(reduceMotion ? nil : .easeOut(duration: 0.16), value: confirming) + .onChange(of: state.phase) { old, phase in + if !phase.isActive || phase == .finishing { confirming = nil } + announce(from: old, to: phase) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Recording controls") + } + + /// Phases change by themselves (the recorder starts late, the limit pauses), so VoiceOver is told. + private func announce(from old: RecordingPhase, to phase: RecordingPhase) { + switch phase { + case .recording: + GenAnnounce.post(old == .paused ? "Recording resumed" : "Recording started") + case .paused: + GenAnnounce.post(state.pauseReason == "limit" ? "Recording paused, the length limit is reached" : "Recording paused") + case .finishing: + GenAnnounce.post("Recording stopped, saving") + default: + break + } + } + + // MARK: Status (the drag area) + + private var status: some View { + HStack(spacing: 7) { + RecordingGrip() + indicator + VStack(alignment: .leading, spacing: 0) { + Text(verbatim: RecordingClock.format(state.elapsedMs)) + .font(.system(size: 13, weight: .semibold).monospacedDigit()) + .foregroundStyle(paused ? .secondary : .primary) + if let caption = statusCaption { + Text(caption).font(.system(size: 11, weight: .medium)) + .foregroundStyle(captionColor) + .lineLimit(1) + } + } + .frame(minWidth: 48, alignment: .leading) + } + .padding(.leading, 4) + .padding(.trailing, 2) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .allowsHitTesting(false) + .background(RecordingWindowDragArea(dragged: actions.moved)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityStatus) + .frameProbe("recording.status") + } + + @ViewBuilder private var indicator: some View { + switch state.phase { + case .starting, .finishing: + ProgressView().controlSize(.mini).frame(width: 12, height: 12) + case .paused: + Image(systemName: "pause.circle.fill").font(.system(size: 12)).foregroundStyle(.orange) + case .recording: + Circle().fill(Color.red).frame(width: 9, height: 9) + .shadow(color: .red.opacity(0.7), radius: 3) + default: + Circle().fill(Color.secondary).frame(width: 9, height: 9) + } + } + + private var statusCaption: String? { + switch state.phase { + case .starting: return "Starting" + case .finishing: return "Saving" + case .paused: + return state.pauseReason == "limit" ? "Limit reached" : "Paused" + case .recording: + return remainingMs.map { "\(RecordingClock.format($0)) left" } + default: + return nil + } + } + + private var captionColor: Color { + state.phase == .paused || remainingMs != nil ? .orange : SessionPalette.secondary + } + + private var accessibilityStatus: String { + let time = RecordingClock.format(state.elapsedMs) + switch state.phase { + case .starting: return state.preparing.map { "Starting the recording. \($0)" } ?? "Starting the recording" + case .recording: return "Recording, \(time)" + case .paused: return "Paused at \(time)" + case .finishing: return "Saving the recording" + default: return "Recording ended" + } + } + + // MARK: Target + + private var targetChip: some View { + Button(action: actions.switchTarget) { + HStack(spacing: 6) { + if let icon = RecordingAppIcon.image(pid: state.target?.pid) { + Image(nsImage: icon).resizable().interpolation(.high).frame(width: 16, height: 16) + } else { + Image(systemName: state.target?.symbol ?? "rectangle.dashed").font(.system(size: 11)) + .frame(width: 16, height: 16) + } + Text(state.target?.title ?? "Choose what to record") + .font(.system(size: 12, weight: .medium)) + .lineLimit(1).truncationMode(.middle) + .frame(maxWidth: 180, alignment: .leading) + Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)).foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.genHoverPlain()) + .disabled(busy) + .instantTooltip("Record something else") + .accessibilityLabel("Recording \(state.target?.title ?? "nothing"). Switch what is recorded") + .accessibilityIdentifier("recording.switch") + .frameProbe("recording.switch") + } + + // MARK: Controls + + private var controls: some View { + HStack(spacing: 6) { + Group { + if paused { + IconButton(systemName: "play.fill", tooltip: state.pauseReason == "limit" ? "The length limit is reached" : "Resume", + size: 13, action: actions.resume) + .disabled(state.pauseReason == "limit") + .accessibilityIdentifier("recording.resume") + .frameProbe("recording.resume") + } else { + IconButton(systemName: "pause.fill", tooltip: "Pause", size: 13, action: actions.pause) + .disabled(busy) + .accessibilityIdentifier("recording.pause") + .frameProbe("recording.pause") + } + } + IconButton(systemName: "arrow.counterclockwise", tooltip: "Restart: discard what was recorded and start again", + size: 13) { + ask(.restart, then: actions.restart) + } + .disabled(busy) + .accessibilityIdentifier("recording.restart") + .frameProbe("recording.restart") + Rectangle().fill(.white.opacity(0.14)).frame(width: 1, height: 20).padding(.horizontal, 2) + Button(action: actions.use) { + HStack(spacing: 5) { + Image(systemName: state.useSymbol).font(.system(size: 10, weight: .bold)) + Text(state.useTitle).font(.system(size: 12, weight: .semibold)).lineLimit(1) + } + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.red.opacity(busy ? 0.4 : 0.88), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.genHoverPlain(brighten: 0.12)) + .disabled(busy) + .fixedSize() + .accessibilityLabel(state.useTitle) + .accessibilityIdentifier("recording.use") + .frameProbe("recording.use") + IconButton(systemName: "trash", tooltip: "Discard the recording", size: 13) { + ask(.discard, then: actions.discard) + } + .disabled(busy) + .accessibilityIdentifier("recording.discard") + .frameProbe("recording.discard") + } + // While the movie is saved nothing can be pressed, and it shows. + .opacity(busy ? 0.45 : 1) + } + + private func ask(_ confirmation: Confirmation, then action: @escaping () -> Void) { + // Nothing recorded yet means nothing to lose. + if state.confirmsDiscard && state.elapsedMs >= 1000 { + confirming = confirmation + } else { + action() + } + } + + private func confirmation(_ kind: Confirmation) -> some View { + HStack(spacing: 8) { + Text(kind == .discard + ? "Discard \(RecordingClock.format(state.elapsedMs)) of recording?" + : "Restart and discard \(RecordingClock.format(state.elapsedMs))?") + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .fixedSize() + Button { + confirming = nil + kind == .discard ? actions.discard() : actions.restart() + } label: { + Text(kind == .discard ? "Discard" : "Restart") + .font(.system(size: 12, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Color.red.opacity(0.88), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.genHoverPlain(brighten: 0.12)) + .fixedSize() + .accessibilityIdentifier("recording.confirm") + .frameProbe("recording.confirm") + Button { + confirming = nil + } label: { + Text("Keep").font(.system(size: 12, weight: .medium)) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(.white.opacity(0.1), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.genHoverPlain()) + .fixedSize() + .accessibilityIdentifier("recording.keep") + .frameProbe("recording.keep") + } + } + + private func preparingRow(_ text: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "hourglass").font(.system(size: 10)).foregroundStyle(.secondary) + Text(text).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + .lineLimit(3).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 420, alignment: .leading) + Spacer(minLength: 0) + } + .padding(.leading, 6) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("recording.preparing") + } + + private func noticeRow(_ notice: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 10)).foregroundStyle(.orange) + Text(notice).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + .lineLimit(3).fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: 420, alignment: .leading) + Spacer(minLength: 0) + IconButton(systemName: "xmark", tooltip: "Dismiss", size: 9, action: actions.dismissNotice) + } + .padding(.leading, 6) + } +} + +/// Six dots: the grip that says the controls can be dragged. +private struct RecordingGrip: View { + var body: some View { + Grid(horizontalSpacing: 2.5, verticalSpacing: 2.5) { + ForEach(0..<3, id: \.self) { _ in + GridRow { + Circle().frame(width: 2.5, height: 2.5) + Circle().frame(width: 2.5, height: 2.5) + } + } + } + .foregroundStyle(.white.opacity(0.35)) + .accessibilityHidden(true) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingModels.swift new file mode 100644 index 0000000000..0e79c9ef8d --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingModels.swift @@ -0,0 +1,222 @@ +import AppKit +import Foundation + +/// What a screen recording records: exact windows, every visible window of an app, or a whole display. The JSON is +/// the `--target` of `tools control capture session` and the `target` of its `switch` command. +public enum RecordingTarget: Hashable, Sendable { + case window(ids: [UInt32], app: String?, title: String?, pid: Int32?) + case app(app: String, name: String?) + case display(index: Int, name: String?) + + /// The line a person reads: the window's title (or its app), the app's name, the display's name. + public var title: String { + switch self { + case let .window(ids, app, title, _): + if let title, !title.isEmpty { return title } + return app ?? "Window \(ids.map(String.init).joined(separator: ", "))" + case let .app(app, name): + return name ?? app + case let .display(index, name): + return name ?? "Display \(index + 1)" + } + } + + /// The smaller line under the title: whose window it is, or what kind of target it is. + public var subtitle: String { + switch self { + case let .window(_, app, title, _): + return (title?.isEmpty == false ? app : nil) ?? "Window" + case .app: + return "Every window of the app" + case .display: + return "The whole display" + } + } + + public var symbol: String { + switch self { + case .window: return "macwindow" + case .app: return "square.stack" + case .display: return "display" + } + } + + /// The pid of the recorded app when the target names one, so a picker can show its icon. + public var pid: Int32? { + switch self { + case let .window(_, _, _, pid): return pid + case let .app(app, _): return Int32(app) + case .display: return nil + } + } + + var wire: [String: Any] { + switch self { + case let .window(ids, app, title, pid): + var object: [String: Any] = ["kind": "window", "windowIds": ids.map(Int.init)] + if let app { object["app"] = app } + if let title { object["title"] = title } + if let pid { object["pid"] = Int(pid) } + return object + case let .app(app, name): + var object: [String: Any] = ["kind": "app", "app": app] + if let name { object["name"] = name } + return object + case let .display(index, name): + var object: [String: Any] = ["kind": "display", "screenIndex": index] + if let name { object["name"] = name } + return object + } + } + + /// The target as one line of JSON. + public var json: String { + guard let data = try? JSONSerialization.data(withJSONObject: wire, options: [.sortedKeys]) else { return "{}" } + return String(decoding: data, as: UTF8.self) + } +} + +extension RecordingTarget: Decodable { + private enum Keys: String, CodingKey { case kind, windowIds, app, title, pid, name, screenIndex } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Keys.self) + switch try container.decode(String.self, forKey: .kind) { + case "window": + self = .window( + ids: try container.decode([UInt32].self, forKey: .windowIds), + app: try container.decodeIfPresent(String.self, forKey: .app), + title: try container.decodeIfPresent(String.self, forKey: .title), + pid: try container.decodeIfPresent(Int32.self, forKey: .pid)) + case "app": + self = .app(app: try container.decode(String.self, forKey: .app), + name: try container.decodeIfPresent(String.self, forKey: .name)) + case "display": + self = .display(index: try container.decode(Int.self, forKey: .screenIndex), + name: try container.decodeIfPresent(String.self, forKey: .name)) + case let other: + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, + debugDescription: "unknown recording target kind \(other)") + } + } +} + +/// One on-screen window, as `tools control capture targets --json` lists it (CG points, top-left origin). +public struct RecordingWindow: Decodable, Identifiable, Hashable, Sendable { + public struct Bounds: Decodable, Hashable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + } + + public let id: UInt32 + public let pid: Int32 + public let app: String + public let title: String + public let bounds: Bounds + + public init(id: UInt32, pid: Int32, app: String, title: String, bounds: Bounds) { + self.id = id + self.pid = pid + self.app = app + self.title = title + self.bounds = bounds + } + + public var target: RecordingTarget { .window(ids: [id], app: app, title: title, pid: pid) } +} + +public struct RecordingApp: Decodable, Identifiable, Hashable, Sendable { + public let pid: Int32 + public let name: String + public let windowCount: Int + public var id: Int32 { pid } + + public init(pid: Int32, name: String, windowCount: Int) { + self.pid = pid + self.name = name + self.windowCount = windowCount + } + + public var target: RecordingTarget { .app(app: String(pid), name: name) } +} + +public struct RecordingDisplay: Decodable, Identifiable, Hashable, Sendable { + public let index: Int + public let name: String + public let isPrimary: Bool + public let width: Double + public let height: Double + public var id: Int { index } + + public init(index: Int, name: String, isPrimary: Bool, width: Double, height: Double) { + self.index = index + self.name = name + self.isPrimary = isPrimary + self.width = width + self.height = height + } + + public var target: RecordingTarget { .display(index: index, name: name) } +} + +/// Everything a picker offers. +public struct RecordingTargets: Decodable, Hashable, Sendable { + public let windows: [RecordingWindow] + public let apps: [RecordingApp] + public let displays: [RecordingDisplay] + public let frontmostPid: Int32? + + public init(windows: [RecordingWindow], apps: [RecordingApp], displays: [RecordingDisplay], frontmostPid: Int32? = nil) { + self.windows = windows + self.apps = apps + self.displays = displays + self.frontmostPid = frontmostPid + } + + /// The same lists without the windows and apps of `pids` (a host's own process). + public func excluding(pids: Set<Int32>) -> RecordingTargets { + RecordingTargets(windows: windows.filter { !pids.contains($0.pid) }, apps: apps.filter { !pids.contains($0.pid) }, + displays: displays, frontmostPid: frontmostPid) + } + + /// The front window of the app the person was in (`preferredPid`, else the frontmost app), else the main display. + public func defaultTarget(preferredPid: Int32? = nil) -> RecordingTarget? { + for pid in [preferredPid, frontmostPid].compactMap({ $0 }) { + if let window = windows.first(where: { $0.pid == pid }) { return window.target } + } + if let window = windows.first { return window.target } + return (displays.first(where: \.isPrimary) ?? displays.first)?.target + } +} + +/// Where a recording is: arming (no frame yet), recording, paused, saving, or over. +public enum RecordingPhase: String, Sendable { + case idle, starting, recording, paused, finishing, done, discarded, failed + + public var isActive: Bool { [.starting, .recording, .paused, .finishing].contains(self) } +} + +/// One JSON line of `tools control capture session --json`. +struct RecordingSessionLine: Decodable { + let event: String + let state: String? + let reason: String? + let target: RecordingTarget? + let elapsedMs: Double? + let limitMs: Double? + let segments: Int? + let path: String? + let durationMs: Double? + let message: String? + let code: String? + let fatal: Bool? +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingPanels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingPanels.swift new file mode 100644 index 0000000000..ff4220b809 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingPanels.swift @@ -0,0 +1,366 @@ +import AppKit +import SwiftUI + +/// A hosting view that acts on the first click: a panel that never becomes key gets every click as a first click, +/// and AppKit drops it before SwiftUI sees it unless the view accepts it. +final class FirstMouseHostingView<Content: View>: NSHostingView<Content> { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} + +/// The recording controls' window: floats over every app and Space, never takes the keyboard or activates the app. +final class RecordingControlsWindow: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +/// The target picker's window: takes the keyboard (arrows, Return, Esc, the filter) without activating the app, so +/// the app the person was in stays active and gets the keyboard back when the picker closes. +final class RecordingPickerWindow: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +/// Where the controls go, in Cocoa screen coordinates. Pure, so the placement rules are tested without a screen. +enum RecordingPanelLayout { + struct Screen { + let frame: CGRect + let visible: CGRect + } + + static let margin: CGFloat = 16 + static let gap: CGFloat = 12 + + /// `targetCG` is what is recorded in CoreGraphics points (top-left origin of the primary display), nil for a + /// display; `displayIndex` names the recorded display in `screens` order (NSScreen order). + static func frame(size: CGSize, placement: RecordingPanelPlacement, targetCG: CGRect?, displayIndex: Int?, + screens: [Screen]) -> CGRect { + guard let primary = screens.first else { return CGRect(origin: .zero, size: size) } + let target = targetCG.map { cocoa($0, primaryHeight: primary.frame.height) } + let screen = displayIndex.flatMap { screens.indices.contains($0) ? screens[$0] : nil } + ?? target.flatMap { rect in screens.first { $0.frame.contains(CGPoint(x: rect.midX, y: rect.midY)) } } + ?? primary + let visible = screen.visible + var origin: CGPoint + switch placement { + case .topLeft: + origin = CGPoint(x: visible.minX + margin, y: visible.maxY - margin - size.height) + case .topRight: + origin = CGPoint(x: visible.maxX - margin - size.width, y: visible.maxY - margin - size.height) + case .bottomLeft: + origin = CGPoint(x: visible.minX + margin, y: visible.minY + margin) + case .bottomRight: + origin = CGPoint(x: visible.maxX - margin - size.width, y: visible.minY + margin) + case .nearTarget: + if let target, displayIndex == nil { + let below = target.minY - gap - size.height + // Under the window when the screen has room, else inside its bottom edge. + let y = below >= visible.minY + margin ? below : target.minY + gap + origin = CGPoint(x: target.midX - size.width / 2, y: y) + } else { + origin = CGPoint(x: visible.midX - size.width / 2, y: visible.minY + margin * 2) + } + } + origin.x = min(max(origin.x, visible.minX + margin), visible.maxX - margin - size.width) + origin.y = min(max(origin.y, visible.minY + margin), visible.maxY - margin - size.height) + return CGRect(origin: origin, size: size).integral + } + + static func cocoa(_ rect: CGRect, primaryHeight: CGFloat) -> CGRect { + CGRect(x: rect.minX, y: primaryHeight - rect.maxY, width: rect.width, height: rect.height) + } + + static var liveScreens: [Screen] { + NSScreen.screens.map { Screen(frame: $0.frame, visible: $0.visibleFrame) } + } +} + +/// A drag area that moves its window. It tracks the drag itself from each event's location: `performDrag(with:)` +/// moved a non-activating panel only on the first drag of a show (ux ledger, 2026-10-14). +struct RecordingWindowDragArea: NSViewRepresentable { + var dragged: () -> Void + + func makeNSView(context: Context) -> RecordingWindowDragView { + let view = RecordingWindowDragView() + view.dragged = dragged + return view + } + + func updateNSView(_ view: RecordingWindowDragView, context: Context) { view.dragged = dragged } +} + +final class RecordingWindowDragView: NSView { + var dragged: (() -> Void)? + private var last: CGPoint? + + override init(frame: NSRect) { + super.init(frame: frame) + setAccessibilityElement(true) + setAccessibilityRole(.handle) + setAccessibilityIdentifier("recording.drag") + setAccessibilityLabel("Move the recording controls") + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + override var mouseDownCanMoveWindow: Bool { false } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func resetCursorRects() { addCursorRect(bounds, cursor: last == nil ? .openHand : .closedHand) } + + /// Global CoreGraphics points (y down), from the event itself, so a drag delivered in the background moves it too. + private func location(_ event: NSEvent) -> CGPoint { + event.cgEvent?.location ?? CGPoint(x: NSEvent.mouseLocation.x, y: -NSEvent.mouseLocation.y) + } + + override func mouseDown(with event: NSEvent) { + last = location(event) + window?.invalidateCursorRects(for: self) + } + + override func mouseDragged(with event: NSEvent) { + guard let window, let last else { return } + let point = location(event) + window.setFrameOrigin(CGPoint(x: window.frame.minX + point.x - last.x, y: window.frame.minY - (point.y - last.y))) + self.last = point + dragged?() + } + + override func mouseUp(with event: NSEvent) { + last = nil + window?.invalidateCursorRects(for: self) + } +} + +/// The floating window that holds the recording controls. Out of every movie by window id (the session passes it to +/// the recorder); `sharingType = .none` also keeps it out of legacy window captures. +@MainActor +final class RecordingControlsPanel { + let window: RecordingControlsWindow + private let hosting: FirstMouseHostingView<AnyView> + /// True once the person dragged the controls: a target switch then leaves them where they are. + private(set) var moved = false + + init() { + window = RecordingControlsWindow(contentRect: CGRect(x: 0, y: 0, width: 520, height: 52), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + window.title = "Recording controls" + window.identifier = NSUserInterfaceItemIdentifier("recording.controls") + RecordingPanelChrome.apply(to: window) + hosting = FirstMouseHostingView(rootView: AnyView(EmptyView())) + // The panel sizes itself from its content's measured size (below); a hosting view that also reports an + // intrinsic size makes AppKit lay the whole tree out on every update. + hosting.sizingOptions = [] + window.contentView = hosting + } + + /// The CG window id the recorder leaves out. + var windowID: Int { window.windowNumber } + + /// `view` is drawn at its ideal size; the window follows that size, keeping its centre and bottom edge. + func setContent(_ view: some View) { + hosting.rootView = AnyView(view.fixedSize().onGeometryChange(for: CGSize.self, of: { $0.size }) { [weak self] size in + // Never resize the window inside the layout pass that measured it. + DispatchQueue.main.async { self?.resize(to: size) } + }) + } + + func noteMoved() { moved = true } + + private func resize(to size: CGSize) { + let size = CGSize(width: size.width.rounded(.up), height: size.height.rounded(.up)) + guard size.width > 0, size.height > 0, size != window.frame.size else { return } + let frame = window.frame + window.setFrame(CGRect(x: (frame.midX - size.width / 2).rounded(), y: frame.minY, width: size.width, + height: size.height), display: true) + } + + func place(_ placement: RecordingPanelPlacement, targetCG: CGRect?, displayIndex: Int?) { + guard !moved else { return } + hosting.layoutSubtreeIfNeeded() + let size = hosting.fittingSize + window.setFrame(RecordingPanelLayout.frame(size: size, placement: placement, targetCG: targetCG, + displayIndex: displayIndex, screens: RecordingPanelLayout.liveScreens), + display: true) + } + + func show() { window.orderFrontRegardless() } + + func close() { + window.orderOut(nil) + window.close() + } +} + +/// A steady dashed border around what is about to be recorded, shown while the recorder starts. The recorder draws its +/// own pulsing border once it runs; this one covers the gap (a compile after an update can take a minute), so the +/// person sees at once what they chose. One clear panel per display draws every rect on it: a panel per window made +/// 50 windows for a long window list and stalled the main thread 1.6 s. Out of every movie by window id, like the +/// controls, and on screen only while a segment starts. +@MainActor +final class RecordingTargetOutline { + private struct Overlay { + let screen: CGRect + let panel: NSPanel + let view: RecordingOutlineView + } + + private var overlays: [Overlay] + + var windowIDs: [Int] { overlays.map(\.panel.windowNumber) } + /// The rects each display's panel draws, in that panel's own points (tests). + var drawn: [[CGRect]] { overlays.map(\.view.rects) } + + /// Makes the panels up front, so their window ids exist before the recorder is told what to leave out. + init(screens: [CGRect] = NSScreen.screens.map(\.frame)) { + overlays = screens.map { frame in + let view = RecordingOutlineView(frame: CGRect(origin: .zero, size: frame.size)) + return Overlay(screen: frame, panel: Self.makePanel(frame: frame, view: view), view: view) + } + } + + /// `rects` in Cocoa screen points; a display with none of them hides its panel. + func show(_ rects: [CGRect]) { + for overlay in overlays { + let local = rects.map { $0.insetBy(dx: -3, dy: -3) } + .filter { $0.intersects(overlay.screen) } + .map { $0.offsetBy(dx: -overlay.screen.minX, dy: -overlay.screen.minY) } + overlay.view.rects = local + if local.isEmpty { + overlay.panel.orderOut(nil) + } else { + overlay.panel.orderFrontRegardless() + } + } + } + + func hide() { overlays.forEach { $0.panel.orderOut(nil) } } + + func close() { + overlays.forEach { $0.panel.orderOut(nil); $0.panel.close() } + overlays = [] + } + + private static func makePanel(frame: CGRect, view: NSView) -> NSPanel { + let panel = NSPanel(contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, + defer: false) + panel.title = "Recording target" + panel.level = .screenSaver + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.ignoresMouseEvents = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + panel.sharingType = .none + panel.contentView = view + panel.setFrame(frame, display: false) + return panel + } +} + +/// Draws its rects as dashed red rounded borders, once per change, no animation. +final class RecordingOutlineView: NSView { + var rects: [CGRect] = [] { + didSet { if rects != oldValue { needsDisplay = true } } + } + + override init(frame: NSRect) { + super.init(frame: frame) + setAccessibilityElement(false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + override func draw(_ dirtyRect: NSRect) { + NSColor.systemRed.withAlphaComponent(0.9).setStroke() + for rect in rects { + let path = NSBezierPath(roundedRect: rect.insetBy(dx: 1.5, dy: 1.5), xRadius: 9, yRadius: 9) + path.lineWidth = 3 + path.setLineDash([9, 6], count: 2, phase: 0) + path.stroke() + } + } +} + +/// What both recording windows share: floating over every app, Space and full-screen app, above the widget's panels, +/// clear behind their own rounded content, out of legacy window captures. +@MainActor +enum RecordingPanelChrome { + static func apply(to window: NSPanel) { + window.isFloatingPanel = true + // After isFloatingPanel, whose setter resets the level: above the widget's edge panels. + window.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 1) + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + window.hidesOnDeactivate = false + window.isReleasedWhenClosed = false + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = true + window.sharingType = .none + } +} + +/// The floating window that holds the target picker. It closes, as a cancel, when it loses the keyboard (a click in +/// another app or window). +@MainActor +final class RecordingPickerPanel: NSObject, NSWindowDelegate { + let window: RecordingPickerWindow + private var onDismiss: (() -> Void)? + + override init() { + window = RecordingPickerWindow(contentRect: CGRect(x: 0, y: 0, width: 400, height: 420), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + super.init() + window.title = "Choose what to record" + window.identifier = NSUserInterfaceItemIdentifier("recording.picker") + RecordingPanelChrome.apply(to: window) + window.delegate = self + } + + var isVisible: Bool { window.isVisible } + + /// Shows the picker with its top centred under `anchor` (Cocoa screen point), inside that screen, and gives it + /// the keyboard without activating the app. + func present(_ view: some View, near anchor: CGPoint, onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + // A fresh hosting view each time: a reused one that had been on screen measured 0x0 after a new root view, so + // the second picker of a session opened invisible and the recorder waited for a choice nobody could see. + let hosting = FirstMouseHostingView(rootView: AnyView(view.fixedSize().onGeometryChange(for: CGSize.self, of: { $0.size }) { [weak self] size in + DispatchQueue.main.async { self?.resize(to: size) } + })) + hosting.sizingOptions = [] + window.contentView = hosting + hosting.layoutSubtreeIfNeeded() + let size = hosting.fittingSize + let screen = NSScreen.screens.first { $0.frame.contains(anchor) } ?? NSScreen.screens.first + let visible = screen?.visibleFrame ?? CGRect(origin: .zero, size: size) + var origin = CGPoint(x: anchor.x - size.width / 2, y: anchor.y - 8 - size.height) + if origin.y < visible.minY + 8 { origin.y = anchor.y + 8 } + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - 8 - size.width) + origin.y = min(max(origin.y, visible.minY + 8), visible.maxY - 8 - size.height) + window.setFrame(CGRect(origin: origin, size: size).integral, display: true) + window.makeKeyAndOrderFront(nil) + } + + /// Follows the content (a tab that lists more or fewer rows), keeping the top edge. + private func resize(to size: CGSize) { + let size = CGSize(width: size.width.rounded(.up), height: size.height.rounded(.up)) + guard window.isVisible, size.width > 0, size.height > 0, size != window.frame.size else { return } + let frame = window.frame + window.setFrame(CGRect(x: frame.minX, y: frame.maxY - size.height, width: size.width, height: size.height), + display: true) + } + + func dismiss() { + onDismiss = nil + window.orderOut(nil) + } + + func windowDidResignKey(_ notification: Notification) { + guard window.isVisible, let onDismiss else { return } + PerfLog.mark("recording.picker cancelled: lost the keyboard to \(NSApp.keyWindow?.title ?? "another app")") + self.onDismiss = nil + window.orderOut(nil) + onDismiss() + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSessionController.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSessionController.swift new file mode 100644 index 0000000000..97fbba38c3 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSessionController.swift @@ -0,0 +1,192 @@ +import Foundation + +/// The command side of a running `tools control capture session`: one JSON line per command, EOF to stop. +@MainActor +public protocol RecordingCommandChannel: AnyObject { + func send(_ line: String) throws + func finishInput() + /// SIGTERM: the session stops and keeps what it recorded. + func stop() +} + +extension ToolsLineStream: RecordingCommandChannel { + public func send(_ line: String) throws { try sendInput(line) } +} + +/// Drives one recording session through `tools control capture session --json --stop-on-stdin` and publishes what it +/// reports. It knows nothing about who records or what happens to the movie: the host gets `onDone` with the movie, +/// `onDiscarded`, or `onFailed`. +@MainActor +public final class RecordingSessionController: ObservableObject { + @Published public private(set) var phase: RecordingPhase = .idle + /// Recorded time: what is in the movie so far, without arming or paused time. + @Published public private(set) var elapsedMs: Double = 0 + @Published public private(set) var limitMs: Double = 600_000 + @Published public private(set) var target: RecordingTarget? + /// Why the session paused: `user`, `limit` (the length limit) or `recorder` (it stopped by itself). + @Published public private(set) var pauseReason: String? + @Published public private(set) var segments = 0 + /// The last problem the session reported that did not end it; cleared by the next state change. + @Published public var notice: String? + /// Why `starting` takes long (the recorder compiles after an update, or no first frame came yet); cleared once + /// the session leaves `starting`. + @Published public private(set) var preparing: String? + + public var onDone: ((URL, Double) -> Void)? + public var onDiscarded: (() -> Void)? + /// The message and its code (`screen-recording-denied`), when the session could not record at all. + public var onFailed: ((String, String?) -> Void)? + + private let bridge: ToolsBridge? + private let openChannel: (([String], @escaping @MainActor ([String]) -> Void, + @escaping @MainActor (Int32, String) -> Void) throws -> RecordingCommandChannel)? + private var channel: RecordingCommandChannel? + private var ended = false + + public init(bridge: ToolsBridge) { + self.bridge = bridge + openChannel = nil + } + + /// Tests: `open` gets the CLI arguments and the line and exit callbacks, and returns the command channel. + init(open: @escaping ([String], @escaping @MainActor ([String]) -> Void, + @escaping @MainActor (Int32, String) -> Void) throws -> RecordingCommandChannel) { + bridge = nil + openChannel = open + } + + /// The arguments after `tools control`. + static func arguments(target: RecordingTarget, excludeWindowIDs: [Int], frameRate: Int, showsBorder: Bool) -> [String] { + var args = ["capture", "session", "--json", "--stop-on-stdin", "--target", target.json, "--fps", String(frameRate)] + if !excludeWindowIDs.isEmpty { + args += ["--exclude-window-ids", excludeWindowIDs.map(String.init).joined(separator: ",")] + } + if !showsBorder { args.append("--no-indicator") } + return args + } + + /// Starts recording `target`. `excludeWindowIDs` are windows the movie never shows (the controls panel). + public func start(target: RecordingTarget, excludeWindowIDs: [Int], frameRate: Int, showsBorder: Bool) throws { + guard channel == nil, !ended else { return } + let args = Self.arguments(target: target, excludeWindowIDs: excludeWindowIDs, frameRate: frameRate, + showsBorder: showsBorder) + self.target = target + phase = .starting + let lines: @MainActor ([String]) -> Void = { [weak self] in self?.receive($0) } + let exit: @MainActor (Int32, String) -> Void = { [weak self] status, stderr in self?.exited(status: status, stderr: stderr) } + do { + if let openChannel { + channel = try openChannel(args, lines, exit) + } else if let bridge { + channel = try ToolsLineStream( + bridge: bridge, subcommand: "control", args: args, + onLines: lines, onExit: { exit($0.status, $0.stderr) }) + } + PerfLog.mark("recording.session started target=\(target.json)") + } catch { + phase = .failed + ended = true + throw error + } + } + + public func pause() { send(["cmd": "pause"]) } + public func resume() { send(["cmd": "resume"]) } + public func restart() { send(["cmd": "restart"]) } + /// Stops and keeps the movie: `onDone` follows once it is joined. + public func stop() { send(["cmd": "stop"]) } + public func discard() { send(["cmd": "discard"]) } + + public func switchTo(_ target: RecordingTarget) { + var wire = target.wire + // The channel takes one line of at most 512 bytes. A window's title and a display's name are labels only, so + // a long one is left out rather than the switch refused. + let line = try? JSONSerialization.data(withJSONObject: ["cmd": "switch", "target": wire], options: [.sortedKeys]) + if (line?.count ?? 0) >= Self.commandLimit { + wire["title"] = nil + wire["name"] = nil + } + send(["cmd": "switch", "target": wire]) + } + + /// `ToolsLineStream.sendInput`'s limit; the newline takes one byte of it. + static let commandLimit = 512 + + /// Ends the child at once (an app that quits): it stops and keeps what it recorded on SIGTERM. + public func terminate() { + channel?.stop() + } + + private func send(_ command: [String: Any]) { + guard let channel, !ended else { return } + do { + let data = try JSONSerialization.data(withJSONObject: command, options: [.sortedKeys]) + try channel.send(String(decoding: data, as: UTF8.self) + "\n") + } catch { + notice = "The recorder did not take the command: \(error.localizedDescription)" + PerfLog.mark("recording.session send failed \(error.localizedDescription)") + } + } + + func receive(_ lines: [String]) { + for line in lines { + guard let event = try? JSONDecoder().decode(RecordingSessionLine.self, from: Data(line.utf8)) else { + PerfLog.mark("recording.session unreadable line \(line.prefix(200))") + continue + } + apply(event) + } + } + + private func apply(_ event: RecordingSessionLine) { + switch event.event { + case "state": + if let state = event.state, let next = RecordingPhase(rawValue: state) { phase = next } + if let target = event.target { self.target = target } + if let elapsed = event.elapsedMs { elapsedMs = elapsed } + if let limit = event.limitMs { limitMs = limit } + if let segments = event.segments { self.segments = segments } + pauseReason = phase == .paused ? event.reason : nil + if phase != .paused { notice = nil } + if phase != .starting { preparing = nil } + case "preparing": + preparing = event.message + case "elapsed": + if let elapsed = event.elapsedMs { elapsedMs = elapsed } + case "segment": + segments += 1 + case "done": + guard let path = event.path else { return } + phase = .done + ended = true + onDone?(URL(fileURLWithPath: path), event.durationMs ?? elapsedMs) + case "discarded": + phase = .discarded + ended = true + onDiscarded?() + case "error": + let message = event.message ?? "The recorder failed." + if event.fatal == true { + phase = .failed + ended = true + onFailed?(message, event.code) + } else { + notice = message + } + default: + break + } + } + + private func exited(status: Int32, stderr: String) { + channel = nil + PerfLog.mark("recording.session exited status=\(status) phase=\(phase.rawValue)") + guard !ended else { return } + ended = true + phase = .failed + // One sentence on screen; the recorder's own words go to the log. + let tail = stderr.trimmingCharacters(in: .whitespacesAndNewlines).suffix(600) + KitHost.log("recording.session stderr (exit \(status)) \(tail.isEmpty ? "(empty)" : String(tail))") + onFailed?("The recorder stopped unexpectedly. The details are in the log.", nil) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSettings.swift new file mode 100644 index 0000000000..9707942762 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSettings.swift @@ -0,0 +1,115 @@ +import Foundation +import SwiftUI + +/// Where the recording controls float when a recording starts or switches target. A drag moves them for the rest of +/// that recording. +public enum RecordingPanelPlacement: String, CaseIterable, Identifiable, Sendable { + /// Under the recorded window (inside its bottom edge when the screen has no room below), or at the bottom of the + /// recorded display. + case nearTarget + case topLeft + case topRight + case bottomLeft + case bottomRight + + public var id: String { rawValue } + + public var title: String { + switch self { + case .nearTarget: return "Near what is recorded" + case .topLeft: return "Top left corner" + case .topRight: return "Top right corner" + case .bottomLeft: return "Bottom left corner" + case .bottomRight: return "Bottom right corner" + } + } +} + +/// The screen recorder's preferences, shared by every host (the widget composer, the Capture shelf). Stored in the +/// app's defaults under `recording.*`; every value applies to the next recording. +@MainActor +public final class RecordingSettings: ObservableObject { + public static let shared = RecordingSettings() + + public static let frameRates = [5, 10, 15, 24, 30] + + public enum Key { + public static let placement = "recording.panelPlacement" + public static let frameRate = "recording.frameRate" + public static let showsBorder = "recording.showsBorder" + public static let confirmsDiscard = "recording.confirmsDiscard" + } + + /// Default: near what is recorded, so the controls sit where the person is looking. + @Published public var placement: RecordingPanelPlacement { + didSet { defaults.set(placement.rawValue, forKey: Key.placement) } + } + + /// Frames per second while something moves (the recorder drops to 2 while the screen is still). Default 15. + @Published public var frameRate: Int { + didSet { defaults.set(frameRate, forKey: Key.frameRate) } + } + + /// The pulsing red border around what is recorded. It never appears in the movie. Default on. + @Published public var showsBorder: Bool { + didSet { defaults.set(showsBorder, forKey: Key.showsBorder) } + } + + /// "Discard" and "Restart" ask once inside the controls before they lose what was recorded. Default on. + @Published public var confirmsDiscard: Bool { + didSet { defaults.set(confirmsDiscard, forKey: Key.confirmsDiscard) } + } + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + placement = defaults.string(forKey: Key.placement).flatMap(RecordingPanelPlacement.init(rawValue:)) ?? .nearTarget + let storedRate = defaults.integer(forKey: Key.frameRate) + frameRate = Self.frameRates.contains(storedRate) ? storedRate : 15 + showsBorder = defaults.object(forKey: Key.showsBorder) as? Bool ?? true + confirmsDiscard = defaults.object(forKey: Key.confirmsDiscard) as? Bool ?? true + } +} + +/// The settings page for screen recording: placement, frame rate, the border and the discard question. +public struct RecordingSettingsView: View { + @ObservedObject private var settings: RecordingSettings + + public init(settings: RecordingSettings? = nil) { + self.settings = settings ?? .shared + } + + public var body: some View { + VStack(spacing: 18) { + NativeSettingsCard("Controls", subtitle: "The small panel that floats beside a recording.") { + NativeSettingsRow("Placement", detail: "Where the controls appear when a recording starts. Drag them anywhere.") { + Picker("Placement", selection: $settings.placement) { + ForEach(RecordingPanelPlacement.allCases) { placement in + Text(placement.title).tag(placement) + } + }.labelsHidden().frame(width: 220, alignment: .trailing) + .accessibilityIdentifier("recording.placement") + } + Divider() + NativeSettingsToggle( + "Ask before discarding", detail: "Discard and Restart ask once inside the controls.", + identifier: "recording.confirmsDiscard", isOn: $settings.confirmsDiscard) + } + NativeSettingsCard("Recording") { + NativeSettingsRow("Frame rate", detail: "While something moves. A still screen records at 2 per second.") { + Picker("Frame rate", selection: $settings.frameRate) { + ForEach(RecordingSettings.frameRates, id: \.self) { rate in + Text("\(rate) fps").tag(rate) + } + }.labelsHidden().frame(width: 120, alignment: .trailing) + .accessibilityIdentifier("recording.frameRate") + } + Divider() + NativeSettingsToggle( + "Red border around the recording", detail: "Marks what is recorded. It never appears in the movie.", + identifier: "recording.showsBorder", isOn: $settings.showsBorder) + } + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift new file mode 100644 index 0000000000..2a0c72aed3 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift @@ -0,0 +1,396 @@ +import AppKit +import SwiftUI + +/// The three kinds of target the picker offers, one tab each. +public enum RecordingPickerTab: String, CaseIterable, Hashable, Sendable { + case windows, apps, displays + + public var title: String { + switch self { + case .windows: return "Windows" + case .apps: return "Apps" + case .displays: return "Displays" + } + } + + static func of(_ target: RecordingTarget?) -> RecordingPickerTab { + switch target { + case .app: return .apps + case .display: return .displays + default: return .windows + } + } +} + +extension RecordingPickerTab { + /// The chord that switches to this tab, shown in the tab's tooltip. + var chord: String { + switch self { + case .windows: return "⌘1" + case .apps: return "⌘2" + case .displays: return "⌘3" + } + } + + static func forKey(_ character: Character) -> RecordingPickerTab? { + switch character { + case "1": return .windows + case "2": return .apps + case "3": return .displays + default: return nil + } + } +} + +/// One line of the picker: an app header over its windows, or a target that a click records. +struct RecordingPickerRow: Identifiable, Equatable { + enum Kind: Equatable { + case header + case target(RecordingTarget) + } + + let id: String + let kind: Kind + let title: String + let detail: String + let pid: Int32? + let symbol: String + var badge: String? + + var target: RecordingTarget? { + if case let .target(target) = kind { return target } + return nil + } + + /// The rows of one tab: windows grouped under their app in the order the apps' front windows stand, apps, or + /// displays. `query` keeps the windows whose title or app contains it (apps by name). + static func rows(_ targets: RecordingTargets, tab: RecordingPickerTab, query: String) -> [RecordingPickerRow] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) + func matches(_ values: String...) -> Bool { + needle.isEmpty || values.contains { $0.localizedCaseInsensitiveContains(needle) } + } + switch tab { + case .windows: + var order: [Int32] = [] + var byApp: [Int32: [RecordingWindow]] = [:] + for window in targets.windows where matches(window.title, window.app) { + if byApp[window.pid] == nil { order.append(window.pid) } + byApp[window.pid, default: []].append(window) + } + return order.flatMap { pid -> [RecordingPickerRow] in + let windows = byApp[pid] ?? [] + let header = RecordingPickerRow( + id: "app-\(pid)", kind: .header, title: windows.first?.app ?? "App", + detail: Plural.count(windows.count, "window"), pid: pid, symbol: "app") + return [header] + windows.map { window in + RecordingPickerRow( + id: "window-\(window.id)", kind: .target(window.target), + title: window.title.isEmpty ? "Untitled window" : window.title, + detail: "\(Int(window.bounds.width)) × \(Int(window.bounds.height))", + pid: nil, symbol: "macwindow") + } + } + case .apps: + return targets.apps.filter { matches($0.name) }.map { app in + RecordingPickerRow(id: "apps-\(app.pid)", kind: .target(app.target), title: app.name, + detail: Plural.count(app.windowCount, "window"), pid: app.pid, symbol: "app") + } + case .displays: + return targets.displays.filter { matches($0.name) }.map { display in + RecordingPickerRow(id: "display-\(display.index)", kind: .target(display.target), title: display.name, + detail: "\(Int(display.width)) × \(Int(display.height))", pid: nil, symbol: "display", + badge: display.isPrimary ? "Main" : nil) + } + } + } +} + +/// Lets the person pick what to record: one window (grouped by app, with titles), every window of an app, or a whole +/// display. Starts on `initial` (the front window of the app the person was in). ↑ ↓ move, Return records, Esc +/// cancels; a click on a row records it. Values and closures only. +public struct RecordingTargetPicker: View { + public enum Load: Equatable { + case loading + case failed(String) + case loaded(RecordingTargets) + } + + private let load: Load + private let title: String + private let initial: RecordingTarget? + private let onChoose: (RecordingTarget) -> Void + private let onCancel: () -> Void + private let onRetry: () -> Void + private let onFilterChange: (RecordingPickerTab, String) -> Void + @State private var tab: RecordingPickerTab + @State private var query = "" + @State private var highlighted: String? + /// The list took over 4 s: the hint names the known slow cause. + @State private var slowLoad = false + @FocusState private var filterFocused: Bool + /// Where the pointer rested when the arrows last moved the highlight; a hover there is the list scrolling under + /// the pointer, not the person moving it, so the keyboard highlight stays. + @State private var keyboardMouse: CGPoint? + + public init(load: Load, title: String = "Choose what to record", initial: RecordingTarget?, + onChoose: @escaping (RecordingTarget) -> Void, onCancel: @escaping () -> Void, + onRetry: @escaping () -> Void = {}, restoring: (tab: RecordingPickerTab, query: String)? = nil, + onFilterChange: @escaping (RecordingPickerTab, String) -> Void = { _, _ in }) { + self.load = load + self.title = title + self.initial = initial + self.onChoose = onChoose + self.onCancel = onCancel + self.onRetry = onRetry + self.onFilterChange = onFilterChange + _tab = State(initialValue: restoring?.tab ?? RecordingPickerTab.of(initial)) + _query = State(initialValue: restoring?.query ?? "") + } + + private var rows: [RecordingPickerRow] { + guard case let .loaded(targets) = load else { return [] } + return RecordingPickerRow.rows(targets, tab: tab, query: query) + } + + private var targetRows: [RecordingPickerRow] { rows.filter { $0.target != nil } } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + GenSegmentedTabs("What to record", items: RecordingPickerTab.allCases.map { .init($0, $0.title) }, + selection: $tab, minSegmentWidth: 114, identifier: "recording.picker.tabs") + .instantTooltip(RecordingPickerTab.allCases.map { "\($0.title) \($0.chord)" }.joined(separator: ", ")) + filterField + content + footer + } + .padding(14) + .frame(width: 380) + .modifier(NativeGlassShapeSurface(shape: RoundedRectangle(cornerRadius: 16, style: .continuous))) + .environment(\.colorScheme, .dark) + .onAppear { + highlight(initial: true) + filterFocused = true + } + .onChange(of: load) { _, _ in highlight(initial: true) } + .onChange(of: tab) { _, _ in onFilterChange(tab, query); highlight(initial: false) } + .onChange(of: query) { _, _ in onFilterChange(tab, query); highlight(initial: false) } + // The keys live on the root, so they work wherever focus is (the filter, a row, nothing). + .focusable() + .focusEffectDisabled() + .onExitCommand(perform: onCancel) + .onKeyPress(.escape) { + onCancel() + return .handled + } + .onKeyPress(.downArrow) { + move(1) + return .handled + } + .onKeyPress(.upArrow) { + move(-1) + return .handled + } + .onKeyPress(.return) { + chooseHighlighted() + return .handled + } + .onKeyPress(phases: .down) { press in + guard press.modifiers == .command, let character = press.characters.first, + let next = RecordingPickerTab.forKey(character) else { return .ignored } + tab = next + return .handled + } + .accessibilityElement(children: .contain) + .accessibilityLabel(title) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: "record.circle").font(.system(size: 14, weight: .semibold)).foregroundStyle(.red) + Text(title).font(.system(size: 13, weight: .semibold)) + Spacer(minLength: 0) + IconButton(systemName: "xmark", tooltip: "Cancel (Esc)", size: 10, action: onCancel) + .accessibilityIdentifier("recording.picker.cancel") + } + } + + private var filterField: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + TextField("Filter \(tab.title.lowercased())", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .focused($filterFocused) + .onSubmit(chooseHighlighted) + .accessibilityIdentifier("recording.picker.filter") + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + @ViewBuilder private var content: some View { + switch load { + case .loading: + VStack(spacing: 6) { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Looking for windows and displays…").font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) + } + if slowLoad { + Text("The first time after an update, the recorder is built first. This can take up to a minute.") + .font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 300) + } + } + .frame(maxWidth: .infinity, minHeight: 120) + .task { + try? await Task.sleep(for: .seconds(4)) + slowLoad = true + } + case let .failed(message): + VStack(spacing: 8) { + Text(message).font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) + .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) + Button("Try again", action: onRetry).buttonStyle(.genHover()) + } + .frame(maxWidth: .infinity, minHeight: 120) + case .loaded: + if rows.isEmpty { + if query.isEmpty { + EmptyState( + symbol: "rectangle.dashed.badge.record", + text: "Nothing to record here.", + detail: "If you expected windows, Screen Recording may be off for this app.", + actionTitle: "Open Screen Recording settings", + action: { NSWorkspace.shared.open(PermissionKind.screenRecording.settingsURL) }) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + Text("Nothing matches “\(query)”.") + .font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) + .frame(maxWidth: .infinity, minHeight: 80) + } + } else { + ScrollViewReader { proxy in + FittedScroll(maxHeight: 330) { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(rows) { row in + rowView(row).id(row.id) + } + } + } + .onChange(of: highlighted) { _, id in + guard let id else { return } + proxy.scrollTo(id) + } + } + } + } + } + + @ViewBuilder private func rowView(_ row: RecordingPickerRow) -> some View { + if let target = row.target { + let isHighlighted = highlighted == row.id + Button { + onChoose(target) + } label: { + HStack(spacing: 8) { + icon(row).frame(width: 18, height: 18) + Text(row.title).font(.system(size: 12, weight: isHighlighted ? .semibold : .regular)) + .lineLimit(1).truncationMode(.middle) + if let badge = row.badge { + Text(badge).font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(.white.opacity(0.12), in: Capsule()) + } + Spacer(minLength: 8) + Text(verbatim: row.detail).font(.system(size: 11).monospacedDigit()).foregroundStyle(SessionPalette.secondary) + } + .padding(.leading, row.symbol == "macwindow" ? 22 : 6) + .padding(.trailing, 8) + .frame(height: 28) + .background(isHighlighted ? Color.accentColor.opacity(0.32) : .clear, + in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + // Every move, not only entry: a pointer that moves inside the row it rested on takes the highlight back. + .onContinuousHover { phase in + guard case .active = phase else { return } + if let rested = keyboardMouse { + let now = NSEvent.mouseLocation + // The list scrolled under a resting pointer: keep the keyboard's row until the pointer moves. + if hypot(now.x - rested.x, now.y - rested.y) < 3 { return } + keyboardMouse = nil + } + if highlighted != row.id { highlighted = row.id } + } + .accessibilityLabel(accessibilityLabel(row)) + .accessibilityIdentifier("recording.picker.\(row.id)") + .frameProbe("recording.picker.\(row.id)") + } else { + HStack(spacing: 6) { + icon(row).frame(width: 14, height: 14) + Text(row.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(SessionPalette.secondary) + Text(verbatim: row.detail).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + } + .padding(.horizontal, 6) + .padding(.top, 6) + .padding(.bottom, 2) + .accessibilityAddTraits(.isHeader) + } + } + + @ViewBuilder private func icon(_ row: RecordingPickerRow) -> some View { + if let image = RecordingAppIcon.image(pid: row.pid) { + Image(nsImage: image).resizable().interpolation(.high) + } else { + Image(systemName: row.symbol).font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) + } + } + + private var footer: some View { + HStack(spacing: 4) { + Text("↑ ↓ to choose · Return records · Esc cancels · ⌘1 ⌘2 ⌘3 switch tabs") + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .font(.system(size: 11)) + .foregroundStyle(SessionPalette.secondary) + } + + /// What VoiceOver reads for a row: the action, then the badge and the details the row shows. + private func accessibilityLabel(_ row: RecordingPickerRow) -> String { + var parts = ["Record \(row.title)"] + if let badge = row.badge { parts.append(badge) } + parts.append(row.detail.replacingOccurrences(of: " × ", with: " by ")) + return parts.joined(separator: ", ") + } + + private func highlight(initial useInitial: Bool) { + let candidates = targetRows + if useInitial, let initial, let row = candidates.first(where: { $0.target == initial }) { + highlighted = row.id + } else if let current = highlighted, candidates.contains(where: { $0.id == current }) { + return + } else { + highlighted = candidates.first?.id + } + } + + private func move(_ step: Int) { + let candidates = targetRows + guard !candidates.isEmpty else { return } + keyboardMouse = NSEvent.mouseLocation + let index = candidates.firstIndex { $0.id == highlighted } ?? -1 + highlighted = candidates[min(max(index + step, 0), candidates.count - 1)].id + } + + private func chooseHighlighted() { + guard let row = targetRows.first(where: { $0.id == highlighted }) ?? targetRows.first, + let target = row.target else { return } + onChoose(target) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/ScreenRecorder.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/ScreenRecorder.swift new file mode 100644 index 0000000000..ea37fae908 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/ScreenRecorder.swift @@ -0,0 +1,362 @@ +import AppKit +import Combine +import SwiftUI + +/// What a host does with a recording. The recorder itself is generic: the widget pastes the movie into a session's +/// draft, the Capture shelf stages it, any other host does what it likes. +public struct ScreenRecordingHost { + /// The name of "stop and keep" in the controls: "Stop and paste", "Stop and save". + public var useTitle: String + public var useSymbol: String + /// Says why the recording asks for Screen Recording, in the permission dialog. + public var permissionReason: String + /// Gets the finished movie. The file is the host's from here on; a throw is reported through `onError`. + public var onUse: (URL) async throws -> Void + public var onDiscard: () -> Void + /// A recording that could not start or failed, or a use that threw. + public var onError: (String) -> Void + /// The recording has started (the picker closed and the controls are up): a host can step aside here. + public var onStart: () -> Void + + public init(useTitle: String, useSymbol: String = "stop.fill", permissionReason: String, + onUse: @escaping (URL) async throws -> Void, onDiscard: @escaping () -> Void = {}, + onError: @escaping (String) -> Void, onStart: @escaping () -> Void = {}) { + self.useTitle = useTitle + self.useSymbol = useSymbol + self.permissionReason = permissionReason + self.onUse = onUse + self.onDiscard = onDiscard + self.onError = onError + self.onStart = onStart + } +} + +/// The whole screen recording flow for one host: the target picker, the Screen Recording grant, the session in +/// `tools control capture session`, the floating controls, switching target (the recording pauses while the picker is +/// open, so the movie never shows it) and the end. One recording runs at a time across every recorder in the process; +/// `begin` while another runs brings that one's controls forward. +@MainActor +public final class ScreenRecorder: ObservableObject { + /// True from the picker's first appearance until the recording ended. + @Published public private(set) var isActive = false + + public let settings: RecordingSettings + private let bridge: ToolsBridge + private let permissionGate: @MainActor (PermissionNeed) async -> Bool + private let loadTargets: @MainActor () async throws -> RecordingTargets + private var host: ScreenRecordingHost? + private var controller: RecordingSessionController? + private var controllerWatch: AnyCancellable? + private var settingsWatch: AnyCancellable? + private var controls: RecordingControlsPanel? + private var controlsModel: RecordingControlsModel? + private var outline: RecordingTargetOutline? + /// The target the outline is drawn around now; nil while it is hidden. + private var outlinedTarget: RecordingTarget? + private let picker = RecordingPickerPanel() + private var targets: RecordingTargets? + private var pickerLoad: RecordingTargetPicker.Load = .loading + private var pickerInitial: RecordingTarget? + /// The picker's tab and filter text, kept here because every `presentPicker` builds a new view; nil until the person + /// changes either, so a list that arrives later does not reset what they typed while it loaded. + private var pickerFilter: (tab: RecordingPickerTab, query: String)? + private var pickerTitle = "Choose what to record" + private var pickerChoose: ((RecordingTarget) -> Void)? + private var pickerCancel: (() -> Void)? + private var pickerAnchor = CGPoint.zero + + private static weak var running: ScreenRecorder? + + public init(bridge: ToolsBridge, settings: RecordingSettings? = nil, + permissionGate: (@MainActor (PermissionNeed) async -> Bool)? = nil) { + self.bridge = bridge + self.settings = settings ?? .shared + self.permissionGate = permissionGate ?? { await PermissionCenter.shared.ensure($0) } + loadTargets = { + // Long enough for the one compile after an update (about 30 s, more on a busy Mac); the compile goes on + // without this call if it is cut, and the recording then reuses its binary. + let result = try await bridge.run(subcommand: "control", args: ["capture", "targets", "--json"], + timeoutSeconds: 120) + guard result.exitCode == 0 else { + throw ToolsBridgeError.refused(String(result.stderr.suffix(600))) + } + return try JSONDecoder().decode(RecordingTargets.self, from: Data(result.stdout.utf8)) + } + } + + /// Opens the picker near `anchor` (a Cocoa screen point; the pointer when nil), starting on the front window of + /// `preferredPid` (the app the person was in), then records what they choose. + public func begin(host: ScreenRecordingHost, anchor: CGPoint? = nil, preferredPid: Int32? = nil) { + if let running = Self.running, running.isActive { + running.controls?.show() + if running !== self { host.onError("A screen recording is already running. Use its controls to end it.") } + return + } + Self.running = self + self.host = host + isActive = true + let preferred = preferredPid ?? NSWorkspace.shared.frontmostApplication.flatMap { app in + app.processIdentifier == ProcessInfo.processInfo.processIdentifier ? nil : app.processIdentifier + } + openPicker(title: "Choose what to record", anchor: anchor ?? NSEvent.mouseLocation, preferredPid: preferred, + choose: { [weak self] target in self?.start(target) }, + cancel: { [weak self] in self?.finish() }) + } + + /// Stops and hands the movie to the host, as the controls' use button does. + public func stopAndUse() { controller?.stop() } + + public func discard() { controller?.discard() } + + /// Brings the controls of a running recording forward. + public func showControls() { controls?.show() } + + /// Chooses in the open picker as a click on its row does (the widget's driver and tests). False when no picker is + /// open or `pick` finds nothing in the listed targets. + @discardableResult + public func choose(_ pick: (RecordingTargets) -> RecordingTarget?) -> Bool { + guard picker.isVisible, let targets, let target = pick(targets) else { return false } + picker.dismiss() + pickerChoose?(target) + return true + } + + /// One line for logs and the driver: the phase, why a start is slow, and whether the target outline shows. + public var stateDescription: String { + guard let controller else { return "idle picker=\(picker.isVisible)" } + return "phase=\(controller.phase.rawValue) preparing=\(controller.preparing ?? "-") " + + "outline=\(outlinedTarget?.title ?? "-") elapsedMs=\(Int(controller.elapsedMs))" + } + + // MARK: Picker + + private func openPicker(title: String, anchor: CGPoint, preferredPid: Int32?, + choose: @escaping (RecordingTarget) -> Void, cancel: @escaping () -> Void) { + pickerTitle = title + pickerAnchor = anchor + pickerChoose = choose + pickerCancel = cancel + pickerLoad = targets.map { .loaded($0) } ?? .loading + pickerInitial = controller?.target ?? targets?.defaultTarget(preferredPid: preferredPid) + pickerFilter = nil + presentPicker() + Task { [weak self] in await self?.refreshTargets(preferredPid: preferredPid) } + } + + private func refreshTargets(preferredPid: Int32?) async { + let previousLoad = pickerLoad + let previousInitial = pickerInitial + do { + let own = ProcessInfo.processInfo.processIdentifier + let loaded = try await loadTargets().excluding(pids: [own]) + targets = loaded + pickerLoad = .loaded(loaded) + if controller?.target == nil { pickerInitial = loaded.defaultTarget(preferredPid: preferredPid) } + } catch { + PerfLog.mark("recording.targets failed \(error.localizedDescription)") + pickerLoad = .failed("Could not list what to record: \(error.localizedDescription)") + } + // A new picker view resets the person's query, tab and arrow selection: skip it when nothing changed. + guard picker.isVisible, pickerLoad != previousLoad || pickerInitial != previousInitial else { return } + presentPicker() + } + + private func presentPicker() { + let view = RecordingTargetPicker( + load: pickerLoad, title: pickerTitle, initial: pickerInitial, + onChoose: { [weak self] target in + guard let self else { return } + self.picker.dismiss() + self.pickerChoose?(target) + }, + onCancel: { [weak self] in + guard let self else { return } + self.picker.dismiss() + self.pickerCancel?() + }, + onRetry: { [weak self] in + guard let self else { return } + self.pickerLoad = .loading + self.presentPicker() + Task { await self.refreshTargets(preferredPid: nil) } + }, + restoring: pickerFilter, + onFilterChange: { [weak self] tab, query in self?.pickerFilter = (tab, query) }) + picker.present(view, near: pickerAnchor) { [weak self] in self?.pickerCancel?() } + } + + // MARK: Session + + private func start(_ target: RecordingTarget) { + guard let host else { return } + Task { [weak self] in + guard let self else { return } + let need = PermissionNeed(.screenRecording, reason: host.permissionReason, grantWorksInNewProcess: true) + guard await self.permissionGate(need) else { + self.finish() + return + } + self.record(target) + } + } + + private func record(_ target: RecordingTarget) { + guard let host else { return } + let controller = RecordingSessionController(bridge: bridge) + let controls = RecordingControlsPanel() + self.controller = controller + self.controls = controls + controller.onDone = { [weak self] url, _ in self?.use(url) } + controller.onDiscarded = { [weak self] in + self?.host?.onDiscard() + self?.finish() + } + controller.onFailed = { [weak self] message, code in self?.failed(message, code: code) } + controllerWatch = controller.objectWillChange.sink { [weak self] _ in + DispatchQueue.main.async { self?.renderControls() } + } + settingsWatch = settings.objectWillChange.sink { [weak self] _ in + DispatchQueue.main.async { self?.renderControls() } + } + let outline = RecordingTargetOutline() + self.outline = outline + renderControls() + place(target) + // The controls are on screen before the recorder lists windows, so it can leave them out by id. + controls.show() + do { + try controller.start(target: target, excludeWindowIDs: [controls.windowID] + outline.windowIDs, + frameRate: settings.frameRate, showsBorder: settings.showsBorder) + host.onStart() + } catch { + failed("Could not start the recorder: \(error.localizedDescription)", code: nil) + } + } + + /// The view goes in once per recording; each tick only changes its state, so what the view holds itself (an open + /// discard question) survives the clock. + private func renderControls() { + guard let controller, let controls, let host else { return } + renderOutline(phase: controller.phase, target: controller.target) + let state = RecordingControlsState( + phase: controller.phase, elapsedMs: controller.elapsedMs, limitMs: controller.limitMs, + target: controller.target, pauseReason: controller.pauseReason, notice: controller.notice, + preparing: controller.preparing, useTitle: host.useTitle, useSymbol: host.useSymbol, + confirmsDiscard: settings.confirmsDiscard) + if let controlsModel { + if controlsModel.state != state { controlsModel.state = state } + return + } + let model = RecordingControlsModel(state: state) + controlsModel = model + controls.setContent(RecordingControlsHost(model: model, actions: RecordingControlsActions( + pause: { [weak controller] in controller?.pause() }, + resume: { [weak controller] in controller?.resume() }, + switchTarget: { [weak self] in self?.switchTarget() }, + restart: { [weak controller] in controller?.restart() }, + use: { [weak controller] in controller?.stop() }, + discard: { [weak controller] in controller?.discard() }, + dismissNotice: { [weak controller] in controller?.notice = nil }, + moved: { [weak controls] in controls?.noteMoved() }))) + } + + /// Pauses (so the picker never shows in the movie), lets the person choose, then switches or resumes. + private func switchTarget() { + guard let controller, let controls, !picker.isVisible else { return } + let wasRecording = controller.phase == .recording || controller.phase == .starting + if wasRecording { controller.pause() } + let frame = controls.window.frame + openPicker(title: "Switch to", anchor: CGPoint(x: frame.midX, y: frame.minY), preferredPid: nil, + choose: { [weak self] target in + self?.controller?.switchTo(target) + self?.place(target) + }, + cancel: { [weak self] in + if wasRecording { self?.controller?.resume() } + }) + } + + /// The dashed outline stands in for the recorder's own border until the first frame: it shows what was chosen + /// at once, also through a slow start. It follows the border setting. + private func renderOutline(phase: RecordingPhase, target: RecordingTarget?) { + guard let outline else { return } + let wanted = phase == .starting && settings.showsBorder ? target : nil + guard wanted != outlinedTarget else { return } + outlinedTarget = wanted + guard let wanted else { return outline.hide() } + outline.show(outlineRects(wanted)) + } + + private func outlineRects(_ target: RecordingTarget) -> [CGRect] { + guard let windows = listedWindows(target) else { + guard case let .display(index, _) = target, NSScreen.screens.indices.contains(index) else { return [] } + return [NSScreen.screens[index].frame] + } + let primaryHeight = NSScreen.screens.first?.frame.height ?? 0 + return windows.map { RecordingPanelLayout.cocoa(rect($0), primaryHeight: primaryHeight) } + } + + private func place(_ target: RecordingTarget) { + guard let controls else { return } + var display: Int? + if case let .display(index, _) = target { display = index } + let bounds = listedWindows(target).flatMap { windows in + windows.map(rect).reduce(nil) { partial, rect in partial.map { $0.union(rect) } ?? rect } + } + controls.place(settings.placement, targetCG: bounds, displayIndex: display) + } + + /// The listed windows a target records (CG points); nil for a display. + private func listedWindows(_ target: RecordingTarget) -> [RecordingWindow]? { + switch target { + case let .window(ids, _, _, _): return targets?.windows.filter { ids.contains($0.id) } ?? [] + case let .app(app, _): return targets?.windows.filter { String($0.pid) == app } ?? [] + case .display: return nil + } + } + + private func rect(_ window: RecordingWindow) -> CGRect { + CGRect(x: window.bounds.x, y: window.bounds.y, width: window.bounds.width, height: window.bounds.height) + } + + private func use(_ url: URL) { + guard let host else { return finish() } + Task { [weak self] in + do { + try await host.onUse(url) + } catch { + host.onError("The recording is saved at \(url.path), but it could not be used: \(error.localizedDescription)") + } + self?.finish() + } + } + + private func failed(_ message: String, code: String?) { + let host = self.host + finish() + if code == WidgetCaptureError.screenRecordingDenied, let host { + let need = PermissionNeed(.screenRecording, reason: host.permissionReason, grantWorksInNewProcess: true) + Task { [permissionGate] in + if await permissionGate(need) { host.onError(message) } + } + return + } + host?.onError(message) + } + + private func finish() { + picker.dismiss() + controls?.close() + controls = nil + outline?.close() + outline = nil + outlinedTarget = nil + controlsModel = nil + controllerWatch = nil + settingsWatch = nil + controller = nil + host = nil + isActive = false + if Self.running === self { Self.running = nil } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift b/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift index 06b2b9ce0d..7be437f335 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Sessions/Transcript/SessionTranscriptList.swift @@ -1241,13 +1241,23 @@ private struct Avatar: View { let glyph: String let color: Color var solid = false + /// An agent's id or alias: its mark (`AIProviderMark`) replaces the letter. + var provider: String? var body: some View { - Text(verbatim: glyph) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(solid ? Color.black.opacity(0.85) : color) - .frame(width: 22, height: 22) - .background(Circle().fill(solid ? color : color.opacity(0.18))) + Group { + if let kind = provider.flatMap(AIProviderMark.Kind.init(provider:)) { + AIProviderMark(kind) + .fill(solid ? Color.black.opacity(0.85) : color, style: FillStyle(eoFill: true)) + .padding(5) + } else { + Text(verbatim: glyph) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(solid ? Color.black.opacity(0.85) : color) + } + } + .frame(width: 22, height: 22) + .background(Circle().fill(solid ? color : color.opacity(0.18))) } } @@ -1467,7 +1477,7 @@ private struct ReplyBlock: View { private var authorRow: some View { HStack(spacing: 8) { - Avatar(glyph: provider.glyph, color: provider.color) + Avatar(glyph: provider.glyph, color: provider.color, provider: provider.alias) Text(verbatim: provider.displayName) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(SessionPalette.text) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubPreferences.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubPreferences.swift index 6deff1fd33..201b486093 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubPreferences.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubPreferences.swift @@ -15,6 +15,26 @@ public enum HubWindowStyle: String, CaseIterable, Identifiable, Sendable { } } +/// What an agent's (or a link's) request to show a place does to the hub: it navigates there, as a click would, +/// or it only adds the place to the history list and leaves the view alone. Either way Back returns. +public enum HubAgentNavigation: String, CaseIterable, Identifiable, Sendable { + case navigate, listOnly + public var id: String { rawValue } + public var title: String { + switch self { + case .navigate: return "Show it" + case .listOnly: return "Only add it to History" + } + } + + public var detail: String { + switch self { + case .navigate: return "The hub moves to what the agent opened. Back returns to where you were." + case .listOnly: return "The view stays. The place waits in History (⌘Y) until you open it." + } + } +} + /// The hub's preferences that a person sets, shared by the hub window (`--hub`) and the Hub pages of the settings /// window (`--features`). Both faces run from one bundle, so they read one defaults domain. A write posts a distributed /// notification, and every other process reloads, as `NativeSettingsAppearance` does for General. @@ -28,11 +48,16 @@ public final class HubPreferences: ObservableObject { public static let glass = "hub.glass" public static let processEnergy = "hub.procs.energy" public static let cleanupIdleDays = "hub.worktreeCleanup.olderThanDays" + public static let navigationLimit = "hub.nav.limit" + public static let agentNavigation = "hub.nav.agentNavigation" } public static let shared = HubPreferences() /// The idle days a worktree needs before cleanup counts it as removable; 0 means any idle time. public static let cleanupIdleDaysRange = 0...90 + /// How many places the navigation history keeps (`tools hub nav`); the oldest go first. + public static let navigationLimitRange = 20...2000 + public static let navigationLimitDefault = 200 @Published public var style: HubWindowStyle { didSet { if oldValue != style { persist(.style) } } } /// The Hub style only: the desktop shows through, blurred. The Native style follows General → Theme instead. @@ -44,8 +69,16 @@ public final class HubPreferences: ObservableObject { @Published public var cleanupIdleDays: Int { didSet { if oldValue != cleanupIdleDays { persist(.cleanupIdleDays) } } } + /// The places Back, Forward and History keep. + @Published public var navigationLimit: Int { + didSet { if oldValue != navigationLimit { persist(.navigationLimit) } } + } + /// What an agent's request to show a place does: navigate (the default) or only add it to History. + @Published public var agentNavigation: HubAgentNavigation { + didSet { if oldValue != agentNavigation { persist(.agentNavigation) } } + } - private enum Field { case style, glass, processEnergy, cleanupIdleDays } + private enum Field { case style, glass, processEnergy, cleanupIdleDays, navigationLimit, agentNavigation } public let notificationNamespace: String public let notificationName: Notification.Name private let defaults: UserDefaults @@ -70,6 +103,8 @@ public final class HubPreferences: ObservableObject { glass = defaults.bool(forKey: Key.glass) processEnergy = defaults.bool(forKey: Key.processEnergy) cleanupIdleDays = Self.storedIdleDays(defaults) + navigationLimit = Self.storedNavigationLimit(defaults) + agentNavigation = Self.storedAgentNavigation(defaults) if observeExternalChanges { observer = DistributedNotificationCenter.default().addObserver( forName: notificationName, object: notificationNamespace, queue: .main @@ -91,11 +126,15 @@ public final class HubPreferences: ObservableObject { let storedGlass = defaults.bool(forKey: Key.glass) let storedEnergy = defaults.bool(forKey: Key.processEnergy) let storedDays = Self.storedIdleDays(defaults) + let storedLimit = Self.storedNavigationLimit(defaults) + let storedAgent = Self.storedAgentNavigation(defaults) applyingExternal = true if style != storedStyle { style = storedStyle } if glass != storedGlass { glass = storedGlass } if processEnergy != storedEnergy { processEnergy = storedEnergy } if cleanupIdleDays != storedDays { cleanupIdleDays = storedDays } + if navigationLimit != storedLimit { navigationLimit = storedLimit } + if agentNavigation != storedAgent { agentNavigation = storedAgent } applyingExternal = false } @@ -107,6 +146,16 @@ public final class HubPreferences: ObservableObject { min(cleanupIdleDaysRange.upperBound, max(cleanupIdleDaysRange.lowerBound, defaults.integer(forKey: Key.cleanupIdleDays))) } + /// Unset reads as the default; a stored value outside the range reads as its nearest limit. + private static func storedNavigationLimit(_ defaults: UserDefaults) -> Int { + guard defaults.object(forKey: Key.navigationLimit) != nil else { return navigationLimitDefault } + return min(navigationLimitRange.upperBound, max(navigationLimitRange.lowerBound, defaults.integer(forKey: Key.navigationLimit))) + } + + private static func storedAgentNavigation(_ defaults: UserDefaults) -> HubAgentNavigation { + HubAgentNavigation(rawValue: defaults.string(forKey: Key.agentNavigation) ?? "") ?? .navigate + } + private func persist(_ field: Field) { guard !applyingExternal else { return } switch field { @@ -116,6 +165,10 @@ public final class HubPreferences: ObservableObject { case .cleanupIdleDays: let clamped = min(Self.cleanupIdleDaysRange.upperBound, max(Self.cleanupIdleDaysRange.lowerBound, cleanupIdleDays)) defaults.set(clamped, forKey: Key.cleanupIdleDays) + case .navigationLimit: + let clamped = min(Self.navigationLimitRange.upperBound, max(Self.navigationLimitRange.lowerBound, navigationLimit)) + defaults.set(clamped, forKey: Key.navigationLimit) + case .agentNavigation: defaults.set(agentNavigation.rawValue, forKey: Key.agentNavigation) } guard broadcasts else { return } if !defaults.synchronize() { log.warning("Could not flush the hub preferences") } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubSettingsPages.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubSettingsPages.swift index ce4f897998..9b8f1fd35d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubSettingsPages.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/HubSettingsPages.swift @@ -6,6 +6,7 @@ import SwiftUI public enum HubSettingsPages { public static let appearancePageID = "hub.appearance" public static let panesPageID = "hub.panes" + public static let historyPageID = "hub.history" public static func section(preferences: HubPreferences? = nil) -> NativeSettingsSection { let preferences = preferences ?? .shared @@ -24,6 +25,12 @@ public enum HubSettingsPages { ) { HubPaneSettings(preferences: preferences) }, + NativeSettingsPage( + id: historyPageID, title: "History", symbol: "clock.arrow.circlepath", tint: .purple, + subtitle: "Back, Forward and the list of places the hub showed, yours and the agents'." + ) { + HubHistorySettings(preferences: preferences) + }, ], order: 35) } } @@ -115,3 +122,39 @@ private struct HubPaneSettings: View { } } } + +@MainActor +private struct HubHistorySettings: View { + @ObservedObject var preferences: HubPreferences + + var body: some View { + VStack(spacing: 18) { + NativeSettingsCard( + "When an agent opens something", + subtitle: "An agent or a link names a PR, a commit, a worktree or a session (tools hub nav open)." + ) { + NativeSettingsSegmented( + "Agent navigation", identifier: "hub.nav.agentNavigation", + options: HubAgentNavigation.allCases.map { NativeSettingsOption($0, $0.title, detail: $0.detail) }, + selection: $preferences.agentNavigation, minSegmentWidth: 90) + } + NativeSettingsCard("History") { + NativeSettingsRow( + "Keep the last", detail: "Older places leave History, Back and Forward. The log is in ~/.genesis-tools/hub/data.db." + ) { + HStack(spacing: 8) { + NativeSettingsNumberPicker( + "Places kept", + range: HubPreferences.navigationLimitRange.lowerBound + ..< (HubPreferences.navigationLimitRange.upperBound + 1), + identifier: "hub.nav.limit", value: $preferences.navigationLimit) + Text("places").font(.system(size: 12)).foregroundStyle(.secondary) + } + } + } + Text("⌘[ and ⌘] go back and forward; ⌘Y lists every place. Changes apply to a running hub at once.") + .font(.system(size: 11)).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift index cb6d0c11ed..d42fcd2f18 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift @@ -53,7 +53,11 @@ public struct NativeGlassShapeSurface<SurfaceShape: Shape>: ViewModifier { content.background(NativeThemePalette.graphiteCard, in: shape) .overlay(shape.stroke(.white.opacity(0.08), lineWidth: 1)) } else if #available(macOS 26, *) { + // SwiftUI does not hit-test Liquid Glass itself, so a gap between two rows on a glass panel was not + // part of it: hover there read as "the pointer left", and the widget's hover list closed under a + // scrolling pointer each time a gap passed (Martin, 2026-10-24). The solid form's fill always counted. content.foregroundStyle(Color.white).glassEffect(.regular.tint(NativeThemePalette.glassCardTint), in: shape) + .contentShape(shape) } else { content.background { ZStack { @@ -130,12 +134,15 @@ public struct NativeGlassControl: ViewModifier { } else if opaqueControl { content.modifier(NativeGlassSurface(radius: radius, opaque: true)) } else if #available(macOS 26, *) { + // The whole control answers the pointer, as its opaque form does (glass itself is not hit-tested). if systemStill || requestedStill { content.foregroundStyle(.white).glassEffect( .regular.tint(tint), in: RoundedRectangle(cornerRadius: radius)) + .contentShape(RoundedRectangle(cornerRadius: radius)) } else { content.foregroundStyle(.white).glassEffect( .regular.tint(tint).interactive(), in: RoundedRectangle(cornerRadius: radius)) + .contentShape(RoundedRectangle(cornerRadius: radius)) } } else { content.modifier(NativeGlassSurface(radius: radius)) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift index 4f6b94db69..0540624987 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift @@ -16,7 +16,12 @@ public struct NativeSettingsDisclosure<Content: View>: View { private let identifier: String private let content: Content @State private var expanded: Bool + /// The search keys of the rows inside, read from the folded copy, so a search jump to one of them opens this. + @State private var keys: [String] = [] @GenReduceMotion private var reduceMotion + @Environment(\.nativeSettingsIndexing) private var indexing + @Environment(\.nativeSettingsPageID) private var page + @ObservedObject private var center = NativeSettingsHighlightCenter.shared public init(_ title: String, identifier: String, @ViewBuilder content: () -> Content) { self.title = title @@ -41,16 +46,46 @@ public struct NativeSettingsDisclosure<Content: View>: View { .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.genHoverRow(cornerRadius: KitTheme.radiusSmall)) .nativeSettingsPointer() .nativeSettingsDisabledLook() .accessibilityIdentifier(identifier) .accessibilityLabel(title) .accessibilityValue(expanded ? "Expanded" : "Collapsed") - if expanded { content.padding(.top, 8).transition(.genReveal) } + if expanded || indexing { + content.padding(.top, 8).transition(.genReveal) + } else { + folded + } } // The section closes over its content; a few points of slack keep a control's focus ring. .clipShape(Rectangle().inset(by: -3)) + .onChange(of: center.token) { _, _ in openForSearch() } + .onChange(of: keys) { _, _ in openForSearch() } + } + + /// The content while folded: drawn with no height and hidden, as the search indexer draws other pages, so the + /// settings search still lists the rows inside. + private var folded: some View { + content + .environment(\.nativeSettingsIndexing, true) + .frame(height: 0) + .clipped() + .hidden() + .disabled(true) + .allowsHitTesting(false) + .accessibilityHidden(true) + .onPreferenceChange(NativeSettingsSearchKey.self) { entries in + MainActor.assumeIsolated { keys = entries.map(\.key) } + } + } + + /// Opens the section when a fresh search jump names a row inside it. + private func openForSearch() { + guard !expanded, !indexing, let page, let anchor = center.anchor, let at = center.requestedAt, + Date().timeIntervalSince(at) < 2, + keys.contains(where: { NativeSettingsSearch.anchor(page: page, key: $0) == anchor }) else { return } + expanded = true } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift index 065b17b104..506c2ec650 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift @@ -56,6 +56,69 @@ public enum NativeSettingsCatalogError: Error, Equatable, CustomStringConvertibl } } +/// A settings link can name a card on its page: `widgets.sessions#inbox-preview` selects the page and pulses the card +/// that carries `.nativeSettingsAnchor("inbox-preview")`. One settings window per process, so one shared center. +@MainActor +public final class NativeSettingsHighlightCenter: ObservableObject { + public static let shared = NativeSettingsHighlightCenter() + @Published public private(set) var anchor: String? + /// Bumped on every request, so the same card asked for twice pulses twice. + @Published public private(set) var token = 0 + /// The last request a card pulsed for, so going back to the page does not replay an old one. + private var deliveredToken = 0 + /// When the last request came: a card that appears later (a page that opens on it) flashes only what is left of + /// the flash, and a page visited again later does not flash or scroll again. + @Published public private(set) var requestedAt: Date? + + public func request(_ anchor: String) { + self.anchor = anchor + requestedAt = Date() + token += 1 + } + + /// True once per request, for the card that carries `anchor`. + func consume(_ anchor: String) -> Bool { + guard self.anchor == anchor, deliveredToken != token else { return false } + deliveredToken = token + return true + } + + /// "page#anchor" into its page id and the anchor; a plain page id has no anchor. + public nonisolated static func split(_ requested: String) -> (page: String, anchor: String?) { + guard let mark = requested.firstIndex(of: "#") else { return (requested, nil) } + let anchor = String(requested[requested.index(after: mark)...]) + return (String(requested[..<mark]), anchor.isEmpty ? nil : anchor) + } +} + +private struct NativeSettingsAnchor: ViewModifier { + let id: String + @ObservedObject private var center = NativeSettingsHighlightCenter.shared + @State private var pulses = 0 + + func body(content: Content) -> some View { + content + .attentionPulse(trigger: pulses) + // A window that opens on the page asks before the card exists: the card pulses once it is on screen. + .onAppear { if center.consume(id) { pulseSoon() } } + .onChange(of: center.token) { _, _ in if center.consume(id) { pulseSoon() } } + } + + private func pulseSoon() { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(350)) + pulses += 1 + } + } +} + +extension View { + /// Names this card for a settings link (`<page>#<id>`): following the link pulses it. + public func nativeSettingsAnchor(_ id: String) -> some View { + modifier(NativeSettingsAnchor(id: id)) + } +} + @MainActor public final class NativeSettingsStore: ObservableObject { @Published public private(set) var sections: [NativeSettingsSection] @@ -78,25 +141,30 @@ public final class NativeSettingsStore: ObservableObject { catalogError = error let acceptedSections = error == nil ? sections.sorted { $0.order < $1.order } : [] self.sections = acceptedSections - let requested = initialPageID ?? defaults.string(forKey: selectionKey) + let link = initialPageID.map(NativeSettingsHighlightCenter.split) + let requested = link?.page ?? defaults.string(forKey: selectionKey) let pages = acceptedSections.flatMap(\.pages) selectedPageID = pages.first(where: { $0.id == requested })?.id ?? pages.first?.id pendingPageID = requested != selectedPageID ? requested : nil + if let anchor = link?.anchor { NativeSettingsHighlightCenter.shared.request(anchor) } } public var selectedPage: NativeSettingsPage? { sections.lazy.flatMap(\.pages).first { $0.id == selectedPageID } } + /// `pageID` may name a card too (`<page>#<anchor>`): the page opens and the card pulses. @discardableResult - public func select(pageID: String) -> Bool { + public func select(pageID requested: String) -> Bool { + let (pageID, anchor) = NativeSettingsHighlightCenter.split(requested) guard sections.contains(where: { $0.pages.contains(where: { $0.id == pageID }) }) else { - pendingPageID = pageID + pendingPageID = requested return false } pendingPageID = nil selectedPageID = pageID defaults.set(pageID, forKey: selectionKey) + if let anchor { NativeSettingsHighlightCenter.shared.request(anchor) } return true } @@ -115,7 +183,9 @@ public final class NativeSettingsStore: ObservableObject { catalogError = nil candidate.sort { $0.order < $1.order } sections = candidate - if let pendingPageID, candidate.contains(where: { $0.pages.contains(where: { $0.id == pendingPageID }) }) { + if let pendingPageID, candidate.contains(where: { + $0.pages.contains(where: { $0.id == NativeSettingsHighlightCenter.split(pendingPageID).page }) + }) { return select(pageID: pendingPageID) } if selectedPage == nil { selectedPageID = candidate.lazy.flatMap(\.pages).first?.id } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift new file mode 100644 index 0000000000..04e27cc456 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -0,0 +1,290 @@ +import SwiftUI + +// The settings window's search (H4): one field above the page list finds a setting on any page by its card title, its +// row title or its detail, and a click opens the page there and flashes the card or row. +// +// Pages are views, not data, so the search learns their cards and rows from what they draw: each titled +// `NativeSettingsCard` and each `NativeSettingsRow` reports itself through a preference, the page on screen keeps its +// entries current, and the first search of a window draws every other page once, hidden, to learn theirs. + +/// One thing the search finds on a page: a titled card or a row, with the words it shows. +public struct NativeSettingsSearchEntry: Equatable, Sendable { + public enum Kind: String, Sendable { case card, row } + + public let kind: Kind + public let title: String + public let detail: String? + /// The titled card a row sits in. + public var card: String? + + public init(kind: Kind, title: String, detail: String? = nil, card: String? = nil) { + self.kind = kind + self.title = title + self.detail = detail + self.card = card + } + + /// The entry's name on its page. A row's names its card too: one row title can repeat in several cards (every + /// background task has a Schedule row), and each must be its own result and jump target. + var key: String { "\(kind.rawValue):\(card.map { "\($0)/" } ?? "")\(title)" } +} + +/// A settings page as the search sees it: its words, without its views. +public struct NativeSettingsSearchPage: Equatable, Sendable { + public let id: String + public let title: String + public let subtitle: String + public let section: String + + public init(id: String, title: String, subtitle: String = "", section: String = "") { + self.id = id + self.title = title + self.subtitle = subtitle + self.section = section + } +} + +/// One result: what the list shows, and where a click goes (`<page>` or `<page>#<anchor>`). +public struct NativeSettingsSearchResult: Identifiable, Equatable, Sendable { + public let target: String + public let title: String + public let context: String + public var id: String { target } +} + +public enum NativeSettingsSearch { + /// The anchor of a card or row on a page, as a jump requests it and the card or row answers to it. + static func anchor(page: String, key: String) -> String { "search/\(page)/\(key)" } + + /// The results for `query`, best first. Every word of the query must appear in the result's title, its detail, its + /// card or its page. A title that starts with the query ranks first, then one that contains it, then one that holds + /// every word, then a match in the other words; within a rank the sidebar's order holds. Case and accents are + /// ignored. + public static func results( + _ query: String, pages: [NativeSettingsSearchPage], entries: [String: [NativeSettingsSearchEntry]], limit: Int = 60 + ) -> [NativeSettingsSearchResult] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return [] } + let words = needle.split(whereSeparator: \.isWhitespace).map(String.init) + func found(_ fields: [String?]) -> Bool { + words.allSatisfy { word in fields.contains { $0?.localizedStandardContains(word) == true } } + } + func rank(_ title: String) -> Int { + if title.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive, .anchored]) != nil { return 0 } + if title.localizedStandardContains(needle) { return 1 } + return found([title]) ? 2 : 3 + } + var ranked: [(rank: Int, order: Int, result: NativeSettingsSearchResult)] = [] + var seen: Set<String> = [] + func add(_ result: NativeSettingsSearchResult) { + guard seen.insert(result.target).inserted else { return } + ranked.append((rank(result.title), ranked.count, result)) + } + for page in pages { + if found([page.title, page.subtitle, page.section]) { + add(NativeSettingsSearchResult( + target: page.id, title: page.title, context: page.section.isEmpty ? page.subtitle : page.section)) + } + for entry in entries[page.id] ?? [] where found([entry.title, entry.detail, entry.card, page.title]) { + let context = entry.card.map { $0 == entry.title ? page.title : "\(page.title) · \($0)" } ?? page.title + add(NativeSettingsSearchResult( + target: "\(page.id)#\(anchor(page: page.id, key: entry.key))", title: entry.title, context: context)) + } + } + return ranked.sorted { ($0.rank, $0.order) < ($1.rank, $1.order) }.prefix(limit).map(\.result) + } +} + +/// What the search knows of each page, by page id. Not observed by the window itself, so a page that reports its +/// entries redraws only the result list. +@MainActor +final class NativeSettingsSearchIndex: ObservableObject { + @Published private(set) var entries: [String: [NativeSettingsSearchEntry]] = [:] + + func set(_ page: String, _ list: [NativeSettingsSearchEntry]) { + if entries[page] != list { entries[page] = list } + } + + /// A page that was drawn at least once, entries or not. + func markSeen(_ page: String) { + if entries[page] == nil { entries[page] = [] } + } + + func knows(_ page: String) -> Bool { entries[page] != nil } +} + +struct NativeSettingsSearchKey: PreferenceKey { + static let defaultValue: [NativeSettingsSearchEntry] = [] + + static func reduce(value: inout [NativeSettingsSearchEntry], nextValue: () -> [NativeSettingsSearchEntry]) { + value.append(contentsOf: nextValue()) + } +} + +extension EnvironmentValues { + /// The settings page a card or row is drawn on; nil outside the settings window. + @Entry var nativeSettingsPageID: String? = nil + /// The title of the titled card a row is drawn in, so the row's search key and its jump target name the card. + @Entry var nativeSettingsSearchCard: String? = nil + /// True in the hidden copy of the pages the search reads: nothing there flashes, and a page's own code must not + /// speak (`GenAnnounce`) or ask for anything, since nobody sees it. + @Entry public var nativeSettingsIndexing = false +} + +/// A card or row the search can jump to: when a jump names it, it flashes, and its marker is what the page scrolls to. +private struct NativeSettingsSearchTarget: ViewModifier { + let key: String + let cornerRadius: CGFloat + let outset: CGFloat + @Environment(\.nativeSettingsPageID) private var page + @Environment(\.nativeSettingsIndexing) private var indexing + @ObservedObject private var center = NativeSettingsHighlightCenter.shared + + func body(content: Content) -> some View { + content.overlay { + if !indexing, let page, let anchor = center.anchor, anchor == NativeSettingsSearch.anchor(page: page, key: key) { + Color.clear + .padding(-outset) + .attentionPulse( + trigger: center.token, + style: .borderFlash(color: .accentColor, cornerRadius: cornerRadius), + since: center.requestedAt + ) + .id(anchor) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + } +} + +extension View { + /// Reports a card or row to the settings search and makes it a jump target. + func nativeSettingsSearchable(_ entry: NativeSettingsSearchEntry, cornerRadius: CGFloat, outset: CGFloat = 0) -> some View { + modifier(NativeSettingsSearchTarget(key: entry.key, cornerRadius: cornerRadius, outset: outset)) + .transformPreference(NativeSettingsSearchKey.self) { entries in + if entry.kind == .card { + for index in entries.indices where entries[index].card == nil { entries[index].card = entry.title } + entries.insert(entry, at: 0) + } else { + entries.append(entry) + } + } + } +} + +/// Every page but the one on screen, drawn once and hidden while a search runs, so the search learns their cards and +/// rows. Their own loads run as if each page had been opened; nothing is drawn, hit or read by VoiceOver. +struct NativeSettingsSearchIndexer: View { + @ObservedObject var store: NativeSettingsStore + let search: NativeSettingsSearchIndex + + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(store.sections.flatMap(\.pages).filter { $0.id != store.selectedPageID }) { page in + let id = page.id + page.content() + .environment(\.nativeSettingsPageID, id) + .onPreferenceChange(NativeSettingsSearchKey.self) { entries in + MainActor.assumeIsolated { search.set(id, entries) } + } + .onAppear { search.markSeen(id) } + } + } + .environment(\.nativeSettingsIndexing, true) + .frame(width: 620) + .fixedSize(horizontal: false, vertical: true) + .hidden() + .disabled(true) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +/// The search field and its results, in the sidebar above the page list. Return opens the first result, Esc empties +/// the field, ⌘F puts the cursor in it. +struct NativeSettingsSearchField: View { + @Binding var query: String + let focused: FocusState<Bool>.Binding + let submit: () -> Void + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundStyle(.secondary) + .accessibilityHidden(true) + TextField("Search settings", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused(focused) + .onSubmit(submit) + .onExitCommand { query = "" } + .accessibilityIdentifier("settings.search") + if !query.isEmpty { + IconButton(systemName: "xmark.circle.fill", tooltip: "Clear the search", size: 11) { + query = "" + focused.wrappedValue = true + } + } + } + .padding(.horizontal, 10) + .frame(height: 30) + .background(RoundedRectangle(cornerRadius: 8).fill(.white.opacity(0.05))) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(.white.opacity(0.1), lineWidth: 0.5)) + .background { + Button("Search settings") { focused.wrappedValue = true } + .keyboardShortcut("f", modifiers: .command) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + } +} + +/// The results of a search, in the place of the page list. +struct NativeSettingsSearchResults: View { + let query: String + @ObservedObject var store: NativeSettingsStore + @ObservedObject var search: NativeSettingsSearchIndex + let open: (NativeSettingsSearchResult) -> Void + let clear: () -> Void + + static func pages(_ store: NativeSettingsStore) -> [NativeSettingsSearchPage] { + store.sections.flatMap { section in + section.pages.map { + NativeSettingsSearchPage(id: $0.id, title: $0.title, subtitle: $0.subtitle, section: section.title) + } + } + } + + var body: some View { + let results = NativeSettingsSearch.results(query, pages: Self.pages(store), entries: search.entries) + VStack(alignment: .leading, spacing: 1) { + if results.isEmpty { + EmptyState( + symbol: "magnifyingglass", + text: "No setting matches “\(query.trimmingCharacters(in: .whitespacesAndNewlines))”.", + actionTitle: "Clear the search", action: clear) + } else { + Text(Plural.count(results.count, "result")) + .font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary) + .padding(.horizontal, 10).padding(.bottom, 4) + .accessibilityAddTraits(.isHeader) + ForEach(results) { result in + Button { open(result) } label: { + VStack(alignment: .leading, spacing: 2) { + Text(result.title).font(.system(size: 13)).lineLimit(2) + if !result.context.isEmpty { + Text(result.context).font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10).padding(.vertical, 5).contentShape(Rectangle()) + } + .buttonStyle(.genHoverRow()) + .instantTooltip(result.context.isEmpty ? "Open \(result.title)" : "Open \(result.title) in \(result.context)") + .accessibilityIdentifier("settings.search.result.\(result.target)") + } + } + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift index 454ba9ff62..3e8228d18c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift @@ -1,6 +1,7 @@ import SwiftUI public struct NativeSettingsCard<Content: View>: View { + @Environment(\.nativeSettingsSearchCard) private var searchCard private let title: String? private let subtitle: String? private let content: Content @@ -12,21 +13,28 @@ public struct NativeSettingsCard<Content: View>: View { } public var body: some View { - VStack(alignment: .leading, spacing: 16) { + let card = VStack(alignment: .leading, spacing: 16) { if let title { VStack(alignment: .leading, spacing: 5) { Text(title).font(.system(size: 13, weight: .semibold)) if let subtitle { Text(subtitle).font(.system(size: 11)).foregroundStyle(.secondary) } } } - content + content.environment(\.nativeSettingsSearchCard, title ?? searchCard) }.padding(20).frame(maxWidth: .infinity, alignment: .leading) .nativeGlassSurface() + // A titled card is a place the settings search finds and flashes (NativeSettingsSearch.swift). + if let title { + card.nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .card, title: title, detail: subtitle), cornerRadius: 22) + } else { + card + } } } public struct NativeSettingsRow<Accessory: View>: View { @Environment(\.isEnabled) private var isEnabled + @Environment(\.nativeSettingsSearchCard) private var searchCard private let title: String private let detail: String? private let accessory: Accessory @@ -55,6 +63,8 @@ public struct NativeSettingsRow<Accessory: View>: View { Spacer(minLength: 0) accessory } + .nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .row, title: title, detail: detail, card: searchCard), + cornerRadius: 10, outset: 8) } } @@ -105,7 +115,9 @@ public struct FeatureSettingsView: View { @ObservedObject private var store: NativeSettingsStore @ObservedObject private var appearance: NativeSettingsAppearance @Environment(\.accessibilityReduceMotion) private var systemStill - @Namespace private var navigation + /// What the settings search knows of each page. Held, not observed: a page that reports its cards and rows redraws + /// the result list, never this window and the page on screen. + @State private var search = NativeSettingsSearchIndex() private let title: String public init(store: NativeSettingsStore, title: String = "Settings") { @@ -116,7 +128,7 @@ public struct FeatureSettingsView: View { public var body: some View { HStack(spacing: 0) { - sidebar + NativeSettingsSidebar(store: store, appearance: appearance, search: search, title: title) Rectangle().fill(.white.opacity(0.075)).frame(width: 1) VStack(alignment: .leading, spacing: 0) { if let page = store.selectedPage { @@ -130,16 +142,9 @@ public struct FeatureSettingsView: View { } Spacer(minLength: 0) }.padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 23) - ScrollView { - VStack(alignment: .leading, spacing: 18) { - if let error = store.catalogError { - Label(error.description, systemImage: "exclamationmark.triangle") - .font(.system(size: 12)).foregroundStyle(.orange) - } - page.content() - }.padding(.horizontal, 28).padding(.bottom, 28) - .frame(maxWidth: .infinity, alignment: .leading) - }.id(page.id) + NativeSettingsPageScroll( + page: page, catalogError: store.catalogError, search: search, + still: systemStill || appearance.reduceMotion) } else { VStack(spacing: 12) { Image(systemName: "slider.horizontal.3").font(.system(size: 34)).foregroundStyle(.secondary) @@ -157,27 +162,114 @@ public struct FeatureSettingsView: View { .nativeSettingsAppearance(appearance) .preferredColorScheme(.dark) } +} + +/// The page on screen. It keeps the search's entries for the page current, and brings the card or row a search result +/// or a link asked for into view while it flashes. +@MainActor +private struct NativeSettingsPageScroll: View { + let page: NativeSettingsPage + let catalogError: NativeSettingsCatalogError? + let search: NativeSettingsSearchIndex + let still: Bool + @ObservedObject private var center = NativeSettingsHighlightCenter.shared + + var body: some View { + let id = page.id + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let error = catalogError { + Label(error.description, systemImage: "exclamationmark.triangle") + .font(.system(size: 12)).foregroundStyle(.orange) + } + page.content() + .environment(\.nativeSettingsPageID, id) + .onPreferenceChange(NativeSettingsSearchKey.self) { entries in + MainActor.assumeIsolated { + let had = search.entries[id].map(holdsTarget) ?? false + search.set(id, entries) + // A card drawn after its page loaded its settings: scroll once it is there. + if !had, holdsTarget(entries) { Task { await reveal(proxy) } } + } + } + }.padding(.horizontal, 28).padding(.bottom, 28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .task(id: center.token) { await reveal(proxy) } + } + .id(id) + } + + private func holdsTarget(_ entries: [NativeSettingsSearchEntry]) -> Bool { + guard let anchor = center.anchor else { return false } + return entries.contains { NativeSettingsSearch.anchor(page: page.id, key: $0.key) == anchor } + } - private var sidebar: some View { + /// Only a fresh request scrolls: a page visited again later stays where its reader left it. + private func reveal(_ proxy: ScrollViewProxy) async { + guard center.anchor != nil, let at = center.requestedAt, Date().timeIntervalSince(at) < 2 else { return } + try? await Task.sleep(for: .milliseconds(80)) + guard let anchor = center.anchor else { return } + let place = UnitPoint(x: 0.5, y: 0.15) + if still { + proxy.scrollTo(anchor, anchor: place) + } else { + withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(anchor, anchor: place) } + } + } +} + +/// The page list, and the search field above it. While the field holds text, its results take the list's place. +@MainActor +private struct NativeSettingsSidebar: View { + @ObservedObject var store: NativeSettingsStore + @ObservedObject var appearance: NativeSettingsAppearance + let search: NativeSettingsSearchIndex + let title: String + @Environment(\.accessibilityReduceMotion) private var systemStill + @Namespace private var navigation + @State private var query = "" + @FocusState private var searchFocused: Bool + /// The hidden copy of the other pages is drawn: from a search that starts while a page is still unknown, until the + /// field is empty again. Later searches use what it learned. + @State private var indexing = false + + private var searching: Bool { !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { Image(systemName: "slider.horizontal.3").font(.system(size: 19, weight: .medium)) .foregroundStyle(.white.opacity(0.7)) Text(title).font(.system(size: 24, weight: .semibold, design: .rounded)) - }.padding(.horizontal, 24).padding(.top, 28).padding(.bottom, 16) + }.padding(.horizontal, 24).padding(.top, 28).padding(.bottom, 14) + NativeSettingsSearchField(query: $query, focused: $searchFocused, submit: openFirst) + .padding(.horizontal, 12).padding(.bottom, 12) // The list scrolls under the header with a visible scroller and room after the last row, so the last // page is never hidden behind a footer (it was: "Providers" sat half cut above one in a 760 pt window). ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(store.sections) { section in - VStack(alignment: .leading, spacing: 1) { - if !section.title.isEmpty { - Text(section.title).font(.system(size: 11, weight: .semibold)).foregroundStyle( - .secondary - ) - .padding(.horizontal, 10).padding(.bottom, 4) + Group { + if searching { + NativeSettingsSearchResults( + query: query, store: store, search: search, open: open, + clear: { + query = "" + searchFocused = true + }) + } else { + VStack(alignment: .leading, spacing: 12) { + ForEach(store.sections) { section in + VStack(alignment: .leading, spacing: 1) { + if !section.title.isEmpty { + Text(section.title).font(.system(size: 11, weight: .semibold)).foregroundStyle( + .secondary + ) + .padding(.horizontal, 10).padding(.bottom, 4) + } + ForEach(section.pages) { page in navigationRow(page) } + } } - ForEach(section.pages) { page in navigationRow(page) } } } }.padding(.horizontal, 12).padding(.top, 2).padding(.bottom, 16) @@ -185,6 +277,26 @@ public struct FeatureSettingsView: View { .scrollIndicators(.automatic) }.frame(width: 238) .titlebarBackground(Color.white.opacity(0.025)) + .background(alignment: .topLeading) { + if indexing { NativeSettingsSearchIndexer(store: store, search: search) } + } + .onChange(of: searching) { _, now in + if !now { + indexing = false + } else if store.sections.flatMap(\.pages).contains(where: { !search.knows($0.id) && $0.id != store.selectedPageID }) { + indexing = true + } + } + } + + /// A page, or a card or row on it: the page opens, scrolls there, and flashes it. + private func open(_ result: NativeSettingsSearchResult) { + store.select(pageID: result.target) + } + + private func openFirst() { + let pages = NativeSettingsSearchResults.pages(store) + if let first = NativeSettingsSearch.results(query, pages: pages, entries: search.entries).first { open(first) } } private func navigationRow(_ page: NativeSettingsPage) -> some View { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift b/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift index 6a51be7b35..da5844ea6d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Stale-while-revalidate on screen: a view paints the last answer (`DiskCache`), marks it as @@ -9,7 +10,20 @@ import SwiftUI /// SWR.fade(moved, current: { changed }, clear: { changed = [] }) /// row.swrFlash(changed.contains(row.id)).transition(SWR.rowTransition) public enum SWR { - public static let animation: Animation = .spring(response: 0.35, dampingFraction: 0.85) + /// The refresh spring, or nil (an instant change) while Reduce Motion is on: the system's or the app's own + /// (`NativeSettingsAppearance`, the hub's General → Reduce motion), the two `@GenReduceMotion` reads. Read at + /// each refresh, so a change takes effect without a restart. + public static var animation: Animation? { + reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85) + } + + /// `withAnimation` runs on the main thread, and so does every caller; off it the app's setting cannot be read and + /// only the system's counts. + private static var reduceMotion: Bool { + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { return true } + guard Thread.isMainThread else { return false } + return MainActor.assumeIsolated { NativeSettingsAppearance.shared.reduceMotion } + } /// New rows slide in from the top; gone rows fade. public static let rowTransition: AnyTransition = .asymmetric( @@ -43,7 +57,7 @@ public enum SWR { Task { @MainActor in try? await Task.sleep(nanoseconds: 1_600_000_000) guard current() == flashed else { return } - withAnimation(.easeOut(duration: 0.6)) { clear() } + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.6)) { clear() } } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Tools/ToolsLineStream.swift b/src/macos/GenesisKit/Sources/GenesisKit/Tools/ToolsLineStream.swift index 902911ca3f..51315493e8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Tools/ToolsLineStream.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Tools/ToolsLineStream.swift @@ -138,10 +138,15 @@ public final class ToolsLineStream: @unchecked Sendable { } self.lock.unlock() } + let ended = SpawnMeter.started(executable: process.executableURL, arguments: process.arguments ?? []) process.terminationHandler = { [weak self] _ in + ended() self?.finish(exited: true) } - try process.run() + do { try process.run() } catch { + ended() + throw error + } priority.applyNice(to: process.processIdentifier) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift index 84dfe48e62..425aec8425 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift @@ -48,7 +48,7 @@ struct WidgetInboxCount: View { LinearKeyframe(0, duration: 0.33) } } - .accessibilityLabel((complete ? "" : "At least ") + "\(count) inbox notifications") + .accessibilityLabel((complete ? "" : "At least ") + (needsAnswer ? Plural.count(count, "item waits", "items wait") + " for your answer" : Plural.count(count, "unread item"))) } } @@ -405,6 +405,8 @@ public struct AgentWidgetView: View { action: actions.submit) } .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 12)) + .contentShape(RoundedRectangle(cornerRadius: 12)) + .onTapGesture { composerFocused = true } HStack(spacing: 6) { if sending { ProgressView().controlSize(.mini) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index c42b03d0ba..02f9b07162 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import UniformTypeIdentifiers @@ -36,6 +37,10 @@ public struct LiveWidgetView: View { /// The composer field's height, so its stand-in takes exactly its place while the panel moves. @State private var composerFieldHeight: CGFloat = 0 @Environment(\.edgePanelMoving) private var panelMoving + /// Dismiss and Mark complete wait out their Undo note before they reach the decision log. + @ObservedObject private var ledgerUndo = WidgetLedgerUndo.shared + /// The Conversation tab's merged list, rebuilt only when its inputs change. + @State private var timelineCache = TimelineCache() public init( model: WidgetModel, edge: EdgePanelPlacement, cutout: CGFloat = 0, compactHeight: CGFloat = 36, @@ -57,6 +62,7 @@ public struct LiveWidgetView: View { } public var body: some View { + let _ = RenderProbe.hit("widget.live") Group { if embedded { content @@ -102,8 +108,8 @@ public struct LiveWidgetView: View { cutoutWidth: cutout, compactHeight: compactHeight, draft: Binding(get: { model.draft.text }, set: model.setText), actions: AgentWidgetActions( - expand: { model.open(edge) }, collapse: model.collapse, - select: { model.select($0, edge: edge) }, choose: { model.submit(choice: $0) }, + expand: { model.open(edge) }, collapse: { model.collapse() }, + select: { model.select($0, edge: edge) }, choose: { model.choose(option: $0) }, submit: { model.submit() }, settings: { model.showSettings?() }, next: { model.next() }), liveContent: AnyView(content) ) @@ -117,19 +123,26 @@ public struct LiveWidgetView: View { VStack(alignment: .leading, spacing: 12) { header if let notice = model.notice { - HStack { + HStack(alignment: .top) { Text(notice).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) Spacer() + if let action = model.noticeAction { + Button(action.title) { action.run() } + .buttonStyle(.genHover()) + .controlSize(.small) + .accessibilityIdentifier("widget.notice.action") + } IconButton(systemName: "xmark", tooltip: "Dismiss this note") { model.notice = nil } } + // A note under the pointer waits to fade. A note closed under the pointer gets no exit event. + .onHover { model.noticeHovered($0) } + .onDisappear { model.noticeHovered(false) } } if let error = model.error { - HStack(alignment: .top) { - Text(error).font(.system(size: 11)).foregroundStyle(.orange).textSelection(.enabled) - .lineLimit(4) - Spacer(minLength: 4) - IconButton(systemName: "xmark", tooltip: "Dismiss this error") { model.error = nil } - } + // One sentence; the raw text (a process's stderr) only behind Details. + NoticePill(text: error, more: model.errorDetail, tone: .error) { model.error = nil } + .accessibilityIdentifier("widget.error") } if model.connectionLost && model.snapshot != nil { HStack { @@ -147,14 +160,30 @@ public struct LiveWidgetView: View { .textSelection(.enabled) }.font(.caption).foregroundStyle(.orange) } - GenSegmentedTabs("View", items: sectionItems, selection: Binding(get: { section }, set: { section = $0 })) - .accessibilityIdentifier("widget.sections") + if !model.inboxOverview { + GenSegmentedTabs("View", items: sectionItems, selection: Binding(get: { section }, set: { section = $0 })) + .accessibilityIdentifier("widget.sections") + } if model.snapshot == nil || (model.selected == nil && model.snapshot?.rosterLoading == true) { VStack(spacing: 12) { ProgressView() Text("Connecting to your local agents…").font(.callout).foregroundStyle(.secondary) if model.error != nil || model.connectionLost { Button("Reconnect", action: model.start) } }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.inboxOverview { + if model.stagedView == nil { WidgetStagedChip(model: model) } + ScrollView { + Group { + if model.stagedView != nil { WidgetStagedList(model: model) } else { WidgetInboxOverview(model: model) } + } + .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 2) + .scrollOverflowContent() + .modifier(DocumentHeight(onScrollView: false) { fit.document = $0; reportFit() }) + } + .modifier(DocumentHeight(onScrollView: true) { fit.document = $0; reportFit() }) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.viewport = $0; reportFit() } + .onDisappear { fit.viewport = nil; fit.document = nil; reportFit() } + .scrollOverflowHints() } else if model.selected == nil { if model.inbox.sessions.isEmpty { VStack(spacing: 10) { @@ -168,43 +197,64 @@ public struct LiveWidgetView: View { waitingSessions } } else { + if composerOnTop { composer } + // Pinned above the list: inside it, a session that opened at its selected card scrolled the filter + // under the top fade (Martin's image 24, 2026-10-23). + if section == "Inbox", !model.cards.isEmpty { inboxFilterBar } + if section == "Inbox", model.stagedView == nil { WidgetStagedChip(model: model) } ScrollViewReader { reader in ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { + // The inbox is eager: a session holds tens of cards, and a lazy stack's estimated height + // under an open card made the scroll offset and the document chase each other without end + // (the widget hung at 99% CPU on a session's Decisions, Martin, 2026-10-25). + Group { if section == "Inbox" { - inbox - } else if section == "Sessions" { - WidgetSessionBrowser(model: model) - } else if section == "Changes" { - changes + VStack(alignment: .leading, spacing: 14) { sectionList } } else { - conversation - } - if section == "Inbox" { - ForEach(model.outgoing) { message in - outgoing(message).id("outgoing-" + message.id) - } + LazyVStack(alignment: .leading, spacing: 14) { sectionList } } }.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 2) .scrollOverflowContent() - .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.document = $0; reportFit() } + .modifier(DocumentHeight(onScrollView: false) { fit.document = $0; reportFit() }) } .modifier(LatestScrollAnchor(enabled: section == "Conversation", identity: model.selectedKey)) + // A new view (section or session) starts at its own top: the Sessions list's deep offset, + // kept for the much shorter inbox, drew its rows over the header (Martin, 2026-10-23). The + // session list keeps its place when Up and Down pick another session in it. + .id(section == "Sessions" ? section : section + "|" + model.selectedKey) .onChange(of: WidgetOutgoingPosition(session: model.selectedKey, messageID: model.outgoing.last?.id)) { previous, next in - guard section == "Inbox", previous.session == next.session, let id = next.messageID else { return } + guard section == "Inbox", previous.session == next.session, let id = next.messageID, + Self.followsOutgoing(model.outgoing.last) else { return } ScrollViewPositioning.scroll(reader, to: "outgoing-" + id, anchor: .bottom) } + // Up and Down move the selection; the list brings the chosen row into view. + .onChange(of: model.listKey) { _, _ in + if section == "Inbox", let focus = model.choiceFocus { + reader.scrollTo("choice-" + focus.cardID + "-" + focus.choiceID) + } else if section == "Inbox", let id = model.card?.id { + reader.scrollTo("card-" + id) + } else if section == "Sessions" { + reader.scrollTo(model.selectedKey) + } + } + .modifier(DocumentHeight(onScrollView: true) { fit.document = $0; reportFit() }) .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.viewport = $0; reportFit() } .onDisappear { fit.viewport = nil; fit.document = nil; reportFit() } // Outermost, so the scroll view's own anchor modifiers still sit directly on it. .scrollOverflowHints() } // While a decision card holds the composer for an own answer, the bottom one would edit the same draft. - if section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) { composer } + if showsComposer && !composerOnTop { + composer + } } } .mediaPreviewHost() .padding(18).frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + if model.stagedLeavePrompt { WidgetStagedLeavePrompt(model: model) } + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: model.stagedLeavePrompt) .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.outer = $0; reportFit() } .onDrop(of: [.fileURL], isTargeted: nil) { providers in for provider in providers { @@ -213,7 +263,7 @@ public struct LiveWidgetView: View { if let url { model.importFile(url) } else if let error { - model.error = error.localizedDescription + model.fail("This file could not be attached.", detail: error.localizedDescription) } } } @@ -222,6 +272,27 @@ public struct LiveWidgetView: View { } } + /// The selected section's rows inside the pane's scroll view. + @ViewBuilder private var sectionList: some View { + if model.stagedView != nil { + WidgetStagedList(model: model) + } else if section == "Inbox" { + inbox + } else if section == "Sessions" { + // Only this list reports its drawn order for Up and Down; the chooser popover does not. + WidgetSessionBrowser(model: model, reportsOrder: true) + } else if section == "Changes" { + changes + } else { + conversation + } + if section == "Inbox" { + ForEach(model.outgoing) { message in + outgoing(message).id("outgoing-" + message.id) + } + } + } + /// The pane needs its chrome plus the whole scroll document; without a list (connecting, nothing selected) a /// short fixed height is enough. /// The three heights change in separate callbacks of one layout pass; read them together after it. @@ -232,7 +303,7 @@ public struct LiveWidgetView: View { fit.scheduled = false guard let outer = fit.outer else { return } // The same condition that chooses the list over the connecting / nothing-selected placeholders. - if model.snapshot != nil, model.selected != nil { + if model.snapshot != nil, model.selected != nil || model.inboxOverview { // The list is shown but not measured yet: wait instead of reporting the placeholder for a moment. guard let viewport = fit.viewport, let document = fit.document else { return } model.reportAgentFit(outer - viewport + document) @@ -248,13 +319,15 @@ public struct LiveWidgetView: View { choosingSession = true } label: { VStack(alignment: .leading, spacing: 3) { - Text(model.selected?.title ?? "Agent inbox").font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - Text( - [model.selected?.target.provider.capitalized, model.selected?.project].compactMap { $0 } - .joined(separator: " · ") - ) - .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + Text(model.inboxOverview ? "Inbox" : model.selected?.title ?? "Agent inbox") + .font(.system(size: 13, weight: .semibold)).lineLimit(1) + if model.inboxOverview { + Text("Every session").font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } else if let selected = model.selected { + WidgetSessionFacts(session: selected, + contextTokens: WidgetModel.contextTokens(model.transcript) ?? model.contextTokens[selected.key], + animate: !reduceMotion) + } } .padding(.horizontal, 6).padding(.vertical, 3) } @@ -273,17 +346,34 @@ public struct LiveWidgetView: View { .onChange(of: choosingSession) { _, value in model.sessionChooserOpen = value } .onDisappear { model.sessionChooserOpen = false } Spacer(minLength: 0) - IconButton(systemName: "pin.slash", tooltip: "Unpin this session from the widget", action: model.unpinSelected) - .accessibilityLabel("Unpin selected session") - IconButton(systemName: "rectangle.on.rectangle", tooltip: "Open this session in Hub") { - model.openHub?(model.selected) + if model.inboxOverview { + if model.selected != nil { + IconButton(systemName: "chevron.left", tooltip: "Back to " + (model.selected?.title ?? "the session")) { + model.inboxOverview = false + }.accessibilityLabel("Back to the session") + } + } else { + IconButton(systemName: "tray.full", tooltip: "Inbox items from every session") { + model.inboxOverview = true + }.accessibilityLabel("Inbox from every session") + if let selected = model.selected { + WidgetCmuxButton(model: model, session: selected, size: 12) + } + } + // Both act on one session, which the overview of every session does not show. + if !model.inboxOverview { + IconButton(systemName: "pin.slash", tooltip: "Unpin this session from the widget", action: model.unpinSelected) + .accessibilityLabel("Unpin selected session") + IconButton(systemName: "rectangle.on.rectangle", tooltip: "Open this session in Hub") { + model.openHub?(model.selected) + } + .accessibilityLabel("Open session in Hub") } - .accessibilityLabel("Open session in Hub") IconButton(systemName: "slider.horizontal.3", tooltip: "Widget settings and filters") { model.showSettings?() } .accessibilityLabel("Widget settings") - IconButton(systemName: "xmark", tooltip: WidgetKeymap.hint("Collapse", WidgetKeymap.collapse), action: model.collapse) + IconButton(systemName: "xmark", tooltip: WidgetKeymap.hint("Collapse", WidgetKeymap.collapse), action: { model.requestCollapse() }) .accessibilityLabel("Collapse widget") } } @@ -297,6 +387,16 @@ public struct LiveWidgetView: View { private var reduceMotion: Bool { systemReduceMotion || model.effectiveReduceMotion } + /// While a decision card holds the composer for an own answer, a second one would edit the same draft. + private var showsComposer: Bool { + !model.inboxOverview && section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) + } + + /// The inbox's composer sits under the tabs unless the user moved it to the bottom (Widgets → Sessions). + private var composerOnTop: Bool { + showsComposer && section == "Inbox" && (model.snapshot?.state.preferences.inboxComposer ?? "top") == "top" + } + /// No session picked: the sessions that hold inbox items, so nothing waits out of sight. private var waitingSessions: some View { ScrollView { @@ -353,19 +453,43 @@ public struct LiveWidgetView: View { /// Every item of the session as a row; the open row carries the whole card in place. Several answers from one /// session read as a list, not as one card behind a drop-down. - @ViewBuilder private var inbox: some View { - if !model.cards.isEmpty { + private var inboxFilterBar: some View { + VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { - Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + GenSegmentedTabs( + "Inbox filter", + items: [.init("all", "All"), .init("inbox", "Messages"), .init("decisions", "Decisions")], + // The model holds the filter, so Up and Down walk the rows on screen. + selection: Binding(get: { model.inboxFilter }, set: { value in + model.holdAgentFit() + model.inboxFilterPicked = true + model.inboxFilter = value + }), minSegmentWidth: 64, identifier: "widget.inbox.filter") Spacer() markAllReadButton } - ForEach(model.cards.reversed()) { card in + Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + } + } + + /// The session's cards, each Dismiss or Mark complete that waits for its Undo note already applied. + private var shownCards: [WidgetCard] { + ledgerUndo.moves.isEmpty ? model.cards : model.cards.map { ledgerUndo.applied($0) } + } + + @ViewBuilder private var inbox: some View { + if !model.cards.isEmpty { + let shown = Self.filtered(shownCards, by: model.inboxFilter) + if shown.isEmpty { + Text(model.inboxFilter == "decisions" ? "No decisions in this session." : "No messages in this session.") + .font(.callout).foregroundStyle(.secondary).padding(.vertical, 6) + } + ForEach(shown.reversed()) { card in inboxRow(card).id("card-" + card.id) } } else if model.inboxLoading { ProgressView("Loading this session’s inbox…") - .controlSize(.small).padding(.vertical, 12) + .controlSize(.small).font(.system(size: 11)).padding(.vertical, 12) .accessibilityIdentifier("widget.inbox.loading") } else { Text("No inbox items for this session.").foregroundStyle(.secondary).font(.callout) @@ -374,7 +498,7 @@ public struct LiveWidgetView: View { } private var inboxHeadline: String { - let cards = model.cards + let cards = shownCards let waiting = cards.filter(\.needsAnswer).count let unread = cards.filter { !$0.read && !$0.needsAnswer }.count return [cards.count == 1 ? "1 item" : "\(cards.count) items", @@ -391,6 +515,7 @@ public struct LiveWidgetView: View { /// Opens or folds a row in one `GenMotion` animation; the panel around it grows or shrinks to fit (W3). private func toggle(_ card: WidgetCard) { let opening = !isOpen(card) + model.releaseAgentFit() GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { if opening { model.openCard(card) @@ -402,6 +527,7 @@ public struct LiveWidgetView: View { private func inboxRow(_ card: WidgetCard) -> some View { let open = isOpen(card) + let _ = RenderProbe.hit("widget.inbox.row") return VStack(alignment: .leading, spacing: 0) { Button { toggle(card) } label: { HStack(alignment: .top, spacing: 9) { @@ -414,7 +540,7 @@ public struct LiveWidgetView: View { // A one-question form's title is its prompt, which the open card does not repeat: open, it // shows whole and as Markdown, the way the form drew it. let isPrompt = Self.asksOneQuestion(card) - (isPrompt ? Text(.init(card.title)) : Text(card.title)) + (isPrompt ? Text(.init(card.title)) : Text(card.numberedTitle)) .font(.system(size: 12.5, weight: open ? .semibold : .medium)) .lineLimit(open ? (isPrompt ? nil : 4) : 2).fixedSize(horizontal: false, vertical: true) if !open, let preview = Self.previewLine(card.body) { @@ -437,7 +563,7 @@ public struct LiveWidgetView: View { .contentShape(Rectangle()) } .buttonStyle(RowButtonStyle(cornerRadius: 11)) - .accessibilityLabel(Self.kindLabel(card) + ": " + card.title) + .accessibilityLabel(Self.kindLabel(card) + ": " + card.numberedTitle) .accessibilityValue(open ? "Expanded" : "Collapsed") .accessibilityIdentifier("widget.inbox.row." + card.id) if open { @@ -452,8 +578,101 @@ public struct LiveWidgetView: View { .strokeBorder(card.needsAnswer ? Color.orange.opacity(0.28) : .clear, lineWidth: 1)) } + /// A card whose pick or note is staged, here or in the snapshot. + private func staged(_ card: WidgetCard) -> Bool { + card.kind == "decision" && card.needsAnswer + && (model.stagedOption(card.sourceId, stored: card.draftOption) != nil || card.draft != nil) + } + + /// One option of a decision: the key badge, "a)" and the text, Staged, the recommended mark and the focus ring. + @ViewBuilder + private func optionRow(_ card: WidgetCard, choice: WidgetChoice, index: Int, key: String?, picked: Bool, + focused: Bool) -> some View { + let shape = RoundedRectangle(cornerRadius: 9) + let fill: Color = picked ? SessionPalette.blue.opacity(0.16) : .white.opacity(0.065) + let border: Color = picked ? SessionPalette.blue.opacity(0.7) : .clear + let ring: Color = focused ? Color(nsColor: .keyboardFocusIndicatorColor) : .clear + let label = (model.decisionClickStages ? "Stage " : "Answer ") + "\(choice.id): " + Self.choiceText(choice) + Button { + model.choose(card, option: choice.id) + } label: { + optionLabel(choice, key: key, picked: picked) + .padding(9).frame(maxWidth: .infinity, alignment: .leading) + .background(fill, in: shape) + .overlay(shape.strokeBorder(border, lineWidth: 1)) + // Up and Down move through the options; the chosen one wears the focus ring. + .overlay(shape.strokeBorder(ring, lineWidth: 2)) + .contentShape(shape) + } + .buttonStyle(RowButtonStyle(cornerRadius: 9)).disabled(model.importing > 0) + .accessibilityAddTraits(focused ? .isSelected : []) + .id("choice-" + card.id + "-" + choice.id) + .instantTooltip(Self.choiceTooltip(choice, index: index, stages: model.decisionClickStages, picked: picked)) + .accessibilityLabel(label) + .accessibilityValue(picked ? "Staged" : "") + } + + private func optionLabel(_ choice: WidgetChoice, key: String?, picked: Bool) -> some View { + HStack(alignment: .top, spacing: 8) { + // The badge is the key that picks the option; the letter, which the agent and the hub name it by, leads + // its text. + Text(verbatim: key ?? choice.id).font(.system(size: 11, weight: .semibold, design: .rounded)) + .padding(.horizontal, 4) + .frame(minWidth: 20, minHeight: 20) + .background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) + HStack(alignment: .firstTextBaseline, spacing: 5) { + if key != nil { + Text(verbatim: choice.id + ")").foregroundStyle(SessionPalette.secondary) + } + Text(Self.choiceText(choice)).fixedSize(horizontal: false, vertical: true) + } + .font(.system(size: 12)) + .padding(.top, 2) + Spacer(minLength: 0) + if picked { + Text("Staged").font(.system(size: 10, weight: .semibold)) + .foregroundStyle(SessionPalette.blue).padding(.top, 3) + } + if choice.recommended { + Image(systemName: "sparkle").foregroundStyle(.orange) + } + } + } + + /// The key that picks the option at `index` (the open decision's number keys, as the keymap has them), or nil. + static func choiceKey(_ index: Int) -> String? { + guard index < 9 else { return nil } + let label = WidgetKeymap.label(WidgetKeymap.choiceID(index + 1)) + return label.isEmpty ? nil : label + } + + /// Dismiss or Mark complete: the card shows its new state at once, and the decision log hears of it only when + /// the note with Undo goes away. + private func holdMove(_ card: WidgetCard, to state: String) { + ledgerUndo.hold(card, to: state, model: model, reduceMotion: reduceMotion) + } + + static func choiceTooltip(_ choice: WidgetChoice, index: Int, stages: Bool, picked: Bool) -> String { + let key = index < 9 ? " · key \(index + 1)" : "" + if !stages { return "Answer \(choice.id)" + (choice.recommended ? " (recommended)" : "") + key } + return (picked ? "Staged; click again to unstage" : "Stage \(choice.id), sent later with your other staged decisions") + + (choice.recommended ? " (recommended)" : "") + key + } + @ViewBuilder private func cardDetail(_ card: WidgetCard) -> some View { VStack(alignment: .leading, spacing: 10) { + if let saved = model.draftSavedAt[card.id] ?? (card.draft != nil ? Date(timeIntervalSince1970: card.at / 1000) : nil), + card.kind == "decision", card.needsAnswer { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill").foregroundStyle(SessionPalette.blue) + // "Staged", as the option badge and the hub say: a note that waits to be sent with the others. + Text(card.draft.map { "Note staged: " + $0 } ?? "Note staged").lineLimit(2) + if model.draftSavedAt[card.id] != nil { + Text("·") + LiveAgo(date: saved, style: .brief) + } + }.font(.system(size: 11)).foregroundStyle(.secondary) + } WidgetReceiptContextView(card: card, model: model) .id(card.id + "|" + String(card.at)) if !card.body.isEmpty { @@ -475,29 +694,12 @@ public struct LiveWidgetView: View { .buttonStyle(.borderedProminent).pointerCursor(!model.cardPending) .disabled(model.importing > 0 || model.cardPending) } else { + let staged = model.stagedOption(card.sourceId, stored: card.draftOption) ForEach(Array(card.choices.enumerated()), id: \.element.id) { index, choice in - Button { - model.submit(choice: choice.id) - } label: { - HStack(alignment: .top, spacing: 8) { - Text(verbatim: choice.id).font(.system(size: 10.5, weight: .semibold, design: .rounded)) - .frame(width: 20, height: 20).background( - .white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) - Text(Self.choiceText(choice)).font(.system(size: 12)).fixedSize( - horizontal: false, vertical: true) - .padding(.top, 2) - Spacer(minLength: 0) - if choice.recommended { - Image(systemName: "sparkle").foregroundStyle(.orange) - } - }.padding(9).frame(maxWidth: .infinity, alignment: .leading) - .background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 9)) - .contentShape(RoundedRectangle(cornerRadius: 9)) - } - .buttonStyle(RowButtonStyle(cornerRadius: 9)).disabled(model.importing > 0) - .instantTooltip("Answer \(choice.id)" + (choice.recommended ? " (recommended)" : "") - + (index < 9 ? " · key \(index + 1)" : "")) - .accessibilityLabel("Answer \(choice.id): " + Self.choiceText(choice)) + let picked = staged == choice.id + let key = Self.choiceKey(index) + let focused = model.choiceFocus == WidgetChoiceFocus(cardID: card.id, choiceID: choice.id) + optionRow(card, choice: choice, index: index, key: key, picked: picked, focused: focused) } ownAnswer(card) } @@ -507,11 +709,17 @@ public struct LiveWidgetView: View { if card.status == "open" { Button("Acknowledge") { model.ledger(card, state: "acknowledged") } } - Button("Mark complete") { model.ledger(card, state: "implemented") } - Button("Dismiss") { model.ledger(card, state: "dismissed") } + Button("Mark complete") { holdMove(card, to: "implemented") } + Button("Dismiss") { holdMove(card, to: "dismissed") } } else if card.kind == "decision" && card.needsAnswer { - Button("Save draft") { model.ledger(card, state: "drafted") } - Button("Dismiss") { model.ledger(card, state: "dismissed") } + Button("Stage note") { model.saveDraft(card) } + .instantTooltip("Stages your text as this decision's note, sent later with your other staged decisions.") + if staged(card) { + Button("Send now") { model.sendStaged([card.sourceId]) } + .instantTooltip("Sends this staged decision alone, without the others.") + Button("Unstage") { model.discardStaged([card.sourceId]) } + } + Button("Dismiss") { holdMove(card, to: "dismissed") } } if card.kind == "result" || card.kind == "answer" { Button("Create handoff") { model.destination("handoff") } @@ -555,11 +763,30 @@ public struct LiveWidgetView: View { .onAppear { editing = true } } else { WidgetOwnAnswerRow { - GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { model.ownAnswerCardID = card.id } + GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { model.openOwnAnswer(card) } }.disabled(model.importing > 0 || model.cardPending) } } + /// "inbox": answers, results and questions; "decisions": decisions and tasks; anything else: every card. + static func filtered(_ cards: [WidgetCard], by filter: String) -> [WidgetCard] { + switch filter { + case "inbox": return cards.filter { !["decision", "todo"].contains($0.kind) } + case "decisions": return cards.filter { ["decision", "todo"].contains($0.kind) } + default: return cards + } + } + + /// A composer message lands under the list, and the list follows it. An answer sent from a card leaves the list + /// where it is: three scrolls to the bottom moved the answered card away (Martin, 2026-10-23: "the whole widget + /// jumped around … i totally lost idea about which one was it"). + static func followsOutgoing(_ message: WidgetOutgoing?) -> Bool { + guard let message, case .object(let payload) = message.payload, case .string(let kind) = payload["kind"] else { + return false + } + return kind == "followup" + } + static func kindSymbol(_ kind: String) -> String { switch kind { case "answer": return "text.bubble" @@ -585,13 +812,39 @@ public struct LiveWidgetView: View { case "decision": if let replacement = card.supersededBy { return "Decision · Superseded by DECISION \(replacement.number)" } if let option = card.option, !option.isEmpty, ["acknowledged", "implemented", "sent", "answered"].contains(card.status) { - return "Decision · Answered \(option) · " + card.status.capitalized + return "Decision · Answered \(option) · " + statusPhrase(card.status, kind: card.kind) } - return "Decision · " + card.status.capitalized - default: return card.kind.capitalized + " · " + card.status.capitalized + return "Decision · " + statusPhrase(card.status, kind: card.kind) + case "form": return "Question · " + statusPhrase(card.status, kind: card.kind) + default: return sentenceCase(card.kind) + " · " + statusPhrase(card.status, kind: card.kind) } } + /// One phrase for each state of the decision log and the question forms, the same in every tab and row. + static func statusPhrase(_ status: String, kind: String) -> String { + if kind == "todo" { return WidgetTask.label(for: status) } + switch status { + case "open", "pending": return "Waiting for your answer" + case "drafted": return "Staged" + // A decision's answer is chosen and waits for its delivery; a form's answer is the end. + case "answered": return kind == "decision" ? "Waiting to send" : "Answered" + case "sent": return "Sent" + case "acknowledged": return "Acknowledged" + case "implemented": return "Implemented" + case "dismissed": return "Dismissed" + case "superseded": return "Superseded" + case "timeout": return "No answer in time" + case "cancelled": return "Withdrawn" + default: return sentenceCase(status) + } + } + + /// "waiting-route" reads "Waiting route", never "Waiting-Route". + static func sentenceCase(_ value: String) -> String { + let words = value.replacingOccurrences(of: "-", with: " ").replacingOccurrences(of: "_", with: " ") + return words.prefix(1).uppercased() + words.dropFirst() + } + /// A collapsed row's one line: the body's first line of prose, without Markdown marks. static func previewLine(_ body: String) -> String? { for line in body.split(separator: "\n", omittingEmptySubsequences: true) { @@ -603,7 +856,7 @@ public struct LiveWidgetView: View { return nil } - /// The option text without a letter the agent already wrote ("a) Record…"); the badge shows the letter. + /// The option text without a letter the agent already wrote ("a) Record…"); the row draws the letter itself. static func choiceText(_ choice: WidgetChoice) -> String { let title = choice.title.trimmingCharacters(in: .whitespaces) for prefix in [choice.id + ")", "(" + choice.id + ")", choice.id + ".", choice.id + ":"] { @@ -645,6 +898,10 @@ public struct LiveWidgetView: View { .white.opacity(selected ? 0.12 : 0.045), in: RoundedRectangle(cornerRadius: 8)) .contentShape(RoundedRectangle(cornerRadius: 8)) }.buttonStyle(RowButtonStyle(cornerRadius: 8)) + // The circle shows the pick; VoiceOver hears it as the selected trait and a value. + .accessibilityLabel(choice.label) + .accessibilityValue(selected ? "Selected" : "Not selected") + .accessibilityAddTraits(selected ? .isSelected : []) } if item.allowFreeText != false { TextField( @@ -666,6 +923,9 @@ public struct LiveWidgetView: View { } @ViewBuilder private var conversation: some View { + let timeline = timelineCache.items(for: TimelineCache.Inputs( + transcript: model.transcript, cards: shownCards, outgoing: model.outgoing, + activity: model.sessionActivity, provider: model.selected?.target.provider ?? "")) if let error = model.transcriptError { Text(error).font(.caption).foregroundStyle(.orange).textSelection(.enabled) } @@ -687,9 +947,8 @@ public struct LiveWidgetView: View { } case .card(let card): VStack(alignment: .leading, spacing: 6) { - Text(card.kind.capitalized + " · " + card.status).font(.caption2).foregroundStyle( - .secondary) - Text(card.title).font(.system(size: 12, weight: .semibold)).textSelection(.enabled) + Text(Self.kindLabel(card)).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + Text(card.numberedTitle).font(.system(size: 12, weight: .semibold)).textSelection(.enabled) if !card.body.isEmpty { WidgetMarkdown(text: card.body) } @@ -706,14 +965,18 @@ public struct LiveWidgetView: View { }.padding(10).background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 11)) case .outgoing(let message): outgoing(message) - case .activity(let event): + case .activity(let line): HStack(alignment: .top, spacing: 8) { Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { - Text(event.title).font(.caption) - Text(event.sourceId + " · " + event.body).font(.caption2).foregroundStyle(.secondary) + Text(line.title).font(.system(size: 11)) + if !line.detail.isEmpty { + Text(line.detail).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + } } } + .accessibilityElement(children: .combine) } } } @@ -724,45 +987,118 @@ public struct LiveWidgetView: View { } } - private struct TimelineItem: Identifiable { + fileprivate struct TimelineItem: Identifiable { enum Event { case turn(TranscriptTurn) case card(WidgetCard) case outgoing(WidgetOutgoing) - case activity(WidgetActivityEvent) + case activity(ActivityLine) } let id: String let date: Date let event: Event } - private var timeline: [TimelineItem] { - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let plain = ISO8601DateFormatter() - let turns = model.transcript.filter { !$0.text.isEmpty }.map { turn in - TimelineItem( - id: "turn:" + turn.id, - date: turn.at.flatMap { iso.date(from: $0) ?? plain.date(from: $0) } ?? .distantPast, - event: .turn(turn)) + + /// A decision event in words, for the Conversation tab. + struct ActivityLine: Equatable { + let title: String + let detail: String + } + + /// The Conversation tab's list: turns, cards, outgoing messages and decision events in time order. The body runs + /// on every publish of the model (a voice level, a hover), so the merge, the date parsing and the sort run only + /// when an input changed. A plain reference in `@State`, so a rebuild never re-renders. + @MainActor + private final class TimelineCache { + struct Inputs: Equatable { + var transcript: [TranscriptTurn] + var cards: [WidgetCard] + var outgoing: [WidgetOutgoing] + var activity: [WidgetActivityEvent] + var provider: String } - let cards = model.cards.map { - TimelineItem(id: $0.id, date: Date(timeIntervalSince1970: $0.at / 1000), event: .card($0)) + private var inputs: Inputs? + private var items: [TimelineItem] = [] + + func items(for next: Inputs) -> [TimelineItem] { + if next != inputs { + items = Self.merged(next) + inputs = next + } + return items } - let outgoing = model.outgoing.map { - TimelineItem( - id: "out:" + $0.id, date: Date(timeIntervalSince1970: $0.createdAt / 1000), - event: .outgoing($0)) + + private static func merged(_ inputs: Inputs) -> [TimelineItem] { + let turns = inputs.transcript.filter { !$0.text.isEmpty }.map { turn in + TimelineItem(id: "turn:" + turn.id, date: SessionFormat.parseISO(turn.at) ?? .distantPast, + event: .turn(turn)) + } + let cards = inputs.cards.map { + TimelineItem(id: $0.id, date: Date(timeIntervalSince1970: $0.at / 1000), event: .card($0)) + } + let outgoing = inputs.outgoing.map { + TimelineItem( + id: "out:" + $0.id, date: Date(timeIntervalSince1970: $0.createdAt / 1000), + event: .outgoing($0)) + } + let numbers = Dictionary(inputs.cards.compactMap { card in card.number.map { (card.sourceId, $0) } }, + uniquingKeysWith: { first, _ in first }) + let activity = inputs.activity.map { event in + TimelineItem( + id: "event:" + event.id, date: Date(timeIntervalSince1970: event.at / 1000), + event: .activity(LiveWidgetView.activityLine( + event, number: numbers[event.sourceId], provider: inputs.provider))) + } + return (turns + cards + outgoing + activity).sorted { + $0.date == $1.date ? $0.id < $1.id : $0.date < $1.date + } } - let activity = (model.snapshot?.activity ?? []).map { - TimelineItem(id: "event:" + $0.id, date: Date(timeIntervalSince1970: $0.at / 1000), event: .activity($0)) + } + + /// A decision event from the log: what happened, then which decision (its number when the session's card is + /// known, else the session's provider) and the state and route it reached. The wire carries the log's raw names + /// ("Decision send_failed", "answered · cmux") and the row id, which mean nothing to the reader. + static func activityLine(_ event: WidgetActivityEvent, number: Int?, provider: String) -> ActivityLine { + let prefix = "Decision " + let name = event.title.hasPrefix(prefix) ? String(event.title.dropFirst(prefix.count)) : "" + let title: String + switch name { + case "created": title = "Decision posted" + case "updated": title = "Decision updated" + case "superseded": title = "Decision superseded" + case "harvested": title = "Decision found in the conversation" + case "delivery": title = "Answer delivered" + case "stale": title = "Reminder sent: the decision still waits" + case "send_failed": title = "Answer not delivered" + default: title = sentenceCase(event.title) } - return (turns + cards + outgoing + activity).sorted { - $0.date == $1.date ? $0.id < $1.id : $0.date < $1.date + let decision = number.map { "Decision \($0)" } ?? AIProviders.meta(for: provider).displayName + let facts = event.body.components(separatedBy: " · ").compactMap { part -> String? in + routePhrase(part) ?? (decisionStates.contains(part) ? statusPhrase(part, kind: "decision") : nil) + } + return ActivityLine(title: title, detail: ([decision] + facts).filter { !$0.isEmpty }.joined(separator: " · ")) + } + + /// The states of the decision log (`src/question/lib/decisions/store.ts`). + private static let decisionStates: Set<String> = [ + "open", "drafted", "answered", "sent", "acknowledged", "implemented", "dismissed", "superseded", + ] + + /// How an answer reached its agent (the log's `delivery.route`). + static func routePhrase(_ route: String) -> String? { + switch route { + case "cmux": return "through cmux" + case "codex": return "through Codex" + case "claude-peer": return "as a Claude message" + case "prompt": return "as a new prompt" + case "queued": return "queued for the session" + case "resume": return "by resuming the session" + default: return nil } } @ViewBuilder private var changes: some View { - if let changes = model.snapshot?.changes, changes.available { + if let changes = model.sessionChanges, changes.available { ForEach(changes.files) { file in Button { openReference(file.path) @@ -802,12 +1138,14 @@ public struct LiveWidgetView: View { systemName: message.state == "sent" ? "checkmark.circle.fill" : "exclamationmark.circle" ) } - Text(receiptLabel(message)).font(.system(size: 10)) + Text(receiptLabel(message)).font(.system(size: 11)) .accessibilityIdentifier("widget.receipt." + message.id) Spacer(minLength: 0) }.foregroundStyle(message.state == "sent" ? .green : .secondary) if let error = message.error, !(message.state == "waiting-route" && message.receipt?.channel == "session-queue") { - Text(error).font(.caption2).foregroundStyle(.orange).textSelection(.enabled) + // What happened in one sentence; the transport's own text only behind Details. + NoticePill(text: Self.outgoingProblem(message), more: error, + tone: message.state == "failed" ? .error : .warning) } if message.isWithdrawable || message.needsRecovery { HStack { @@ -852,15 +1190,19 @@ public struct LiveWidgetView: View { attachments(model.draft.assetIds, tile: CGSize(width: 88, height: 60), removable: true) } if model.voiceActive { - HStack(spacing: 8) { - Image(systemName: "waveform").foregroundStyle(.red) - Text( - model.voiceText.isEmpty ? "Listening… tap Stop to edit the transcript" : model.voiceText - ) - .font(.caption).lineLimit(2) - Spacer() - ProgressView(value: min(1, model.voiceLevel * 5)).frame(width: 40) + HStack(alignment: .center, spacing: 9) { + VoiceLevelBars(level: min(1, model.voiceLevel * 5), tint: .red, reduceMotion: model.effectiveReduceMotion) + VStack(alignment: .leading, spacing: 2) { + Text(model.voiceMode == .note ? "Voice note" : "Dictation") + .font(.system(size: 10, weight: .semibold)).foregroundStyle(.secondary) + let transcript = model.voiceTranscript + // The newest words stay in view: a long dictation drops its beginning, not its end. + Text(transcript.isEmpty ? "Listening… the words appear here as you talk" : transcript) + .font(.caption).lineLimit(3).truncationMode(.head) + } + Spacer(minLength: 0) } + .accessibilityElement(children: .combine) } if model.importing > 0 { ProgressView("Saving attachment…").controlSize(.small).font(.caption) @@ -882,6 +1224,7 @@ public struct LiveWidgetView: View { .onSubmit { model.send() } .accessibilityLabel(ownAnswer == nil ? "Reply to selected agent" : "Your own answer") .onGeometryChange(for: CGFloat.self, of: { $0.size.height.rounded() }) { composerFieldHeight = $0 } + .frameProbe("widget.composer.field") } Button { model.send() @@ -891,29 +1234,75 @@ public struct LiveWidgetView: View { .instantTooltip(ownAnswer == nil ? "Send (Return)" : "Send as your answer (Return)") .accessibilityLabel(ownAnswer == nil ? "Send reply" : "Send your answer") } - HStack(spacing: 15) { - Button(action: model.chooseFiles) { Image(systemName: "paperclip") }.instantTooltip( - "Attach images or videos" - ).accessibilityLabel("Attach media") - Button { + // Each icon is the kit's 22 pt target (IconButton), with 4 pt between neighbours instead of a 15 pt strip + // where a click did nothing. + HStack(spacing: 4) { + IconButton(systemName: "paperclip", tooltip: "Attach images or videos", action: model.chooseFiles) + .accessibilityLabel("Attach media") + IconButton(systemName: "clipboard", tooltip: WidgetKeymap.hint("Paste image or video", WidgetKeymap.paste)) { _ = model.pasteMedia() - } label: { - Image(systemName: "clipboard") - }.instantTooltip(WidgetKeymap.hint("Paste image or video", WidgetKeymap.paste)).accessibilityLabel("Paste media") - Button(action: model.capture) { Image(systemName: "camera") }.instantTooltip("Capture a screenshot") - .accessibilityLabel("Capture screenshot") - Button(action: model.toggleVoice) { - Image(systemName: model.voiceActive ? "stop.circle.fill" : "mic").padding(.horizontal, 8) - .padding(.vertical, 4) } + .accessibilityLabel("Paste media") + IconButton(systemName: "camera", tooltip: "Capture a screenshot", action: model.capture) + .accessibilityLabel("Capture screenshot") + IconButton(systemName: model.recordingVideo ? "stop.circle.fill" : "video", + tooltip: model.recordingVideo ? "Stop the recording and paste it into the draft" + : "Record a window, an app or a display (up to 10 min)", + tint: model.recordingVideo ? Color.red : nil, action: model.toggleVideoCapture) + .accessibilityLabel(model.recordingVideo ? "Stop and paste the recording" : "Record the screen") + voiceButton + Spacer(minLength: 8) + composerKeys + }.font(.system(size: 12)).foregroundStyle(.secondary).buttonStyle(.genHoverIcon()) + } + .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 13)) + // The whole box is the field's target: a click beside the one-line field (or under it, in the padding) starts + // typing. The buttons inside keep their own clicks, because a child's gesture wins over this one. + .contentShape(RoundedRectangle(cornerRadius: 13)) + .onTapGesture { editing = true } + .frameProbe("widget.composer") + } + + /// The composer's keys, in a hint beside the field: Return sends, Option-Return adds a line (SwiftUI's own key + /// for a vertical field with `onSubmit`), and the paste and close keys in force. A narrow card (an own answer) + /// drops the last ones first. Text the user reads: 11 pt at 70% white. + private var composerKeys: some View { + let paste = WidgetKeymap.label(WidgetKeymap.paste) + let close = WidgetKeymap.label(WidgetKeymap.collapse) + let base = "↩ send · ⌥↩ new line" + let media = paste.isEmpty ? base : base + " · \(paste) media" + return ViewThatFits(in: .horizontal) { + Text(close.isEmpty ? media : media + " · \(close) close") + Text(media) + Text(base) + } + .font(.system(size: 11)).foregroundStyle(SessionPalette.secondary).lineLimit(1) + .layoutPriority(1) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Return sends. Option Return adds a new line.") + } + + /// The microphone: Stop while a dictation runs; otherwise a menu of the two modes when the setting asks, else the + /// set mode at once. Both are drawn buttons with the kit's hover and hand cursor (a SwiftUI `Menu` has neither). + @ViewBuilder private var voiceButton: some View { + let icon = Image(systemName: model.voiceActive ? "stop.circle.fill" : "mic") + .padding(.horizontal, 8).padding(.vertical, 4) + if model.voiceActive || model.composerVoiceChoice != "ask" { + Button(action: model.toggleVoice) { icon } .modifier(WidgetGlassControl()) .foregroundStyle(model.voiceActive ? .red : .secondary) .instantTooltip(model.voiceActionLabel).accessibilityLabel(model.voiceActionLabel) - Spacer() - Text(WidgetKeymap.mediaFooter).font(.system(size: 9)).foregroundStyle(.tertiary) - }.font(.system(size: 12)).foregroundStyle(.secondary).buttonStyle(.genHoverIcon()) + } else { + MenuButton(style: .genHoverIcon(), items: { + [.action("Dictate into the reply box") { model.startVoice(.dictate) }, + .action("Voice note into the reply box (keeps the recording)", enabled: model.voiceNoteClip != nil) { + model.startVoice(.note) + }] + }) { icon } + .modifier(WidgetGlassControl()) + .foregroundStyle(.secondary) + .instantTooltip(model.voiceActionLabel).accessibilityLabel(model.voiceActionLabel) } - .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 13)) } private func attachments(_ ids: [String], tile: CGSize, removable: Bool) -> some View { @@ -939,7 +1328,19 @@ public struct LiveWidgetView: View { case "waiting-route": return message.receipt?.channel == "session-queue" ? "Queued for this session · waiting for the agent" : "Waiting for an available session route" case "unknown": return "Delivery unknown · check the conversation" - default: return message.state.capitalized + case "failed": return "Not delivered" + default: return Self.sentenceCase(message.state) + } + } + + /// The sentence over a message's raw delivery error: what it means for the message and what the buttons under + /// it can do. + static func outgoingProblem(_ message: WidgetOutgoing) -> String { + switch message.state { + case "failed": return "The agent did not get this message. Retry it, or choose another destination." + case "waiting-route": return "No session can take this message yet. It goes out when one can." + case "unknown": return "No receipt came back. Check the conversation before you retry." + default: return "The last attempt to send this message did not work." } } private func showMedia(_ selection: WidgetMediaSelection) { @@ -959,6 +1360,204 @@ public struct LiveWidgetView: View { } } +/// The scroll document's height for the pane's fit. macOS 15 and later read it from the scroll view itself +/// (`onScrollView: true`); before, a geometry query of the content (`onScrollView: false`) measures it, which lays out +/// every row of a lazy list on each pass. Each scroll view carries both; one of them reports. +private struct DocumentHeight: ViewModifier { + let onScrollView: Bool + let report: (CGFloat) -> Void + + func body(content: Content) -> some View { + if #available(macOS 15, *) { + if onScrollView { + content.onScrollGeometryChange(for: CGFloat.self, of: { $0.contentSize.height.rounded() }) { _, height in + report(height) + } + } else { + content + } + } else if onScrollView { + content + } else { + content.onGeometryChange(for: CGFloat.self, of: \.size.height) { report($0) } + } + } +} + +/// Dismiss and Mark complete in a session's inbox wait for their Undo note before they reach the decision log. The +/// log has no move back from a dismissed decision, and the widget's `ledger` action cannot reopen a task, so the +/// change is held here instead of undone there: the card shows its new state at once, Undo drops the change, and the +/// change goes to the log when the note goes away (it faded after the note duration setting, was closed, or a newer +/// note replaced it). Shared by every panel of the widget, and kept while a panel is closed. What the user did still +/// lands when the panel's model goes away (a hold keeps its model until it is sent) and when the app quits. +@MainActor +final class WidgetLedgerUndo: ObservableObject { + static let shared = WidgetLedgerUndo(sendsBeforeQuit: true) + /// The longest a change waits, even under a note that stays until it is closed. + static let longestHold: Duration = .seconds(60) + /// How long a quit waits for the held changes to reach the hub. + static let quitWait: DispatchTimeInterval = .seconds(4) + + struct Move: Equatable { + let state: String + /// The card's revision when the user acted. A snapshot with a newer one shows the card as it is. + let revision: Int + } + + private struct Hold { + let move: Move + let request: WidgetJSON + let note: String + /// Kept until the change is sent, so a panel's model that goes away still sends it. + let model: WidgetModel + var watch: AnyCancellable? + var deadline: Task<Void, Never>? + } + + /// Card id to the move the card shows. + @Published private(set) var moves: [String: Move] = [:] + private var holds: [String: Hold] = [:] + /// Changes handed to the model's queue and not answered yet: a quit sends them again, because that queue never + /// runs again. A second copy finds the item already moved and changes nothing. + private var sending: [String: (request: WidgetJSON, bridge: ToolsBridge)] = [:] + /// The quit observer of the shared instance, which lives as long as the app. + private var quitObserver: NSObjectProtocol? + + init(sendsBeforeQuit: Bool = false) { + guard sendsBeforeQuit else { return } + quitObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.sendBeforeQuit() } + } + } + + /// `card` with its held or just committed move applied. + func applied(_ card: WidgetCard) -> WidgetCard { + guard let move = moves[card.id], move.revision == card.revision else { return card } + var shown = card + shown.status = move.state + return shown + } + + /// Moves `card` to `state` ("dismissed", "implemented") on screen now, and in the log when its note goes. + func hold(_ card: WidgetCard, to state: String, model: WidgetModel, reduceMotion: Bool) { + // The session is read now: the user may pick another one before the note goes. + guard let session = model.selected?.target.sessionId, let revision = card.revision else { return } + let id = card.id + let move = Move(state: state, revision: revision) + let note = Self.note(card, state: state) + var hold = Hold( + move: move, + request: .object([ + "action": "ledger", "id": .string(card.sourceId), "sessionId": .string(session), + "expectedRevision": .number(Double(revision)), "state": .string(state), + ]), + note: note, model: model) + moves[id] = move + // Folded, the card is no longer the decision the number keys and the composer answer. + GenMotion.perform(expanding: false, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.insert(id) } + model.heldCardIDs.insert(id) + model.postNote(note, action: WidgetNoticeAction(title: "Undo") { [weak self] in + self?.undo(id, reduceMotion: reduceMotion) + }) + // The current value is this note; any later one (nil, or another note) means it went away. + hold.watch = model.$notice.dropFirst().sink { [weak self] text in + guard text != note else { return } + Task { @MainActor [weak self] in self?.commit(id) } + } + hold.deadline = Task { [weak self] in + do { try await Task.sleep(for: Self.longestHold) } catch { return } + self?.commit(id) + } + holds[id] = hold + } + + /// "Decision 4 dismissed.", "Task 2 completed.": what changed, in the note that offers Undo. + static func note(_ card: WidgetCard, state: String) -> String { + let name = card.kind == "todo" + ? card.number.map { "Task \($0)" } ?? "Task" + : card.number.map { "Decision \($0)" } ?? "Decision" + return name + (state == "implemented" ? " completed." : " dismissed.") + } + + private func commit(_ id: String) { + guard let hold = holds.removeValue(forKey: id) else { return } + hold.watch?.cancel() + hold.deadline?.cancel() + let model = hold.model + // Its Undo could not take the change back any more. + if model.notice == hold.note { model.notice = nil } + sending[id] = (hold.request, model.bridge) + model.action(hold.request, failed: { [weak self] in + self?.sending[id] = nil + // The log refused it (the item changed elsewhere): the card shows its real state again, and the error + // line says why. + if self?.moves[id] == hold.move { + self?.moves[id] = nil + model.heldCardIDs.remove(id) + } + }, completed: { [weak self] in + self?.sending[id] = nil + // The card keeps its new look until a snapshot carries the newer revision; then the entry is inert. + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(30)) + if self?.moves[id] == hold.move { + self?.moves[id] = nil + model.heldCardIDs.remove(id) + } + } + }) + } + + private func undo(_ id: String, reduceMotion: Bool) { + guard let hold = holds.removeValue(forKey: id) else { return } + hold.watch?.cancel() + hold.deadline?.cancel() + moves[id] = nil + let model = hold.model + model.heldCardIDs.remove(id) + GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.remove(id) } + model.postNote("Undone.") + } + + /// The app quits while changes still wait for their note, or wait in the model's queue. The main actor is busy + /// quitting, so that queue never runs again: each change goes to the hub straight through the bridge, off the + /// main thread, and the quit waits for them up to `quitWait`. The `ledger` action reads the decision log itself, + /// so it needs no widget state root. + private func sendBeforeQuit() { + let held = holds.values.map { (request: $0.request, bridge: $0.model.bridge) } + for hold in holds.values { + hold.watch?.cancel() + hold.deadline?.cancel() + } + holds.removeAll() + let work = held + Array(sending.values) + sending.removeAll() + guard !work.isEmpty else { return } + let done = DispatchSemaphore(value: 0) + Task.detached { + for item in work { await WidgetLedgerUndo.send(item.request, through: item.bridge) } + done.signal() + } + if done.wait(timeout: .now() + Self.quitWait) == .timedOut { + PerfLog.mark("widget.ledger quit: \(work.count) change(s) not confirmed before the app quit") + } + } + + nonisolated private static func send(_ request: WidgetJSON, through bridge: ToolsBridge) async { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("widget-ledger-" + UUID().uuidString + ".json") + do { + try JSONEncoder().encode(request).write(to: file, options: .atomic) + defer { try? FileManager.default.removeItem(at: file) } + _ = try await bridge.run(subcommand: "hub", args: ["widget", "call", "--input", file.path], timeoutSeconds: 4) + } catch { + PerfLog.mark("widget.ledger quit send \(error.localizedDescription)") + } + } +} + /// Measured heights of LiveWidgetView's pane, its scroll viewport and the scroll document. final class WidgetContentFit { static let placeholderHeight: CGFloat = 360 diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift index da74f72bbf..ede13c3cf2 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift @@ -4,7 +4,7 @@ public extension WidgetShelfStore { func captureModule() -> WidgetModuleDescriptor { WidgetModuleDescriptor( id: "capture", title: "Capture", symbol: "viewfinder", tint: .orange, - summary: { self.isCapturing ? "Select an area…" : "\(self.captures.count) captures" }, + summary: { self.isCapturing ? "Select an area…" : Plural.count(self.captures.count, "capture") }, visibilityChanged: { self.visibilityChanged(module: "capture", presentation: $0) } ) { WidgetShelfModuleView(store: self, captureOnly: true, presentation: $0) } } @@ -12,7 +12,7 @@ public extension WidgetShelfStore { func shelfModule() -> WidgetModuleDescriptor { WidgetModuleDescriptor( id: "shelf", title: "File Shelf", symbol: "tray.full", tint: .cyan, - summary: { "\(self.items.count) items" }, + summary: { Plural.count(self.items.count, "item") }, visibilityChanged: { self.visibilityChanged(module: "shelf", presentation: $0) } ) { WidgetShelfModuleView(store: self, captureOnly: false, presentation: $0) } } @@ -41,7 +41,7 @@ struct WidgetShelfModuleView: View { if presentation == .compact { HStack(spacing: 7) { Image(systemName: symbol).foregroundStyle(tint) - Text(store.isCapturing ? "Select an area…" : "\(displayedItems.count) \(captureOnly ? "captures" : "items")") + Text(store.isCapturing ? "Select an area…" : Plural.count(displayedItems.count, captureOnly ? "capture" : "item")) .font(.system(size: 11, weight: .medium)) } } else { @@ -76,7 +76,9 @@ struct WidgetShelfModuleView: View { Image(systemName: symbol).foregroundStyle(tint) Text(title).font(.system(size: 15, weight: .semibold)) Spacer() - Text("\(displayedItems.count) staged").font(.system(size: 10)).foregroundStyle(.secondary) + // "Staged" is the inbox's word for a decision answer kept to send later; the shelf keeps items. + Text(Plural.count(displayedItems.count, captureOnly ? "capture" : "item")) + .font(.system(size: 11)).foregroundStyle(.secondary) } } @@ -88,6 +90,13 @@ struct WidgetShelfModuleView: View { } .disabled(store.isBusy && !store.isCapturing) .accessibilityIdentifier("shelf-capture") + Button(action: store.isRecording ? store.showRecordingControls : store.recordScreen) { + Label(store.isRecording ? "Recording…" : "Record screen…", systemImage: "record.circle") + } + .disabled(store.isCapturing || !store.canRecord) + .instantTooltip(store.isRecording ? "Show the recording's controls" + : "Record a window, an app or a display, then keep the movie here") + .accessibilityIdentifier("shelf-record") } else { Button { store.chooseFiles() } label: { Label("Add files", systemImage: "plus") } .disabled(store.isBusy) @@ -112,6 +121,20 @@ struct WidgetShelfModuleView: View { if let error = store.error { Text(error).font(.system(size: 11)).foregroundStyle(KitPalette.removed) .fixedSize(horizontal: false, vertical: true) + } else if let removed = store.removed, !captureOnly || removed.kind == .capture { + HStack(spacing: 8) { + Text(removed.kind == .capture ? "\(removed.name) was removed." + : "\(removed.name) was removed from the shelf. The original file is kept.") + .font(.system(size: 11)).foregroundStyle(.secondary) + .lineLimit(2).truncationMode(.middle) + Spacer(minLength: 4) + Button("Undo", action: store.undoRemove) + .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) + .keyboardShortcut("z", modifiers: .command) + .instantTooltip("Puts it back (⌘Z)") + .accessibilityIdentifier("shelf-undo-remove") + IconButton(systemName: "xmark", tooltip: "Close; the item is removed now", action: store.confirmRemoval) + } } else if let notice = store.notice { Text(notice).font(.system(size: 11)).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -188,7 +211,8 @@ private struct WidgetShelfRow: View { @State private var recipientSelection: WidgetShelfRecipientSelection? static func preview(_ item: WidgetShelfItem) -> MediaPreviewItem { - MediaPreviewItem(id: item.id, path: item.path, name: item.name, kind: item.kind == .capture ? .image : nil) + MediaPreviewItem(id: item.id, path: item.path, name: item.name, + kind: item.kind == .capture ? (item.isVideo ? .video : .image) : nil) } var body: some View { @@ -208,7 +232,8 @@ private struct WidgetShelfRow: View { Spacer(minLength: 0) IconButton( systemName: "tray.and.arrow.down", - tooltip: item.kind == .capture ? "Attach image to an inbox draft" : "Add file reference to an inbox draft" + tooltip: item.kind == .capture ? "Attach \(item.isVideo ? "video" : "image") to an inbox draft" + : "Add file reference to an inbox draft" ) { recipientSelection = WidgetShelfRecipientSelection(item: item, recipients: store.recipients) } @@ -258,7 +283,8 @@ private struct WidgetShelfRecipientPicker: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text(selection.item.kind == .capture ? "Attach image to inbox" : "Add file reference to inbox") + Text(selection.item.kind == .capture ? "Attach \(selection.item.isVideo ? "video" : "image") to inbox" + : "Add file reference to inbox") .font(.system(size: 13, weight: .semibold)) Text("This adds to the draft. It does not send.") .font(.system(size: 11)).foregroundStyle(.secondary) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift index f4082aaafb..c7acbc483d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import CoreGraphics import SwiftUI @@ -13,6 +14,9 @@ public struct WidgetShelfItem: Codable, Identifiable, Equatable, Sendable { public let bytes: Int64 public let createdAt: Double public let assetId: String? + + /// A capture that is a screen recording rather than a screenshot (by its file's extension). + public var isVideo: Bool { MediaKind.of(path: path) == .video } } struct WidgetShelfAttachment: Codable, Sendable { @@ -51,12 +55,28 @@ struct WidgetShelfSnapshot: Decodable, Sendable { public final class WidgetShelfStore: ObservableObject { @Published public private(set) var items: [WidgetShelfItem] = [] @Published public private(set) var isBusy = false - @Published public private(set) var isCapturing = false + /// The area selection is up; VoiceOver hears it, since the screen changes without a click in the shelf. + @Published public private(set) var isCapturing = false { + didSet { if isCapturing, !oldValue { announce("Select an area to capture. Press Escape to cancel.", .medium) } } + } @Published public private(set) var captureAccessGranted: Bool? - @Published public private(set) var error: String? - @Published public private(set) var notice: String? + /// The status line's failure; it arrives after the action that caused it, so VoiceOver hears it. + @Published public private(set) var error: String? { + didSet { if let error, error != oldValue { announce(Self.spoken(error), .high) } } + } + @Published public private(set) var notice: String? { + didSet { if let notice, notice != oldValue { announce(notice, .medium) } } + } + /// The item the user just removed: gone from the list, still on the shelf until `undoSeconds` run out, so + /// the status line's Undo can bring it back (ux checklist "Undo and destructive actions"). + @Published public private(set) var removed: WidgetShelfItem? /// True once the first inventory read finished, so a view can tell a first load from an item arriving. @Published public private(set) var loaded = false + /// A screen recording started from the Capture shelf runs (from its picker until it ends). + @Published public private(set) var isRecording = false + /// "Record screen…": GenesisKit's generic recorder; "Stop and save" stages the movie here as a capture. + private let screenRecorder: ScreenRecorder? + private var recorderActivity: AnyCancellable? private let request: ([String], Int) async throws -> Data private let recipientSource: () -> [WidgetSession] private let didAttach: (WidgetSession) -> Void @@ -79,6 +99,16 @@ public final class WidgetShelfStore: ObservableObject { private var isChoosingFiles = false private var filePicker: NSOpenPanel? private var stopped = false + private let announce: @MainActor (String, GenAnnounce.Priority) -> Void + /// How long a removal waits behind Undo (`WidgetModel.undoSeconds`), read at each removal. + private let undoSeconds: @MainActor () -> Double + /// Where the removed item stood, so Undo puts it back in place. + private var removedIndex = 0 + private var removalTimer: Task<Void, Never>? + /// The CLI removal once the Undo ran out; teardown waits for it. + private var removal: Task<Void, Never>? + /// Items whose CLI removal started and has not finished; a list read must not show them again. + private var removingIDs: Set<String> = [] public init( binaryPath: String, stateRoot: String? = nil, @@ -87,10 +117,13 @@ public final class WidgetShelfStore: ObservableObject { onCaptureWillBegin: @escaping () -> Void = {}, onCaptureStaged: @escaping () -> Void = {}, onDialogVisibilityChanged: @escaping (Bool) -> Void = { _ in }, - attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil + attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil, + undoSeconds: @escaping @MainActor () -> Double = { WidgetModel.undoSeconds(nil) } ) { screenCaptureAccess = { PermissionAccess.live.isGranted(.screenRecording) } permissionGate = { await PermissionCenter.shared.ensure($0) } + announce = { GenAnnounce.post($0, priority: $1) } + self.undoSeconds = undoSeconds let bridge = ToolsBridge(binaryPath: binaryPath) self.request = { arguments, timeout in var prefix = ["widget"] @@ -103,6 +136,7 @@ public final class WidgetShelfStore: ObservableObject { } return Data(result.stdout.utf8) } + screenRecorder = ScreenRecorder(bridge: bridge) recipientSource = recipients self.didAttach = didAttach self.onCaptureWillBegin = onCaptureWillBegin @@ -115,14 +149,20 @@ public final class WidgetShelfStore: ObservableObject { request: @escaping ([String], Int) async throws -> Data, screenCaptureAccess: @escaping () -> Bool = { true }, permissionGate: @escaping @MainActor (PermissionNeed) async -> Bool = { _ in true }, + screenRecorder: ScreenRecorder? = nil, recipients: @escaping () -> [WidgetSession] = { [] }, didAttach: @escaping (WidgetSession) -> Void = { _ in }, onCaptureWillBegin: @escaping () -> Void = {}, onCaptureStaged: @escaping () -> Void = {}, onDialogVisibilityChanged: @escaping (Bool) -> Void = { _ in }, - attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil + attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil, + announce: @escaping @MainActor (String, GenAnnounce.Priority) -> Void = { _, _ in }, + undoSeconds: @escaping @MainActor () -> Double = { 10 } ) { self.request = request + self.announce = announce + self.undoSeconds = undoSeconds + self.screenRecorder = screenRecorder self.screenCaptureAccess = screenCaptureAccess self.permissionGate = permissionGate recipientSource = recipients @@ -136,6 +176,8 @@ public final class WidgetShelfStore: ObservableObject { /// Coordinator teardown is terminal for this store; hiding a module leaves active staging alone. public func stop() { guard !stopped else { return } + // The user removed it; teardown must not put it back on the shelf. + commitRemoval() stopped = true visibleModules.removeAll() watcher?.stop() @@ -184,9 +226,11 @@ public final class WidgetShelfStore: ObservableObject { let data = try await self.request(["list", "--json"], 15) guard !self.stopped, !Task.isCancelled else { return } let snapshot = try JSONDecoder().decode(WidgetShelfSnapshot.self, from: data) - if self.items != snapshot.items { - self.captureItems = snapshot.items.filter { $0.kind == .capture } - self.items = snapshot.items + // A removed item stays on disk during its Undo; it stays off the list. + let shown = snapshot.items.filter { $0.id != self.removed?.id && !self.removingIDs.contains($0.id) } + if self.items != shown { + self.captureItems = shown.filter { $0.kind == .capture } + self.items = shown } self.statePath = snapshot.statePath if !self.loaded { self.loaded = true } @@ -244,7 +288,7 @@ public final class WidgetShelfStore: ObservableObject { private var screenRecordingNeed: PermissionNeed { PermissionNeed( .screenRecording, - reason: "Capture takes a screenshot of the area you select and stages it in the widget.", + reason: "Capture takes a screenshot of the area you select and keeps it in the widget until you choose an inbox.", grantWorksInNewProcess: true, onGranted: { [weak self] in self?.capture() }) } @@ -255,7 +299,7 @@ public final class WidgetShelfStore: ObservableObject { guard !stopped, !isBusy else { return } isCapturing = true onCaptureWillBegin() - start(success: "Screenshot staged. Choose an inbox when you are ready.") { + start(success: "Screenshot added. Choose an inbox when you are ready.") { do { let data = try await self.request(["capture"], 125) if try JSONDecoder().decode(WidgetShelfCaptureResponse.self, from: data).cancelled == true { @@ -296,13 +340,52 @@ public final class WidgetShelfStore: ObservableObject { operation?.cancel() } + /// Whether "Record screen…" can run here (the store has a recorder; previews and tests may not). + public var canRecord: Bool { screenRecorder != nil && !stopped } + + /// "Record screen…": the person picks a window, an app or a display, records it with the floating controls, and + /// "Stop and save" stages the movie here as a capture, ready for any inbox with the frame review of a video. + public func recordScreen() { + guard !stopped, let screenRecorder else { return } + recorderActivity = screenRecorder.$isActive.removeDuplicates().sink { [weak self] active in + self?.isRecording = active + } + screenRecorder.begin(host: ScreenRecordingHost( + useTitle: "Stop and save", + permissionReason: "Record screen records the window, app or display you choose and keeps the movie on the shelf until you choose an inbox.", + onUse: { [weak self] movie in try await self?.stageRecording(movie) }, + onDiscard: { [weak self] in self?.notice = "Recording discarded." }, + onError: { [weak self] message in self?.report(ToolsBridgeError.refused(message)) }, + onStart: { [weak self] in self?.onCaptureWillBegin() })) + } + + public func showRecordingControls() { + screenRecorder?.showControls() + } + + /// Stages a finished recording as a capture, then removes the recorder's file (the shelf keeps its own copy). + func stageRecording(_ movie: URL) async throws { + guard !stopped else { return } + let stamp = Date().formatted(.dateTime.hour().minute().second()) + let data = try await request(["video", movie.path, "--name", "Screen recording \(stamp).mp4"], 300) + let item = try JSONDecoder().decode(WidgetShelfItem.self, from: data) + guard item.kind == .capture else { throw ToolsBridgeError.refused("The recording could not be added as a capture.") } + do { try FileManager.default.removeItem(at: movie) } catch { + GenesisKit.log("widget shelf: recording cleanup \(error.localizedDescription)") + } + guard !stopped else { return } + notice = "Recording added. Choose an inbox when you are ready." + refresh() + onCaptureStaged() + } + public func importFiles(_ urls: [URL], asImages: Bool = false) { guard !stopped, !isBusy, !urls.isEmpty else { return } guard urls.allSatisfy(\.isFileURL) else { - error = "Only local files can be staged." + error = "Only local files can be added." return } - start(success: urls.count == 1 ? "Item staged." : "\(urls.count) items staged.") { + start(success: urls.count == 1 ? "Item added." : "\(urls.count) items added.") { var failures: [String] = [] for url in urls { try Task.checkCancellation() @@ -328,7 +411,7 @@ public final class WidgetShelfStore: ObservableObject { filePicker = panel panel.canChooseDirectories = false panel.allowsMultipleSelection = true - panel.prompt = asImages ? "Stage images" : "Add to shelf" + panel.prompt = asImages ? "Add images" : "Add to shelf" if asImages { panel.allowedContentTypes = [.image] } panel.begin { [weak self] response in Task { @MainActor in @@ -360,7 +443,7 @@ public final class WidgetShelfStore: ObservableObject { error = "Copy a file or image first." return } - start(success: "Clipboard image staged.") { + start(success: "Clipboard image added.") { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("widget-paste-\(UUID().uuidString)", isDirectory: true) let temporary = directory.appendingPathComponent("Clipboard image.png") try await Task.detached(priority: .userInitiated) { @@ -379,12 +462,68 @@ public final class WidgetShelfStore: ObservableObject { } } + /// Takes `item` off the list at once and removes it from the shelf after `undoSeconds`, unless Undo brings + /// it back first. Removing another item removes the first one now. public func remove(_ item: WidgetShelfItem) { - start(success: "Removed from the shelf. Files and existing drafts are preserved.") { - _ = try await self.request(["remove", item.id], 15) + guard !stopped, !isBusy, let index = items.firstIndex(where: { $0.id == item.id }) else { return } + commitRemoval() + removed = item + removedIndex = index + items.remove(at: index) + captureItems.removeAll { $0.id == item.id } + error = nil + // A capture has no original elsewhere; a file does. + notice = item.kind == .capture ? "Removed from Capture. Drafts that already use it keep it." + : "Removed from the shelf. The original file and existing drafts are kept." + let delay = Duration.seconds(undoSeconds()) + removalTimer = Task { [weak self] in + do { try await Task.sleep(for: delay) } catch { return } + self?.commitRemoval() + } + } + + /// Undo of Remove from shelf: the item comes back in its place. + public func undoRemove() { + guard let item = removed else { return } + removalTimer?.cancel() + removalTimer = nil + removed = nil + if !items.contains(where: { $0.id == item.id }) { + items.insert(item, at: min(removedIndex, items.count)) + captureItems = items.filter { $0.kind == .capture } + } + notice = "\(item.name) is back" + (item.kind == .capture ? "." : " on the shelf.") + refresh() + } + + /// The Undo row's close button: the removal happens now instead of when the Undo time runs out. + public func confirmRemoval() { commitRemoval() } + + /// Removes the item for good: its Undo ran out or was closed, another item was removed, or the store stops. + private func commitRemoval() { + guard let item = removed else { return } + removalTimer?.cancel() + removalTimer = nil + removed = nil + let previous = removal + // The request is held, not the store: a removal that runs at teardown still reaches the CLI. + let request = request + removingIDs.insert(item.id) + removal = Task { [weak self] in + await previous?.value + do { + _ = try await request(["remove", item.id], 15) + } catch { + // Still on the shelf: say why; the list read below shows it again. + if let self, !self.stopped { self.report(error) } + } + self?.removingIDs.remove(item.id) + if let self, !self.stopped { self.refresh() } } } + func waitForRemoval() async { await removal?.value } + public func attach(_ item: WidgetShelfItem, to recipient: WidgetSession) { start(success: "Added to inbox draft. Nothing has been sent.") { if let attachToDraft = self.attachToDraft { @@ -435,10 +574,17 @@ public final class WidgetShelfStore: ObservableObject { } private func report(_ error: Error) { - self.error = error.localizedDescription + // A reply the shelf cannot decode is one sentence on screen; the decoder's own words go to the log only. + self.error = error is DecodingError ? "The shelf could not be read. Try again in a moment." : error.localizedDescription GenesisKit.log("widget shelf: \(error.localizedDescription)") } + /// The first line of an error, short enough for VoiceOver to read out. + nonisolated static func spoken(_ error: String) -> String { + let line = error.split(whereSeparator: \.isNewline).first.map(String.init) ?? error + return line.count > 160 ? String(line.prefix(157)) + "…" : line + } + deinit { watcher?.stop() refreshTask?.cancel() diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift index fcc901c919..877df01f5b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift @@ -381,7 +381,7 @@ private struct WidgetTaskRow: View { if task.isLocal { Text("Written in the widget").font(.caption2).foregroundStyle(.secondary) } else { - Text([task.provider.capitalized, task.sessionTitle ?? task.sessionId, task.sourceContext.aiAgent] + Text([task.provider.capitalized, task.sessionTitle ?? "Untitled session", task.sourceContext.aiAgent] .compactMap { $0 }.joined(separator: " · ")) .font(.caption2).foregroundStyle(.secondary).textSelection(.enabled) if let cwd = task.sourceContext.cwd { @@ -400,7 +400,7 @@ private struct WidgetTaskRow: View { private func menuItems() -> [MenuButtonItem] { var items: [MenuButtonItem] = task.actions.map { action in - .action(action == .acknowledge ? "Start (acknowledge)" : action.title) { store.perform(action, on: task) } + .action(action == .acknowledge ? "Start" : action.title) { store.perform(action, on: task) } } if task.editable { items += [.divider, .action("Edit…") { edit.begin(task) }] diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift index a0406e8056..007ae146ac 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift @@ -477,7 +477,8 @@ public final class WidgetTasksStore: ObservableObject { } private func report(_ failure: Error) { - error = failure.localizedDescription + // A reply the list cannot decode is one sentence on screen; the decoder's own words go to the log only. + error = failure is DecodingError ? "The task list could not be read. Try again in a moment." : failure.localizedDescription PerfLog.mark("widget.tasks \(failure.localizedDescription)") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNote.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNote.swift index 10ea174929..4fe991d830 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNote.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNote.swift @@ -15,6 +15,31 @@ public struct WidgetVoiceNote: Decodable, Identifiable, Equatable, Sendable { public let transcription: String public let error: String? public let provider: String? + /// Recorded from a reply box: its session and decision. + public var context: WidgetVoiceNoteContext? = nil +} + +/// Where a voice note was recorded: a session's reply box, for one decision or a follow-up. +public struct WidgetVoiceNoteContext: Codable, Equatable, Sendable { + public var sessionKey: String + public var sessionTitle: String? + public var decisionId: String? + public var decisionNumber: Int? + public var title: String? + + public init(sessionKey: String, sessionTitle: String? = nil, decisionId: String? = nil, decisionNumber: Int? = nil, + title: String? = nil) { + self.sessionKey = sessionKey + self.sessionTitle = sessionTitle + self.decisionId = decisionId + self.decisionNumber = decisionNumber + self.title = title + } + + /// "DECISION 19 · gt-native-fixes" or the session's title. + public var label: String { + [decisionNumber.map { "DECISION \($0)" }, sessionTitle].compactMap { $0 }.joined(separator: " · ") + } } struct WidgetVoiceNoteSnapshot: Decodable { @@ -26,6 +51,16 @@ struct WidgetVoiceNoteSnapshot: Decodable { struct WidgetVoiceNoteResult: Decodable { let note: WidgetVoiceNote } public struct WidgetVoiceNoteSettings: Sendable { + /// The `transcribe` command line. An empty setting is left out: it passed `--language ""`, which the vault key + /// window then showed as a bare `--language.` (Martin's screenshot, 2026-10-23). + func transcribeArguments(id: String, revision: Int) -> [String] { + var args = ["transcribe", id, "--revision", String(revision), "--provider", provider] + for (flag, value) in [("--account", account), ("--model", model), ("--language", language)] { + if let value, !value.isEmpty { args += [flag, value] } + } + return args + } + public var provider: String public var account: String? public var model: String? diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift index 9ba01e9db0..b048141fa7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift @@ -11,9 +11,62 @@ public extension WidgetVoiceNotesStore { } } +/// The notice for an error the store reports: the message, what was found, and the fixes for that kind of failure. +/// The store keeps only the text, so the kinds are told apart by their exact messages. +@MainActor +enum WidgetVoiceNotesNotice { + struct Content { + let text: String + var detail: String? + var more: String? + var fixes: [NoticeFix] = [] + } + + static let failedRunsCommand = "tools genesis-tools logs failures --tool hub --grep voice-notes --limit 5" + + static func content(for error: String, permission: VoiceMicrophonePermission, bundleID: String?, + reviewMicrophone: @escaping @MainActor () -> Void, + recordAgain: @escaping @MainActor () -> Void) -> Content { + if let refusal = FlowFocusRuntime.lastAudioRefusal, refusal.text == error { + return Content(text: error, detail: refusal.detail, more: refusal.found, + fixes: refusal.fixes + [.button("Record again", symbol: "mic.fill", perform: recordAgain)]) + } + let reset = "tccutil reset Microphone \(bundleID ?? "com.genesiscz.genesistools")" + let kinds: [VoiceCommandFailure] = [.microphonePermission, .microphonePromptOpen, .noAudio, .interrupted, + .captureFailed, .cancelled, .operationFailed, .timedOut] + switch kinds.first(where: { $0.errorDescription == error }) { + case .microphonePermission: + return Content(text: error, detail: "macOS answers \"\(describe(permission))\" for this app's microphone access.", + fixes: [.button("Review microphone access", symbol: "mic", perform: reviewMicrophone), + .command("Or reset the grant from a terminal, so macOS asks again on the next Record", reset)]) + case .microphonePromptOpen: + return Content(text: error, fixes: [.button("Review microphone access", symbol: "mic", perform: reviewMicrophone)]) + case .noAudio, .captureFailed, .interrupted: + return Content(text: error, fixes: [.button("Record again", symbol: "mic.fill", perform: recordAgain), + .command("See why the recorder failed", failedRunsCommand)]) + case .operationFailed, .timedOut: + return Content(text: error, fixes: [.command("See why it failed", failedRunsCommand)]) + case .cancelled, nil: + return Content(text: error, fixes: [.command("See recent failed voice runs", failedRunsCommand)]) + } + } + + private static func describe(_ permission: VoiceMicrophonePermission) -> String { + switch permission { + case .notDetermined: return "not asked yet" + case .authorized: return "allowed" + case .denied: return "denied" + case .restricted: return "restricted" + case .unknown: return "unknown" + } + } +} + private struct WidgetVoiceNotesView: View { @ObservedObject var store: WidgetVoiceNotesStore let presentation: WidgetModulePresentation + /// The error the person dismissed; it stays hidden until the next operation starts (the store clears its error then). + @State private var dismissedError: String? var body: some View { Group { @@ -26,8 +79,8 @@ private struct WidgetVoiceNotesView: View { Label("Voice Notes", systemImage: "recordingtape").font(.system(size: 15, weight: .semibold)) Spacer() Text("\(store.notes.count) local").font(.caption2).foregroundStyle(.secondary) - Button { store.refresh(force: true) } label: { Image(systemName: "arrow.clockwise") } - .buttonStyle(.plain).accessibilityLabel("Refresh voice notes").disabled(store.isBusy) + IconButton(systemName: "arrow.clockwise", tooltip: "Refresh voice notes") { store.refresh(force: true) } + .disabled(store.isBusy) } if presentation == .expanded { expanded @@ -44,7 +97,14 @@ private struct WidgetVoiceNotesView: View { .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in store.refreshMicrophonePermission() } + .onChange(of: store.phase) { _, phase in + if phase != nil { dismissedError = nil } + } + } + private var shownError: String? { + guard let error = store.error, error != dismissedError else { return nil } + return error } @ViewBuilder private var expanded: some View { @@ -59,7 +119,7 @@ private struct WidgetVoiceNotesView: View { Text("Up to 30 seconds").font(.caption2).foregroundStyle(.secondary) Spacer() if store.isBusy { - Button("Cancel", action: store.cancel).buttonStyle(.borderless) + Button("Cancel", action: store.cancel).buttonStyle(.genHover()) } } if let phase = store.phase { @@ -69,7 +129,9 @@ private struct WidgetVoiceNotesView: View { if phase == "Recording" { WidgetVoiceNoteMeterView(meter: store.meter) } } } - if let guidance = store.microphoneGuidance { + // The permission notice below carries the same "Review microphone access" fix, so the box steps aside for it. + if let guidance = store.microphoneGuidance, + shownError != VoiceCommandFailure.microphonePermission.errorDescription { VStack(alignment: .leading, spacing: 6) { Label(guidance, systemImage: "mic.slash").font(.system(size: 11)) .fixedSize(horizontal: false, vertical: true) @@ -80,8 +142,25 @@ private struct WidgetVoiceNotesView: View { .background(.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) .accessibilityIdentifier("voice-microphone-permission") } - if let error = store.error { - Text(error).font(.system(size: 11)).foregroundStyle(KitPalette.removed).textSelection(.enabled) + if let error = shownError { + let notice = WidgetVoiceNotesNotice.content( + for: error, permission: store.microphonePermission, bundleID: Bundle.main.bundleIdentifier, + reviewMicrophone: store.reviewMicrophoneAccess, recordAgain: { store.record() }) + NoticePill(text: notice.text, detail: notice.detail, more: notice.more, tone: .error, fixes: notice.fixes) { + dismissedError = error + } + .accessibilityIdentifier("voice-notes-error") + } else if store.discarded != nil { + // No ⌘Z here: the transcript editor below owns ⌘Z for its own text. + HStack(spacing: 8) { + Label("Recording discarded.", systemImage: "trash").font(.system(size: 11)).foregroundStyle(.secondary) + Spacer(minLength: 4) + Button("Undo", action: store.undoDiscard) + .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) + .instantTooltip("Brings the recording back. It is deleted for good after a few seconds.") + .accessibilityIdentifier("voice-notes-undo-discard") + IconButton(systemName: "xmark", tooltip: "Close; the recording is deleted now", action: store.confirmDiscard) + } } else if let receipt = store.receipt { Label(receipt, systemImage: "checkmark.circle").font(.system(size: 11)).foregroundStyle(.secondary) } @@ -117,9 +196,10 @@ private struct WidgetVoiceNotesView: View { .frame(minHeight: 100).disabled(store.isBusy) .accessibilityLabel("Voice note transcript") HStack { - Button("Save text", action: store.saveText).buttonStyle(.borderless) + Button("Save text", action: store.saveText).buttonStyle(.genHover()) Spacer() - Button("Discard", role: .destructive, action: store.discard).buttonStyle(.borderless) + Button("Discard", role: .destructive, action: store.discard).buttonStyle(.genHover(accent: .red)) + .instantTooltip("Removes this recording. Undo brings it back for a few seconds.") }.font(.caption).disabled(store.isBusy) WidgetRecipientPicker(sessions: store.sessions, selection: $store.recipientKey, presentationChanged: store.recipientPickerVisibilityChanged) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift index 5ede1b8384..f9ca7a0a59 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift @@ -23,6 +23,9 @@ public final class WidgetVoiceNotesStore: ObservableObject { @Published public var selectedID: String? @Published public var recipientKey = "" @Published private var drafts: [String: String] = [:] + /// The note the user just discarded: gone from the list, still on disk until `undoSeconds` run out, so + /// Undo can bring it back (ux checklist "Undo and destructive actions"). + @Published public private(set) var discarded: WidgetVoiceNote? public let meter = WidgetVoiceRecordingMeter() public var recipientPickerVisibilityChanged: (Bool) -> Void = { _ in } private let request: ([String]) async throws -> Data @@ -31,6 +34,7 @@ public final class WidgetVoiceNotesStore: ObservableObject { private let cancelCommand: () -> Void private let acquireAudio: () async throws -> any VoiceRecordingLease private let settings: () -> WidgetVoiceNoteSettings + private var incomingDirectory: String? private let sessionsSource: () -> [WidgetSession] private let attachDraft: (WidgetSession, WidgetVoiceNote) async throws -> String private let micLauncher: String @@ -40,6 +44,15 @@ public final class WidgetVoiceNotesStore: ObservableObject { private let presentMicrophonePermission: @MainActor (PermissionNeed) -> Void private var operation: Task<Void, Never>? private var refreshTask: Task<Void, Never>? + /// How long a discard waits behind Undo (`WidgetModel.undoSeconds`), read at each discard. + private let undoSeconds: @MainActor () -> Double + /// Where the discarded note stood in the list, so Undo puts it back in place. + private var discardedIndex = 0 + private var discardTimer: Task<Void, Never>? + /// The CLI delete of a discarded note once its Undo ran out; teardown waits for it. + private var discardDelete: Task<Void, Never>? + /// Notes whose CLI delete started and has not finished; a list read must not show them again. + private var deletingIDs: Set<String> = [] private var watcher: DirectoryWatcher? private var watchedPath: String? private var cached = false @@ -51,7 +64,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { acquireAudio: @escaping () async throws -> any VoiceRecordingLease, settings: @escaping () -> WidgetVoiceNoteSettings, sessions: @escaping () -> [WidgetSession], - attachDraft: @escaping (WidgetSession, WidgetVoiceNote) async throws -> String) { + attachDraft: @escaping (WidgetSession, WidgetVoiceNote) async throws -> String, + undoSeconds: @escaping @MainActor () -> Double = { WidgetModel.undoSeconds(nil) }) { let bridge = ToolsBridge(binaryPath: binaryPath) let transport = VoiceCommandTransport(binaryPath: binaryPath, stateRoot: stateRoot) let prefix = ["widget"] + (stateRoot.map { ["--state-root", $0] } ?? []) + ["voice-notes"] @@ -64,7 +78,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { return Data(result.stdout.utf8) }, execute: { args, lease, event in try await transport.run(args: args, lease: lease, onEvent: event) }, finishCapture: { transport.finishRecording() }, cancelCommand: { transport.cancel() }, - acquireAudio: acquireAudio, settings: settings, sessions: sessions, attachDraft: attachDraft) + acquireAudio: acquireAudio, settings: settings, sessions: sessions, attachDraft: attachDraft, + undoSeconds: undoSeconds) } init(micLauncher: String, request: @escaping ([String]) async throws -> Data, @@ -76,8 +91,10 @@ public final class WidgetVoiceNotesStore: ObservableObject { readMicrophonePermission: @escaping () -> VoiceMicrophonePermission = { .current }, requestMicrophonePermission: @escaping () async throws -> VoiceMicrophonePermission = { try await VoiceMicrophonePermission.request() }, activateForMicrophone: @escaping () -> Void = { NSApp?.activate(ignoringOtherApps: true) }, - presentMicrophonePermission: @escaping @MainActor (PermissionNeed) -> Void = { PermissionCenter.shared.require($0) }) { + presentMicrophonePermission: @escaping @MainActor (PermissionNeed) -> Void = { PermissionCenter.shared.require($0) }, + undoSeconds: @escaping @MainActor () -> Double = { 10 }) { self.micLauncher = micLauncher + self.undoSeconds = undoSeconds self.readMicrophonePermission = readMicrophonePermission self.requestMicrophonePermission = requestMicrophonePermission self.activateForMicrophone = activateForMicrophone @@ -121,8 +138,11 @@ public final class WidgetVoiceNotesStore: ObservableObject { try Task.checkCancellation() guard !stopped, generation == requestedGeneration else { return } let snapshot = try JSONDecoder().decode(WidgetVoiceNoteSnapshot.self, from: data) - notes = snapshot.notes - drafts = drafts.filter { id, _ in self.notes.contains { $0.id == id } } + // A discarded note is still on disk during its Undo; it stays out of the list. + notes = snapshot.notes.filter { $0.id != discarded?.id && !deletingIDs.contains($0.id) } + drafts = drafts.filter { id, _ in + self.notes.contains { $0.id == id } || id == self.discarded?.id || self.deletingIDs.contains(id) + } if selected == nil { selectedID = notes.first?.id } cached = true watch(snapshot.statePath) @@ -193,6 +213,41 @@ public final class WidgetVoiceNotesStore: ObservableObject { } } + /// Where the reply box's live dictation writes its audio (`voice listen --save-audio`) before `fileLiveNote` + /// keeps it. The CLI names the folder once (it follows GENESIS_TOOLS_HOME); later clips are named here. + public func newIncomingClipPath() async throws -> String { + if let incomingDirectory { + return (incomingDirectory as NSString).appendingPathComponent(UUID().uuidString.lowercased() + ".pcm") + } + struct Path: Decodable { let path: String } + let data = try await execute(["incoming-path", "--json"], nil) { _ in } + let path = try JSONDecoder().decode(Path.self, from: data).path + incomingDirectory = (path as NSString).deletingLastPathComponent + return path + } + + /// Keeps a live dictation from the reply box as a voice note: its audio, its text, and where it was spoken + /// (Martin, 2026-10-23: it "can go into the voice notes place (with context of which session, which decision")). + public func fileLiveNote(clip: String, text: String, context: WidgetVoiceNoteContext, provider: String, + model: String?, language: String?) { + var args = ["add", "--clip", clip, "--text", text, "--provider", provider, "--json"] + if let json = try? JSONEncoder().encode(context), let encoded = String(data: json, encoding: .utf8) { + args += ["--context", encoded] + } + for (flag, value) in [("--model", model), ("--language", language)] { + if let value, !value.isEmpty { args += [flag, value] } + } + Task { [weak self] in + guard let self else { return } + do { + let data = try await self.execute(args, nil) { _ in } + self.put(try JSONDecoder().decode(WidgetVoiceNoteResult.self, from: data).note) + } catch { + self.report(error) + } + } + } + public func record(input: String = "mic") { begin(input == "mic" ? "Checking microphone access" : "Starting recording") { [self] in meter.reset() @@ -209,8 +264,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { try await lease.release() throw CancellationError() } - let data = try await execute(["record", "--input", input, "--mic-launcher", micLauncher, - "--wait-for-start", "--stop-on-stdin", "--json"], lease) { [weak self, weak meter] event in + let args = ["record", "--input", input, "--mic-launcher", micLauncher, "--wait-for-start", "--stop-on-stdin", "--json"] + let data = try await execute(args, lease) { [weak self, weak meter] event in meter?.receive(event) if event.kind == "recording", let self, self.phase == "Starting recording" { self.phase = "Recording" } } @@ -232,11 +287,7 @@ public final class WidgetVoiceNotesStore: ObservableObject { let configuration = settings() begin("Transcribing") { [self] in let saved = try await save(note) - var args = ["transcribe", saved.id, "--revision", String(saved.revision), "--provider", configuration.provider] - if let account = configuration.account { args += ["--account", account] } - if let model = configuration.model { args += ["--model", model] } - if let language = configuration.language { args += ["--language", language] } - let data = try await execute(args, nil) { _ in } + let data = try await execute(configuration.transcribeArguments(id: saved.id, revision: saved.revision), nil) { _ in } try Task.checkCancellation() let updated = try JSONDecoder().decode(WidgetVoiceNoteResult.self, from: data).note put(updated) @@ -264,15 +315,66 @@ public final class WidgetVoiceNotesStore: ObservableObject { } } + /// Takes the selected note off the list at once and deletes it after `undoSeconds`, unless Undo brings it + /// back first. A second discard deletes the first one now. public func discard() { - guard let note = selected else { return } - begin("Discarding") { [self] in - _ = try await request(["discard", note.id, "--revision", String(note.revision)]) - generation += 1 - notes.removeAll { $0.id == note.id } - drafts[note.id] = nil - selectedID = notes.first?.id - receipt = "Local recording discarded." + guard !stopped, !isBusy, let note = selected, let index = notes.firstIndex(where: { $0.id == note.id }) else { return } + deleteDiscarded() + generation += 1 + discarded = note + discardedIndex = index + notes.remove(at: index) + selectedID = notes.first?.id + error = nil + receipt = "Recording discarded." + let delay = Duration.seconds(undoSeconds()) + discardTimer = Task { [weak self] in + do { try await Task.sleep(for: delay) } catch { return } + self?.deleteDiscarded() + } + } + + /// Undo of Discard: the note returns to its place and is selected again. + public func undoDiscard() { + guard let note = discarded else { return } + discardTimer?.cancel() + discardTimer = nil + discarded = nil + generation += 1 + if !notes.contains(where: { $0.id == note.id }) { notes.insert(note, at: min(discardedIndex, notes.count)) } + selectedID = note.id + receipt = "Recording restored." + } + + /// The Undo row's close button: the delete happens now instead of when the Undo time runs out. + public func confirmDiscard() { deleteDiscarded() } + + /// Deletes the discarded note for good: its Undo ran out or was closed, another note was discarded, or the store + /// stops. + private func deleteDiscarded() { + guard let note = discarded else { return } + discardTimer?.cancel() + discardTimer = nil + discarded = nil + let previous = discardDelete + // The request is held, not the store: a delete that runs at teardown still reaches the CLI. + let request = request + deletingIDs.insert(note.id) + discardDelete = Task { [weak self] in + await previous?.value + do { + _ = try await request(["discard", note.id, "--revision", String(note.revision)]) + self?.deletingIDs.remove(note.id) + // The edits go only once the note is gone: a refused delete brings the note back with them. + self?.drafts[note.id] = nil + } catch { + // The recording is still on disk: show why and read the list again, so it comes back. + self?.deletingIDs.remove(note.id) + guard let self, !self.stopped else { return } + self.report(error) + self.cached = false + self.refresh(force: true) + } } } @@ -329,14 +431,18 @@ public final class WidgetVoiceNotesStore: ObservableObject { func waitForOperation() async { await operation?.value } func waitForRefresh() async { await refreshTask?.value } + func waitForDiscard() async { await discardDelete?.value } public func cancel() { operation?.cancel(); cancelCommand() } public func shutdown() async { stop() await operation?.value await refreshTask?.value + await discardDelete?.value } public func stop() { + // The user discarded it; teardown must not bring it back on the next launch. + deleteDiscarded() stopped = true cancel() refreshTask?.cancel() diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index fc245684e9..a1e8e15b15 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -56,16 +56,18 @@ struct WidgetSessionActivity: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel(status.label + ", \(max(0, count)) inbox notifications") + .accessibilityLabel(status.label + ", " + Plural.count(max(0, count), "inbox item")) } } struct AgentWidgetPreview: View { @ObservedObject var model: WidgetModel let surface: WidgetSurfaceID - private var waiting: Int { model.waitingSessionCount } + /// The session whose cmux panel was opened from the row's accessibility action (the button itself exists only on the hovered row). + @State private var cmuxPanelKey: String? var body: some View { + let _ = RenderProbe.hit("widget.preview") VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: "tray").font(.system(size: 12, weight: .medium)) @@ -73,100 +75,103 @@ struct AgentWidgetPreview: View { Text("Inbox").font(.system(size: 12, weight: .semibold)) Spacer() if model.inbox.needsAnswer > 0 { - Button { model.openInboxNotification(on: surface, needsAnswer: true) } label: { + Button { model.toggleInboxOverview(on: surface) } label: { HStack(spacing: 4) { WidgetInboxCount(count: model.inbox.needsAnswer, needsAnswer: true, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) + pulse: model.waitingPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) Text("need you").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Open need you inbox items") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every inbox item that waits for your answer") } if model.inbox.unread > 0 { - Button { model.openInboxNotification(on: surface, needsAnswer: false) } label: { + Button { model.toggleInboxOverview(on: surface) } label: { HStack(spacing: 4) { WidgetInboxCount(count: model.inbox.unread, needsAnswer: false, pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) Text("unread").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Open unread inbox items") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every unread inbox item, from all sessions") } if model.inboxCount == 0 { - Text(model.inbox.complete ? "All caught up" : "Inbox status unavailable") + Text(model.snapshot == nil ? "Loading inbox" : model.inbox.complete ? "All caught up" : "Inbox status unavailable") .font(.system(size: 10)).foregroundStyle(.secondary) } else { IconButton(systemName: "checkmark.circle", tooltip: "Mark all read. Nothing is deleted.", size: 11, action: model.markAllRead) .accessibilityLabel("Mark all inbox items read") } - }.padding(.horizontal, 5).padding(.bottom, 5) + }.padding(.horizontal, 5) + + HStack(spacing: 6) { + GenSegmentedTabs( + "Show", items: WidgetPreviewChoices.filters.map { .init($0.value, $0.title) }, + selection: Binding(get: { model.previewFilter }, set: { model.setPreview(filter: $0) }), + minSegmentWidth: 70, identifier: "widget.preview.filter") + Spacer(minLength: 4) + // The order is named beside its icon, so reading it needs no hover (Martin, 2026-10-25). + Button { + let sorts = WidgetPreviewChoices.sorts.map(\.value) + let index = sorts.firstIndex(of: model.previewSort) ?? 0 + model.setPreview(sort: sorts[(index + 1) % sorts.count]) + } label: { + HStack(spacing: 3) { + Image(systemName: "arrow.up.arrow.down").font(.system(size: 10.5)) + Text(WidgetPreviewChoices.shortTitle(sort: model.previewSort)).font(.system(size: 11)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 5).frame(minHeight: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + .instantTooltip(sortTooltip) + .accessibilityLabel("Order: " + WidgetPreviewChoices.title(sort: model.previewSort)) + .accessibilityHint("Click for the next order") + IconButton(systemName: "slider.horizontal.3", tooltip: "Choose what this list shows and its order", + size: 10.5) { + model.showSettingsPage?("widgets.sessions#inbox-preview") + }.accessibilityLabel("Inbox preview settings") + }.padding(.horizontal, 5).padding(.bottom, 3) - if model.previewSessions.isEmpty { + if model.previewList.isEmpty && model.snapshot == nil { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading sessions").font(.system(size: 11)).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 5).padding(.vertical, 6) + .accessibilityElement(children: .combine) + } else if model.previewList.isEmpty { HStack(spacing: 12) { GenesisWidgetMark() VStack(alignment: .leading, spacing: 4) { - Text("Nothing needs you.").font(.system(size: 13, weight: .medium)) + Text(model.previewFilter == "active" ? "Nothing is running." : "Nothing needs you.") + .font(.system(size: 13, weight: .medium)) Text("New questions and finished work appear here.") .font(.system(size: 11)).foregroundStyle(.secondary) } - }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading).padding(.horizontal, 5) + }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 5).padding(.vertical, 6) } else { - ForEach(model.previewSessions) { session in - Button { - model.openInboxNotification(on: surface, key: session.key) - } label: { - HStack(spacing: 9) { - AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) - VStack(alignment: .leading, spacing: 4) { - Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) - HStack(spacing: 5) { - Text( - session.project.isEmpty ? session.target.provider.capitalized : session.project - ) - .lineLimit(1) - Text("·") - Text(session.visualStatus.label).lineLimit(1) - }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) - } - Spacer(minLength: 2) - if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { - WidgetInboxCount(count: inbox.unread + inbox.needsAnswer, needsAnswer: inbox.needsAnswer > 0, - pulse: model.inboxPulseFor(session.key), reduceMotion: model.effectiveReduceMotion, - complete: model.inbox.complete) - } else { - WidgetActivityIndicator(status: session.visualStatus, animate: !model.effectiveReduceMotion) - .frame(width: 12, height: 12) - } - } - .padding(.horizontal, 9).padding(.vertical, 9) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - session.status == "waiting" ? Color.orange.opacity(0.065) : .white.opacity(0.025), - in: RoundedRectangle(cornerRadius: 10)) + FittedScroll(maxHeight: 360, initialHeight: model.previewListHeight, + measured: { if $0 > 0 { model.previewListHeight = $0 } }) { + LazyVStack(spacing: 6) { + ForEach(model.previewList) { session in row(session) } } - .buttonStyle(.genHoverRow()) - .accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label) } } - Spacer(minLength: 0) Button { model.section = "Sessions" + model.inboxOverview = false model.openModule("agents", on: surface) } label: { HStack { Image(systemName: "rectangle.stack").font(.system(size: 10)) - Text("Projects & sessions").font(.system(size: 10, weight: .medium)) + Text("Projects & sessions").font(.system(size: 11, weight: .medium)) Spacer() - if model.activeSessionCount > 0 { - Text("\(model.activeSessionCount) active").font(.system(size: 9.5)).monospacedDigit() - } + Text(Plural.count(model.previewList.count, "session")).font(.system(size: 11)).monospacedDigit() Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold)) } - .foregroundStyle(.white.opacity(0.5)).padding(.horizontal, 5).padding(.vertical, 5) + .foregroundStyle(.white.opacity(0.6)).padding(.horizontal, 5).padding(.vertical, 5) .contentShape(Rectangle()) }.buttonStyle(.genHoverPlain()) - .instantTooltip(model.activeSessionCount > 0 - ? "\(model.activeSessionCount) sessions are working or wait for you. Browse every project and session." - : "Browse every project and session") + .instantTooltip("Browse every project and session") .accessibilityLabel("Browse all projects and sessions") } .padding(.horizontal, 13).padding(.vertical, 14) @@ -175,5 +180,152 @@ struct AgentWidgetPreview: View { .fixedSize(horizontal: false, vertical: true) .onGeometryChange(for: CGFloat.self, of: \.size.height) { model.reportPreviewFit($0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + // Like the row button's own popover: while it is open the edge panel must not collapse. + .onChange(of: cmuxPanelKey) { _, key in model.cmuxPopoverOpen = key != nil } + .onDisappear { if cmuxPanelKey != nil { model.cmuxPopoverOpen = false } } + // A row that leaves the list takes its popover along; its key must not hold the panel open. + .onChange(of: model.previewList.map(\.key)) { _, keys in + if let key = cmuxPanelKey, !keys.contains(key) { cmuxPanelKey = nil } + } + } + + private var sortTooltip: String { + let sorts = WidgetPreviewChoices.sorts + let index = sorts.firstIndex { $0.value == model.previewSort } ?? 0 + return "Order: \(sorts[index].title). Click for \(sorts[(index + 1) % sorts.count].title)." + } + + private func row(_ session: WidgetSession) -> some View { + WidgetHoverRow { hovering in HStack(spacing: 4) { + Button { + model.openInboxNotification(on: surface, key: session.key) + } label: { + HStack(spacing: 9) { + AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) + VStack(alignment: .leading, spacing: 3) { + Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) + HStack(spacing: 5) { + Text(session.project.isEmpty ? session.target.provider.capitalized : session.project) + .lineLimit(1) + Text("·") + Text(session.visualStatus.label).lineLimit(1) + }.font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) + WidgetSessionTimes(session: session, inbox: model.inboxFor(session.key)) + // The waiting question itself, so an alert that folded can be found again here (Martin, + // 2026-10-25: "I can't seem to find that same stuff anywhere"). + if model.snapshot?.state.preferences.previewQuestion ?? true, + let pending = model.inboxFor(session.key)?.pendingItem, let title = pending.title, !title.isEmpty { + Text(WidgetAlerts.kicker(pending) + " · " + title) + .font(.system(size: 11)).foregroundStyle(Color.orange.opacity(0.9)).lineLimit(1) + } + } + Spacer(minLength: 2) + if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { + WidgetInboxCount(count: inbox.unread + inbox.needsAnswer, needsAnswer: inbox.needsAnswer > 0, + pulse: model.inboxPulseFor(session.key), reduceMotion: model.effectiveReduceMotion, + complete: model.inbox.complete) + } else { + WidgetActivityIndicator(status: session.visualStatus, animate: !model.effectiveReduceMotion) + .frame(width: 12, height: 12) + } + } + .padding(.horizontal, 9).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 10)) + .accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label) + WidgetCmuxButton(model: model, session: session, live: hovering) + } } + .padding(.trailing, 4) + .accessibilityAction(named: "Open cmux for " + session.title) { cmuxPanelKey = session.key } + .popover(isPresented: Binding(get: { cmuxPanelKey == session.key }, set: { if !$0 { cmuxPanelKey = nil } }), + arrowEdge: .trailing) { + WidgetCmuxPanel(model: model, session: session).padding(14).frame(width: 380) + } + .background( + model.inboxFor(session.key).map { $0.needsAnswer > 0 } == true || session.status == "waiting" + ? Color.orange.opacity(0.065) : .white.opacity(0.025), + in: RoundedRectangle(cornerRadius: 10)) + .attentionPulse(trigger: model.attentionTrigger(session.key), style: rowAttention, since: model.attentionStart(session.key)) + } + + private var rowAttention: AttentionPulseStyle { + var style = model.attentionStyle + style.cornerRadius = 10 + return style + } +} + +/// When a session was last active and when its newest question or answer arrived: "working now · asked 3m ago", +/// "active 12m ago · answered 1h ago". Both live, so a row left open keeps counting. +struct WidgetSessionTimes: View { + let session: WidgetSession + let inbox: WidgetInboxSession? + + var body: some View { + HStack(spacing: 5) { + if session.visualStatus == .working { + Text("working now") + } else if session.activityAt > 0 { + LiveAgo(date: Date(timeIntervalSince1970: session.activityAt / 1000), style: .brief) { "active " + $0 } + } + if let entry = Self.latest(inbox) { + Text("·") + LiveAgo(date: Date(timeIntervalSince1970: entry.at / 1000), style: .brief) { entry.verb + " " + $0 } + .foregroundStyle(entry.waiting ? Color.orange.opacity(0.8) : .white.opacity(0.6)) + } + } + .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)).lineLimit(1) + } + + /// The newest entry: a question still waiting wins, since that is what the row is about. + static func latest(_ inbox: WidgetInboxSession?) -> (verb: String, at: Double, waiting: Bool)? { + if let item = inbox?.pendingItem, item.at.isFinite, item.at > 0 { + return (item.kind == "decision" ? "decision asked" : "asked", item.at, true) + } + guard let item = inbox?.unreadItem ?? inbox?.latest, item.at.isFinite, item.at > 0 else { return nil } + switch item.kind { + case "answer": return ("answered", item.at, false) + case "result": return ("finished", item.at, false) + default: return ("new item", item.at, false) + } + } +} + +/// The header line under a session's title: agent, project and model, then whether it works now or how long it +/// has been idle, and its context size (Martin, 2026-10-23: "the header probably can have way more info"). +struct WidgetSessionFacts: View { + let session: WidgetSession + let contextTokens: Int? + let animate: Bool + + var body: some View { + HStack(spacing: 5) { + Text(Self.origin(session)).lineLimit(1).truncationMode(.middle) + Text("·") + if session.visualStatus == .working { + WidgetActivityIndicator(status: .working, animate: animate) + Text("working").foregroundStyle(session.visualStatus.color) + } else if session.activityAt > 0 { + LiveAgo(date: Date(timeIntervalSince1970: session.activityAt / 1000), style: .brief) { "idle " + $0 } + } else { + Text(session.visualStatus.label) + } + if let contextTokens { + Text("·") + // The header is the session chooser's button, whose tooltip covers this line; VoiceOver says it. + Text("ctx " + SessionFormat.tokens(contextTokens)).monospacedDigit() + .accessibilityLabel("Context size of the last model call, \(contextTokens) tokens") + } + } + .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } + + /// "Claude · my-project · opus-5-5": the agent, the project, and the model without its vendor prefix. + static func origin(_ session: WidgetSession) -> String { + let model = session.model.map { $0.replacingOccurrences(of: "claude-", with: "") } + return [AIProviders.meta(for: session.target.provider).displayName, session.project, model] + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift index ec37a3c67e..c85d5426ad 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift @@ -25,16 +25,42 @@ public enum WidgetAlerts { items.filter(\.needsAnswer).max { $0.at < $1.at } ?? items.max { $0.at < $1.at } } - /// The popup's first line. + /// The popup's first line: "Decision 4", "New question", "New answer". Sentence case, one word per thing. public static func kicker(_ item: WidgetInboxItem) -> String { switch item.kind { - case "decision": return item.number.map { "New decision · DECISION \($0)" } ?? "New decision" + case "decision": return item.number.map { "Decision \($0)" } ?? "New decision" case "form": return "New question" case "answer": return "New answer" default: return "New in the inbox" } } + /// What VoiceOver says when an alert arrives: what it is, its title and the session that sent it. + public static func announcement(_ item: WidgetInboxItem, session: String?) -> String { + var parts = [kicker(item)] + if let title = item.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { parts.append(title) } + if let session, !session.isEmpty { parts.append("from " + session) } + return parts.joined(separator: ", ") + } + + /// Seconds the alert popup stays while the pointer is not on it (`alertPopupSeconds`, default 7). + public static func popupSeconds(_ preferences: WidgetPreferences?) -> Double { + max(3, min(60, preferences?.alertPopupSeconds ?? 7)) + } + + /// The sound for a burst: `alertSound` when anything in it waits for an answer, else the answers' own sound + /// (`alertAnswerSound`, where "same" means `alertSound`). nil plays the default sound. + public static func sound(for items: [WidgetInboxItem], preferences: WidgetPreferences?) -> String? { + let asks = items.contains { $0.needsAnswer || $0.kind == "decision" || $0.kind == "form" } + guard !asks, let answer = preferences?.alertAnswerSound, answer != "same" else { return preferences?.alertSound } + return answer + } + + /// Seconds the session behind an alert flashes in the widget; 0 is off. + public static func flashSeconds(_ preferences: WidgetPreferences?) -> Double { + max(0, min(30, preferences?.alertFlashSeconds ?? 5)) + } + private static var lastChime = Date.distantPast /// Plays `name` once; a burst of arrivals within 1.5 s chimes once. "none" or an unknown name is silent. @@ -42,16 +68,31 @@ public enum WidgetAlerts { let sound = name ?? defaultSound guard sound != "none", now.timeIntervalSince(lastChime) >= 1.5 else { return } lastChime = now - guard let player = NSSound(named: NSSound.Name(sound)) else { + guard NSSound(named: NSSound.Name(sound)) != nil else { PerfLog.mark("widget.alert unknown sound \(sound)") return } - player.play() + play(sound) + } + + /// `NSSound.play` waits for the output device: about 0.8 s on the main thread on 2026-10-23 (HangWatch), long + /// enough to freeze the widget the moment an alert arrives. It plays from a background queue instead. + private static func play(_ name: String) { + soundQueue.async { + guard let player = NSSound(named: NSSound.Name(name)) else { return } + player.play() + playing.append(player) + if playing.count > 4 { playing.removeFirst() } + } } + private static let soundQueue = DispatchQueue(label: "genesiskit.widget.chime", qos: .userInitiated) + /// Keeps the last few players alive until they finish; a released NSSound stops at once. + private nonisolated(unsafe) static var playing: [NSSound] = [] + /// Plays `name` now, for the settings page's preview button. @MainActor public static func preview(_ name: String) { guard name != "none" else { return } - NSSound(named: NSSound.Name(name))?.play() + play(name) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift new file mode 100644 index 0000000000..40afffd5b9 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// The chrome of a session bubble on the rail and the top strip: a ring that answers the pointer, and the flash of +/// a session that just asked for you. +/// +/// Hover: the ring fades in strong, then settles to a quieter ring while the pointer stays, so a bubble reads as a +/// control the moment it is reached (Martin, 2026-10-23: "missing hover effects (pulsing around)"). Opacity only, +/// two timed steps, never a repeating animation: a `repeatForever` lays the whole window out every frame (swift skill). +struct WidgetBubbleChrome: ViewModifier { + let trigger: Int + let attention: AttentionPulseStyle + let reduceMotion: Bool + /// When the session's flash began; a bubble rebuilt mid-flash (the rail reordered) plays the rest of it. + var since: Date? + /// Room around the bubble that still takes its click and hover: half the gap to each neighbour, so the strip + /// between two bubbles is never dead (ux checklist, Hit targets). The caller takes the same amount out of its + /// spacing, so nothing moves. + var hitPadding = EdgeInsets() + @State private var ring = 0.0 + @State private var settle: Task<Void, Never>? + + func body(content: Content) -> some View { + content + .background(Circle().fill(Color.white.opacity(ring > 0 ? 0.07 : 0)).padding(2)) + .overlay(Circle().strokeBorder(Color.white.opacity(ring), lineWidth: 1.5).padding(2).allowsHitTesting(false)) + .attentionPulse(trigger: trigger, style: attention, since: since) + .padding(hitPadding) + // The whole target takes clicks and hover at every ring value: the count is not hit-testable, and SwiftUI + // does not hit-test the fill at opacity 0, so a bubble whose attention ended took no click at all. With + // hit padding the target is the padded rectangle; without it, the circle. + .contentShape(hitPadding == EdgeInsets() ? AnyShape(Circle()) : AnyShape(Rectangle())) + .onHover { inside in + settle?.cancel() + guard inside else { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.18)) { ring = 0 } + return + } + guard !reduceMotion else { + ring = 0.5 + return + } + withAnimation(.easeOut(duration: 0.14)) { ring = 0.9 } + settle = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(220)) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.35)) { ring = 0.45 } + } + } + .onDisappear { settle?.cancel() } + } +} + +extension WidgetModel { + /// A rail bubble's tooltip under `bubbleTooltip`: "auto" shows it only while hover previews are off, because + /// the preview already names the session and an extra label over it read as the preview failing to open. + func bubbleTooltip(_ content: TooltipContent) -> TooltipContent { + switch snapshot?.state.preferences.bubbleTooltip ?? "auto" { + case "always": return content + case "never": return TooltipContent() + default: return layout.hoverPreviews ? TooltipContent() : content + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift new file mode 100644 index 0000000000..e66c99a4a0 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift @@ -0,0 +1,148 @@ +import SwiftUI + +/// The cmux button every widget session carries: a popover with the shared `CmuxSessionPanel` (Focus, open in a pane, +/// choose a pane), the same block the hub's session sidebar shows. The widget drives it through its own `tools` +/// bridge; the pane moves go through GenesisKit's `CmuxCommandRunner`, which the hub's `CmuxHost` uses too. +struct WidgetCmuxButton: View { + @ObservedObject var model: WidgetModel + let session: WidgetSession + var size: CGFloat = 11 + /// In a list, false on every row but the hovered or selected one: the row then carries no button, tooltip or + /// hover sensor, because an accessibility client walks every control of every row per update (swift skill, + /// "Dense lists and accessibility clients"). The popover stays attached, so an open one survives the pointer leaving. + var live = true + /// The list this button sits in ("sessions"), so a row's accessibility action (`WidgetModel.requestCmux`) opens the + /// popover of this row's button and not of another button for the same session elsewhere on screen. + var requestScope: String? + @State private var open = false + + var body: some View { + Group { + if live || open { + IconButton(systemName: "terminal", tooltip: "cmux: focus this session, or resume it in a pane", size: size) { + open = true + } + .accessibilityLabel("cmux for " + session.title) + .accessibilityIdentifier("widget.cmux." + session.key) + } else { + Color.clear.frame(width: size + 13, height: size + 13).accessibilityHidden(true) + } + } + .popover(isPresented: $open, arrowEdge: .trailing) { + WidgetCmuxPanel(model: model, session: session).padding(14).frame(width: 380) + } + // The popover takes the pointer out of the edge panel; while it is open the panel must not collapse. Its own + // flag, as the session chooser has one: closing it must never release a window the host opened. + .onChange(of: open) { _, value in model.cmuxPopoverOpen = value } + .onDisappear { if open { model.cmuxPopoverOpen = false } } + // A keyboard or VoiceOver user cannot reach the button of a row that is not hovered; the row's accessibility + // action asks for it through the model, and the popover opens on this row (ux catalogue R5). + .onChange(of: model.cmuxRequest) { _, request in + guard let request, request.key == session.key, request.scope == requestScope else { return } + model.cmuxRequest = nil + open = true + } + } +} + +/// A list row that knows whether the pointer is on it, for `WidgetCmuxButton(live:)` and its other row-only controls. +struct WidgetHoverRow<Content: View>: View { + @ViewBuilder let content: (Bool) -> Content + @State private var hovering = false + + var body: some View { + content(hovering).onHover { hovering = $0 } + } +} + +struct WidgetCmuxPanel: View { + @ObservedObject var model: WidgetModel + let session: WidgetSession + @State private var tree: CmuxTree? + @State private var loading = false + @State private var busy = false + @State private var choosing = false + @State private var notice: CmuxSessionPanel.Notice? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(session.title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + Text([AIProviders.meta(for: session.target.provider).displayName, session.project, + String(session.target.sessionId.prefix(8))].filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 10.5)).foregroundStyle(.secondary).lineLimit(1).textSelection(.enabled) + } + CmuxSessionPanel( + sessionId: session.target.sessionId, + window: nil, workspace: nil, pane: nil, surface: nil, + tree: tree, loading: loading, busy: busy, lastPane: nil, + choosing: $choosing, notice: $notice, + modeKey: "widget.cmux.pickerMode", + focus: { Task { await perform(focus) } }, + open: { target in Task { await perform { await open(at: target) } } }, + reload: load) + } + .task { load() } + } + + private func load() { + guard !loading else { return } + loading = true + Task { + // Not the exit status: with cmux unreachable the command prints {available: false, error} and exits 1. + let result = try? await model.bridge.run(subcommand: "ai", args: ["cmux", "tree", "--json"], timeoutSeconds: 20) + tree = result.flatMap { try? CmuxTree.decode(Data($0.stdout.utf8)) } + loading = false + } + } + + private func focus() async -> String? { + let tool = CmuxCommandRunner.tool(provider: session.target.provider) + do { + let result = try await model.bridge.run( + subcommand: tool, args: ["cmux", "focus", session.target.sessionId, "--first", "--json"], timeoutSeconds: 30) + return CmuxCommandRunner.focusFailure( + status: result.exitCode, stdout: Data(result.stdout.utf8), stderr: Data(result.stderr.utf8)) + } catch { + return error.localizedDescription + } + } + + /// Claude resumes through `tools claude cmux open-session`; another agent's resume command is typed into the pane. + private func open(at target: CmuxTarget) async -> String? { + let sessionId = session.target.sessionId + if session.target.provider == "claude", + let args = target.openSessionArgs ?? tree?.windows.first.map({ ["--window", $0.target] }) { + do { + let result = try await model.bridge.run( + subcommand: "claude", args: ["cmux", "open-session", sessionId] + args + ["--json"], timeoutSeconds: 60) + return CmuxCommandRunner.openSessionFailure( + status: result.exitCode, stdout: Data(result.stdout.utf8), stderr: Data(result.stderr.utf8)) + } catch { + return error.localizedDescription + } + } + guard let command = CmuxCommandRunner.resumeCommand(provider: session.target.provider, sessionId: sessionId) else { + return "No resume command for \(session.target.provider)" + } + let cwd = session.target.cwd + let name = session.title + return await Task.detached(priority: .userInitiated) { + CmuxCommandRunner.run(command, cwd: cwd, name: name, at: target) + }.value + } + + private func perform(_ action: @escaping () async -> String?) async { + busy = true + let error = await action() + busy = false + if error == CmuxCommandRunner.paneGone { + // The tab it ran in is closed: say so in words and show where it can go instead. + notice = CmuxSessionPanel.Notice(text: error ?? "", isError: true) + choosing = true + } else { + notice = CmuxSessionPanel.Notice(text: error.map { "cmux: \($0)" } ?? "Done", isError: error != nil) + } + load() + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index da49b5fb3f..5a9c3fafa3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -46,6 +46,14 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { public var passivePanels = false { didSet { panels.values.forEach { $0.becomesKeyOnExpand = !passivePanels } } } + /// Whether this coordinator owns the global "open the widget" shortcut (`openShortcut` preference). Only the + /// widget face sets it: the preview studio and tests must not take a key from every app on the Mac. + public var registersOpenShortcut = false + private var openHotKey: CompanionHotKey? + /// The chord `openHotKey` holds, or nil while none is registered. + private var openHotKeyChord: WidgetShortcutChord? + /// "GNWD": Genesis widget. Its own Carbon identity, so it never shadows dictation's (`CompanionHotKey.flowSignature`). + static let openHotKeySignature: OSType = 0x474E_5744 /// Orders one edge panel front. Tests replace it to prove a hidden widget never shows a panel. var orderFront: (EdgePanelController<WidgetHostView>) -> Void = { $0.show() } var panelCount: Int { panels.count } @@ -99,7 +107,8 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { attachToDraft: { [weak model] item, recipient in guard let model else { throw CancellationError() } try await model.attachShelfItem(item.id, to: recipient.key) - }) + }, + undoSeconds: { [weak model] in model?.undoSeconds ?? WidgetModel.undoSeconds(nil) }) self.shelf = shelf let tasks = WidgetTasksStore( binaryPath: binaryPath, @@ -121,13 +130,24 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { try await model.attachVoiceNote(note, to: session.key) self?.showShelfDraft(for: session) return "Added to \(session.title)'s draft. Nothing has been sent." - }) + }, + undoSeconds: { [weak model] in model?.undoSeconds ?? WidgetModel.undoSeconds(nil) }) voiceNotes.recipientPickerVisibilityChanged = { [weak self] visible in guard let self else { return } self.model.dialogOpen = visible || self.settings?.isVisible == true || self.mediaWindow?.isVisible == true } self.voiceNotes = voiceNotes - model.recordVoiceNote = { [weak self] key in self?.openVoiceNotes(for: key) } + // The reply box's microphone dictates live in place; a voice note keeps its audio in Voice Notes without + // opening that panel (Martin, 2026-10-23: "should not open another widget but just let me dictate"). + model.voiceNoteClip = { [weak voiceNotes] in + guard let voiceNotes else { throw CancellationError() } + return try await voiceNotes.newIncomingClipPath() + } + model.fileVoiceNote = { [weak voiceNotes, weak model] clip, text, context in + guard let voiceNotes, let prefs = model?.snapshot?.state.preferences else { return } + voiceNotes.fileLiveNote(clip: clip, text: text, context: context, provider: prefs.voiceProvider, + model: prefs.voiceModel, language: prefs.voiceLanguage) + } do { try modules.register( WidgetModuleDescriptor( @@ -165,11 +185,32 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { guard let motion = self?.panels[surface]?.motion, motion.pointerInside != inside else { return } motion.pointerInside = inside } + // SwiftUI's hover no longer moves the panel (the window's tracking area does); a disagreement is logged with + // what lies under the pointer, which is how a hover list that closed under a scroll gets explained. + model.swiftUIHoverChanged = { [weak self] surface, inside in + guard !inside, let self, let panel = self.panels[surface]?.panel, panel.isVisible else { return } + let point = NSEvent.mouseLocation + guard panel.frame.contains(point), let primary = NSScreen.screens.first?.frame else { return } + let cg = CGPoint(x: point.x, y: primary.maxY - point.y) + PerfLog.mark("widget.hover swiftui-left \(surface.key) pointer still on the panel; under it: " + + self.hitTestDescription(cgPoint: cg)) + } model.showSettings = { [weak self] in self?.showSettings() } + model.showSettingsPage = { [weak self] in self?.showSettings(pageID: $0) } model.showMedia = { [weak self] selection in self?.showMedia(selection) } model.inboxArrived = { [weak self] items in self?.alert(items) } } + /// A main-spin with a panel open: the panel closes, so the app answers again, and says why. The spin's stack and + /// the bodies it evaluated are already in app-perf.log (HangWatch). Not a setting: it recovers from a bug, and the + /// other way is an app that does not respond. + func recoverFromSpin(_ seconds: CFAbsoluteTime) { + guard model.expanded != nil else { return } + PerfLog.mark("widget.spin-recovered after \(Int(seconds))s section=\(model.section) overview=\(model.inboxOverview)") + model.collapse() + model.notice = "The panel closed: it redrew for \(Int(seconds)) s without a pause. The details are in app-perf.log." + } + /// Plays the alert chime. Tests replace it. var playChime: (String?) -> Void = { WidgetAlerts.chime($0) } @@ -180,11 +221,18 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { let wanted = WidgetAlerts.wanted(items, preferences: preferences) guard let item = WidgetAlerts.popupItem(wanted) else { return } PerfLog.mark("widget.alert items=\(wanted.count) id=\(item.id) kind=\(item.kind)") - playChime(preferences?.alertSound) + playChime(WidgetAlerts.sound(for: wanted, preferences: preferences)) + // The chime and the flash say nothing to VoiceOver; one announcement per burst, like the chime. + GenAnnounce.post(WidgetAlerts.announcement(item, session: model.session(forKey: item.key)?.title), + priority: item.needsAnswer ? .high : .medium) + // The session that asked flashes on its rail bubble and in the lists, popup or not: it says why the widget + // chimed or opened (Martin, 2026-10-23). + model.flashAttention(item.key, seconds: WidgetAlerts.flashSeconds(preferences)) // Checked before the module switch: a popup the user's interaction refuses must not swap the pane they use. guard preferences?.alertPopup ?? true, let surface = peekSurface(), model.canPeek(on: surface) else { return } // The peek shows the agents module and gives the user's own module back when it ends. - model.showPeek(item, on: surface, module: moduleIDs(for: surface).contains("agents") ? "agents" : nil) + model.showPeek(item, on: surface, module: moduleIDs(for: surface).contains("agents") ? "agents" : nil, + seconds: WidgetAlerts.popupSeconds(preferences)) } /// The visible edge whose selected module is the agents inbox, else the first visible edge that has it. @@ -202,6 +250,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { public func start(showSettings: Bool = false, settingsWhenOff: Bool = true) { guard !started, !stopped else { return } started = true + HangWatch.onSpin = { [weak self] seconds in self?.recoverFromSpin(seconds) } runtimeStart = Task { [flowRuntime] in await flowRuntime.start() } rebuildPanels() screenObserver = NotificationCenter.default.addObserver( @@ -216,6 +265,8 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { if let shutdownTask { await shutdownTask.value; return } let task = Task { [self] in stop() + // A shelf item removed during its Undo is removed before the app quits, or it is back on the next launch. + await shelf?.waitForRemoval() await voiceNotes?.shutdown() await runtimeStart?.value await flowRuntime.stop() @@ -291,12 +342,57 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { model.stop() modules.removeAllSurfaces() removePanelMonitors() + openHotKey?.stop() + openHotKey = nil + openHotKeyChord = nil if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } panels.values.forEach { $0.hide() } settings?.close() mediaWindow?.close() } + /// Registers, moves or drops the global shortcut that opens the widget, as the preferences say (ux catalogue O1). + /// Only while the widget is on: with it off there is nothing to open. + private func applyOpenShortcut() { + let wanted = registersOpenShortcut && !stopped && panelsEnabled + ? model.snapshot?.state.preferences.openShortcutChord : nil + guard wanted != openHotKeyChord else { return } + openHotKey?.stop() + openHotKey = nil + openHotKeyChord = wanted + guard let wanted else { return } + let key = CompanionHotKey(keyCode: wanted.keyCode, modifiers: wanted.modifiers, + signature: Self.openHotKeySignature, hotKeyId: 1) + key.onKeyDown = { [weak self] _ in + Task { @MainActor in self?.toggleFromShortcut() } + } + let label = wanted.hotkey.label + if key.start() { + openHotKey = key + PerfLog.mark("widget.shortcut registered \(label)") + } else { + PerfLog.mark("widget.shortcut refused \(label)") + model.postNote("The shortcut \(label) to open the widget is taken. Choose another in Widget settings.") + } + } + + /// The shortcut: opens the agents pane with the keyboard in it, or closes the open panel (staged decisions still + /// ask first). + func toggleFromShortcut() { + guard panelsEnabled else { return } + if model.expanded != nil { + model.requestCollapse() + return + } + guard let surface = peekSurface() else { + model.postNote("Add Agent Inbox to a visible edge to open it with the shortcut.") + showSettings(pageID: "widgets.modules") + return + } + PerfLog.mark("widget.shortcut open \(surface.key)") + model.openModule("agents", on: surface) + } + public func registerModule(_ module: WidgetModuleDescriptor) throws { try modules.register(module) if display != nil { rebuildPanels() } @@ -305,7 +401,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { private func compactHeight(_ ids: [String]) -> CGFloat { WidgetSideStripMetrics( classic: model.snapshot?.state.preferences.sideStyle == "classic", - moduleIDs: ids, visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.inboxCount > 0 + moduleIDs: ids, visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.waitingCount > 0 ).minimumHeight } @@ -429,6 +525,11 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { controller.updateContent(content) nextPanels[surface] = controller } else { + defer { + nextPanels[surface]?.pointerChanged = { [weak self] inside in + self?.model.hover(surface, inside: inside) + } + } nextPanels[surface] = EdgePanelController( placement: surface.edge, screen: screen, compactSize: compact, expandedSize: CGSize(width: top ? 432 : 476, height: 600), @@ -458,6 +559,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { private func sync() { resolvePendingLaunch() + applyOpenShortcut() if lastShown != panelsEnabled { rebuildPanels() return @@ -627,6 +729,8 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { /// within a second (2026-10-11). func collapseIfOutside(_ event: NSEvent) { guard !passivePanels, event.timestamp > model.openedAt, model.expanded != nil, !model.dialogOpen else { return } + // A dictation keeps the panel, so its words stay in view; Esc or the close button still closes it. + guard !model.voiceActive else { return } var window = event.window while let candidate = window { if panels.values.contains(where: { $0.panel === candidate }) { return } @@ -636,7 +740,51 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { event.window?.convertPoint(toScreen: event.locationInWindow) ?? event.locationInWindow guard !panels.values.contains(where: { $0.panel.isVisible && $0.panel.frame.contains(point) }) else { return } - model.collapse() + // A click on another GenesisTools window keeps the panel: the vault key window that a dictation or a send + // raises took its Allow click as "outside" and closed the panel under it (Martin, 2026-10-23: "allow with touch + // id does literally hide the widget"). + if Self.ownWindowOwnsPoint(point) { return } + model.requestCollapse() + } + + /// The view chain AppKit's hit test picks at a CoreGraphics screen point (top-left origin) in each visible panel, for + /// the test driver's `hittest` command: accessibility frames can name a control that AppKit never delivers a click + /// to (a nested hosting view placed outside its parent's bounds). + public func hitTestDescription(cgPoint: CGPoint) -> String { + guard let primary = NSScreen.screens.first?.frame else { return "no screen" } + let point = NSPoint(x: cgPoint.x, y: primary.maxY - cgPoint.y) + var lines: [String] = [] + for (surface, controller) in panels where controller.panel.isVisible && controller.panel.frame.contains(point) { + guard let content = controller.panel.contentView else { continue } + let local = content.convert(controller.panel.convertPoint(fromScreen: point), from: nil) + var chain: [String] = [] + var view = content.hitTest(local) + while let current = view, chain.count < 12 { + chain.append("\(type(of: current))\(current.frame.integral)") + view = current.superview + } + lines.append("\(surface.key): " + (chain.isEmpty ? "nothing" : chain.joined(separator: " < "))) + } + return lines.isEmpty ? "no panel at the point" : lines.joined(separator: " | ") + } + + /// Whether the topmost on-screen window at `point` (AppKit screen coordinates) belongs to a GenesisTools process. + static func ownWindowOwnsPoint(_ point: NSPoint) -> Bool { + guard let primary = NSScreen.screens.first?.frame, + let rows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[CFString: Any]] else { return false } + // CoreGraphics measures from the primary screen's top edge, AppKit from its bottom edge. + let flipped = CGPoint(x: point.x, y: primary.maxY - point.y) + let bundle = Bundle.main.bundleIdentifier + for row in rows { + guard (row[kCGWindowLayer] as? Int ?? 0) < 1000, + let bounds = row[kCGWindowBounds] as? NSDictionary, + let frame = CGRect(dictionaryRepresentation: bounds), frame.contains(flipped) else { continue } + guard let pid = row[kCGWindowOwnerPID] as? pid_t else { return false } + return pid == ProcessInfo.processInfo.processIdentifier + || (bundle != nil && NSRunningApplication(processIdentifier: pid)?.bundleIdentifier == bundle) + } + return false } private func openVoiceNotes(for key: String) { @@ -665,6 +813,14 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { } private func handleKey(_ event: NSEvent) -> NSEvent? { + // The alert popup never takes the keyboard, but when the widget has it (a panel of another edge is key), Esc + // closes the popup before anything else. + if model.expanded == nil, model.peekItem != nil, panels.values.contains(where: { $0.panel === event.window }), + let stroke = KeyStroke(event), + WidgetKeymap.commandIDs(for: stroke, in: [.window], keymap: WidgetKeymap.shared).contains(WidgetKeymap.collapse) { + model.dismissPeek() + return nil + } guard let expanded = model.expanded, let panel = panels[WidgetSurfaceID(edge: expanded, group: expanded == .top ? 0 : model.activeSideGroup)]? .panel, @@ -689,18 +845,29 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { switch Self.panelKey( stroke: stroke, context: .init(previewOpen: MediaPreviewKeyboard.isOpen, agents: isAgent, editingText: editingText, - answering: model.answerCard)) + answering: model.answerCard, list: model.keyboardList)) { case .pass: return event - case .collapse: model.collapse() + case .list(let key): + // Only a list that took the key swallows it; otherwise the scroll view under it still scrolls. + return model.moveInList(key) ? nil : event + case .collapse: + // The leave question first (Esc keeps the staged decisions), then the staged view, then the list. + if model.stagedLeavePrompt { + model.resolveStagedLeave("keep") + } else if model.stagedView != nil { + model.stagedView = nil + } else if !model.escapeToList() { + model.requestCollapse() + } case .previous: model.next(-1) case .next: model.next() - case .choose(let choice): model.submit(choice: choice) + case .choose(let choice): model.choose(option: choice) } return nil } - enum PanelKey: Equatable { case pass, collapse, previous, next, choose(String) } + enum PanelKey: Equatable { case pass, collapse, previous, next, choose(String), list(WidgetListKey.Kind) } struct PanelKeyContext { let previewOpen: Bool @@ -708,6 +875,9 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { let editingText: Bool /// `WidgetModel.answerCard`: an open, unanswered decision, never a folded one. let answering: WidgetCard? + /// `WidgetModel.keyboardList`: a list (the inbox of every session, a session's inbox, the sessions) is on + /// screen, so Up, Down and Return move in it. + var list: Bool = false } /// What a key the media preview did not take does on the panel. While the preview is open, nothing: @@ -735,6 +905,11 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { guard context.agents, !context.editingText else { return .pass } if ids.contains(WidgetKeymap.previous) { return .previous } if ids.contains(WidgetKeymap.next) { return .next } + if context.list { + if ids.contains(WidgetKeymap.listUp) { return .list(.up) } + if ids.contains(WidgetKeymap.listDown) { return .list(.down) } + if ids.contains(WidgetKeymap.listOpen) { return .list(.open) } + } if let number = WidgetKeymap.choiceNumber(in: ids), let card = context.answering, number <= card.choices.count { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift index 54e8372252..41e41c642a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift @@ -36,7 +36,8 @@ struct WidgetDecisionOutcome: View { } if !picked.isEmpty || !(card.answer ?? "").isEmpty { VStack(alignment: .leading, spacing: 4) { - Text(card.answeredVia == "agent" ? "Answer recorded by the agent" : "Your answer") + Text(card.answeredVia == "agent" ? "Answer recorded by the agent" + : card.answeredVia == "automatic_heuristic" ? "Answer read from your chat" : "Your answer") .font(.system(size: 10, weight: .semibold)).foregroundStyle(.secondary) ForEach(picked, id: \.id) { choice in HStack(alignment: .top, spacing: 6) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index ff311cf3dc..7705b70e56 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -93,6 +93,12 @@ public enum WidgetFeatureSettings { ) { WidgetModuleSettings(model: model, modules: modules) }, + NativeSettingsPage( + id: "widgets.recording", title: "Screen recording", symbol: "record.circle", + tint: .red, subtitle: "Where the recording controls float and how a recording looks." + ) { + RecordingSettingsView() + }, ], order: 30), NativeSettingsSection( id: "dictation", title: "Dictation", @@ -173,6 +179,21 @@ private struct WidgetGeneralSettings: View { NativeSettingsToggle( "Hover previews", detail: "See a preview without changing keyboard focus.", isOn: boolean("hoverPreviews", prefs?.hoverPreviews ?? true)) + Divider() + NativeSettingsRow("Session bubble tooltips", detail: "Automatic shows them only while hover previews are off.") { + Picker("Session bubble tooltips", selection: string("bubbleTooltip", prefs?.bubbleTooltip ?? "auto")) { + Text("Automatic").tag("auto") + Text("Always").tag("always") + Text("Never").tag("never") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } + Divider() + NativeSettingsRow("Session bubble order", detail: "Same as the list follows the hover list's order. Keep places holds each bubble where it appeared.") { + Picker("Session bubble order", selection: string("railOrder", prefs?.railOrder ?? "list")) { + Text("Same as the list").tag("list") + Text("Keep places").tag("stable") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } } NativeSettingsCard("Shape") { NativeSettingsRow("Side style", detail: "Classic keeps a compact monochrome rail with its handle below.") { @@ -187,9 +208,13 @@ private struct WidgetGeneralSettings: View { isOn: boolean("joinedEdges", prefs?.joinedEdges ?? true)) } NativeSettingsCard("Appearance") { - NativeSettingsToggle( - "Glass effect", detail: "Use your selected theme for widget surfaces.", - isOn: boolean("glassEffect", prefs?.glassEffect ?? true)) + NativeSettingsSegmented( + "Panel background", + detail: "Dark is a solid near-black panel. Glass shows the desktop through it. Same as the app follows the theme in General, which other windows can change.", + identifier: "widget.panelBackground", + options: [NativeSettingsOption("dark", "Dark"), NativeSettingsOption("glass", "Glass"), + NativeSettingsOption("theme", "Same as the app")], + selection: string("panelBackground", prefs?.panelBackground ?? "dark")) Divider() NativeSettingsToggle( "Reduce motion", detail: "Shared with General: keeps every native window and widget still.", @@ -200,6 +225,11 @@ private struct WidgetGeneralSettings: View { isOn: $appearance.reduceTransparency) } NativeSettingsCard("Inbox behavior") { + NativeSettingsToggle( + "Show the waiting question in the list", + detail: "Each session in the hover list shows its open decision under its name, so an alert that closed can be found again.", + isOn: boolean("previewQuestion", prefs?.previewQuestion ?? true)) + Divider() NativeSettingsRow( "Collapse when quiet", detail: "A draft, recording or active agent keeps the inbox open." ) { @@ -219,36 +249,185 @@ private struct WidgetGeneralSettings: View { NativeSettingsToggle( "Changed files", detail: "Show a session's working changes alongside its conversation.", isOn: boolean("showChanges", prefs?.showChanges ?? true)) + Divider() + NativeSettingsRow("Reply box", detail: "Where a session's inbox puts the reply box.") { + Picker("Reply box", selection: string("inboxComposer", prefs?.inboxComposer ?? "top")) { + Text("Under the tabs").tag("top") + Text("Below the list").tag("bottom") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } + Divider() + NativeSettingsRow("Decision click", detail: "Stage keeps your pick to send later with the others, one message per session.") { + Picker("Decision click", selection: string("decisionClick", prefs?.decisionClick ?? "stage")) { + Text("Stage").tag("stage") + Text("Send at once").tag("send") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } + Divider() + NativeSettingsRow("Closing with staged decisions", detail: "What happens to staged decisions when the inbox closes.") { + Picker("Closing with staged decisions", selection: string("stagedOnLeave", prefs?.stagedOnLeave ?? "ask")) { + Text("Ask").tag("ask") + Text("Keep staged").tag("keep") + Text("Send them").tag("send") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } + Divider() + NativeSettingsRow("Inbox of every session", detail: "The inbox button's list: grouped under each session, or one list.") { + Picker("Inbox of every session", selection: string("inboxGrouping", prefs?.inboxGrouping ?? "session")) { + Text("By session").tag("session") + Text("One list").tag("list") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } + openingFilters + Divider() + NativeSettingsRow("Reply box microphone", detail: "Both put the words in the reply box as you talk; a voice note also keeps the recording in Voice Notes.") { + Picker("Reply box microphone", selection: string("composerVoice", prefs?.composerVoice ?? "ask")) { + Text("Ask each time").tag("ask") + Text("Voice note").tag("note") + Text("Dictate").tag("live") + }.labelsHidden().pickerStyle(.segmented).frame(width: 300, alignment: .trailing) + } } NativeSettingsCard("Alerts", subtitle: "When an agent asks or answers while the widget is closed.") { NativeSettingsToggle( - "Decisions and questions", detail: "A new ❓ DECISION or question that waits for your answer.", + "Decisions and questions", detail: "A new question that waits for your answer.", identifier: "widget.alertDecisions", isOn: boolean("alertDecisions", prefs?.alertDecisions ?? true)) Divider() NativeSettingsToggle( "Answers and messages", detail: "A new logged answer or a message an agent sent you.", identifier: "widget.alertAnswers", isOn: boolean("alertAnswers", prefs?.alertAnswers ?? true)) Divider() - NativeSettingsRow("Sound", detail: "One chime per burst of new items.") { - HStack(spacing: 6) { - Picker("Alert sound", selection: string("alertSound", prefs?.alertSound ?? WidgetAlerts.defaultSound)) { - Text("None").tag("none") - ForEach(WidgetAlerts.sounds, id: \.self) { Text($0).tag($0) } - }.labelsHidden().frame(width: 140, alignment: .trailing) - IconButton(systemName: "speaker.wave.2", tooltip: "Play the alert sound") { - WidgetAlerts.preview(prefs?.alertSound ?? WidgetAlerts.defaultSound) - }.disabled((prefs?.alertSound ?? WidgetAlerts.defaultSound) == "none") - } + NativeSettingsRow("Sound for decisions", detail: "Decisions and questions that wait for you. One chime per burst.") { + soundPicker("alertSound", prefs?.alertSound ?? WidgetAlerts.defaultSound, same: false) + } + Divider() + NativeSettingsRow("Sound for answers", detail: "Answers and messages, when nothing in the burst waits for you.") { + soundPicker("alertAnswerSound", prefs?.alertAnswerSound ?? "same", same: true) } Divider() NativeSettingsToggle( - "Popup", detail: "The widget opens a short preview of the new item; a click on it answers.", + "Open the widget", detail: "The widget opens a short preview of the new item; a click on it answers.", identifier: "widget.alertPopup", isOn: boolean("alertPopup", prefs?.alertPopup ?? true)) + Divider() + NativeSettingsRow("Preview stays", detail: "How long the preview stays while the pointer is not on it. × or Esc closes it sooner.") { + Picker( + "Preview stays", + selection: Binding( + get: { Int(WidgetAlerts.popupSeconds(prefs)) }, + set: { patch(["alertPopupSeconds": .number(Double($0))]) }) + ) { + ForEach(Self.popupSecondsChoices(current: Int(WidgetAlerts.popupSeconds(prefs))), id: \.self) { + Text("\($0) seconds").tag($0) + } + }.labelsHidden().frame(width: 150, alignment: .trailing) + .accessibilityIdentifier("widget.alertPopupSeconds") + } + .disabled(!(prefs?.alertPopup ?? true)) + Divider() + NativeSettingsRow("Flash the session", detail: "Its rail bubble and its row flash, so you see which session asked.") { + Picker( + "Flash the session", + selection: Binding( + get: { Int(WidgetAlerts.flashSeconds(prefs)) }, + set: { patch(["alertFlashSeconds": .number(Double($0))]) }) + ) { + Text("Off").tag(0) + Text("3 seconds").tag(3) + Text("5 seconds").tag(5) + Text("10 seconds").tag(10) + Text("20 seconds").tag(20) + }.labelsHidden().frame(width: 150, alignment: .trailing) + } + Divider() + NativeSettingsRow("Notes stay", detail: "How long a note under the widget's header stays, such as \"Inbox marked read\". A note with a button stays at least 10 seconds; errors stay until you close them.") { + Picker( + "Notes stay", + selection: Binding( + get: { Int(prefs?.noteSeconds ?? 6) }, + set: { patch(["noteSeconds": .number(Double($0))]) }) + ) { + ForEach(Self.noteSecondsChoices(current: Int(prefs?.noteSeconds ?? 6)), id: \.self) { seconds in + Text(seconds == 0 ? "Until closed" : "\(seconds) seconds").tag(seconds) + } + }.labelsHidden().frame(width: 150, alignment: .trailing) + .accessibilityIdentifier("widget.noteSeconds") + } + } + .nativeSettingsAnchor("alerts") + NativeSettingsCard("Shortcut", subtitle: "A global shortcut that opens the widget from any app.") { + NativeSettingsToggle( + "Open with a shortcut", + detail: "Opens the agent inbox with the keyboard in it; press it again to close the widget.", + identifier: "widget.openShortcutEnabled", + isOn: boolean("openShortcutEnabled", prefs?.openShortcutEnabled ?? true)) + Divider() + NativeSettingsRow("Shortcut", detail: "Works in every app while the widget is on.") { + HotkeyRecorder( + chord: Binding( + get: { (prefs?.openShortcut ?? .defaultOpen).hotkey }, + set: { chord in + patch(["openShortcut": .object([ + "keyCode": .number(Double(chord.keyCode)), + "modifiers": .number(Double(chord.modifiers)), + ])]) + }), + defaultChord: WidgetShortcutChord.defaultOpen.hotkey, + identifier: "widget.openShortcut") + } + .disabled(!(prefs?.openShortcutEnabled ?? true)) } + .nativeSettingsAnchor("shortcut") + WidgetAgentHooksCard(bridge: model.bridge) } }.task { model.refreshSettings() } } + /// The popup durations offered, plus a stored value that is none of them, so the picker never shows a blank. + static func popupSecondsChoices(current: Int) -> [Int] { + Array(Set([4, 7, 10, 15, 30, current])).sorted() + } + + /// The note durations offered (0 is "Until closed"), plus a stored value that is none of them. + static func noteSecondsChoices(current: Int) -> [Int] { + let timed = Array(Set([3, 6, 10, 20, current]).subtracting([0])).sorted() + return timed + [0] + } + + /// A system sound picker with a play button. `same` adds "Same as decisions", stored as "same". + private func soundPicker(_ key: String, _ value: String, same: Bool) -> some View { + let played = value == "same" ? prefs?.alertSound ?? WidgetAlerts.defaultSound : value + return HStack(spacing: 6) { + Picker(key == "alertSound" ? "Decision sound" : "Answer sound", selection: string(key, value)) { + if same { Text("Same as decisions").tag("same") } + Text("None").tag("none") + ForEach(WidgetAlerts.sounds, id: \.self) { Text($0).tag($0) } + }.labelsHidden().frame(width: 160, alignment: .trailing) + IconButton(systemName: "speaker.wave.2", tooltip: "Play this sound") { + WidgetAlerts.preview(played) + }.disabled(played == "none") + } + } + + /// The filter each inbox list opens with. + @ViewBuilder private var openingFilters: some View { + Divider() + NativeSettingsRow("Inbox of every session opens with", detail: "The filter the inbox button's list shows first.") { + Picker("Inbox of every session opens with", selection: string("overviewFilter", prefs?.overviewFilter ?? "all")) { + Text("All").tag("all") + Text("Needs you").tag("needs") + Text("Unread").tag("unread") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } + Divider() + NativeSettingsRow("Session inbox opens with", detail: "The filter a session's inbox shows first.") { + Picker("Session inbox opens with", selection: string("inboxFilter", prefs?.inboxFilter ?? "all")) { + Text("All").tag("all") + Text("Inbox entries").tag("inbox") + Text("Decisions").tag("decisions") + }.labelsHidden().pickerStyle(.segmented).frame(width: 300, alignment: .trailing) + } + } + private func string(_ key: String, _ value: String) -> Binding<String> { Binding(get: { value }, set: { patch([key: .string($0)]) }) } @@ -303,6 +482,7 @@ private struct WidgetSessionSettings: View { var body: some View { VStack(alignment: .leading, spacing: 18) { + previewCard NativeSettingsCard("Your sessions", subtitle: "Pin a session to keep it in the widget's rail. Click one to open it in the widget.") { WidgetSessionBrowser(model: model, onOpen: openSession) } @@ -311,6 +491,22 @@ private struct WidgetSessionSettings: View { }.task { model.refreshSettings() } } + /// The agents hover list. Its settings button opens this page at `#inbox-preview`, and the card pulses. + private var previewCard: some View { + NativeSettingsCard("Inbox preview", subtitle: "The list that opens when the pointer rests on the agents button.") { + NativeSettingsSegmented( + "Show", identifier: "widget.preview.filter", + options: WidgetPreviewChoices.filters, + selection: Binding(get: { model.previewFilter }, set: { model.setPreview(filter: $0) }), + minSegmentWidth: 96) + NativeSettingsPicker( + "Order", identifier: "widget.preview.sort", + options: WidgetPreviewChoices.sorts, + selection: Binding(get: { model.previewSort }, set: { model.setPreview(sort: $0) })) + } + .nativeSettingsAnchor("inbox-preview") + } + private var filterCard: some View { let selected = prefs?.sessions ?? [] let projects = prefs?.projects ?? [] @@ -539,6 +735,7 @@ private struct WidgetDictationSettings: View { @State private var languageSaved = false @State private var savedReset: Task<Void, Never>? @FocusState private var languageFocused: Bool + @GenReduceMotion private var reduceMotion private var prefs: WidgetPreferences? { model.snapshot?.state.preferences } private var provider: VoiceConfiguration.Provider? { configuration?.providers.first { $0.id == prefs?.voiceProvider } @@ -651,11 +848,12 @@ private struct WidgetDictationSettings: View { /// One timer: a second save restarts the two seconds instead of letting the first save's timer hide it early. private func confirmLanguageSaved() { - withAnimation(.easeOut(duration: 0.15)) { languageSaved = true } + // A fade is allowed under Reduce Motion, but the page keeps every setting change still (ux checklist, Motion). + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.15)) { languageSaved = true } savedReset?.cancel() savedReset = Task { @MainActor in do { try await Task.sleep(for: .seconds(2)) } catch { return } - withAnimation(.easeOut(duration: 0.3)) { languageSaved = false } + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.3)) { languageSaved = false } } } @@ -678,6 +876,78 @@ private struct WidgetDictationSettings: View { } } +/// What the Agents card reads from `tools agents hooks doctor --json`. +private struct AgentHooksDoctor: Decodable { + struct Config: Decodable { + struct Decisions: Decodable { var questionNudge: String? } + var decisions: Decisions + } + var config: Config + var stopHookWired: Bool +} + +/// The agents hooks' question nudge (`decisions.questionNudge` in `~/.genesis-tools/agents/hooks.json`), read and +/// written through `tools agents hooks`. On writes `block`; a `warn` set from the CLI shows as on. +private struct WidgetAgentHooksCard: View { + let bridge: ToolsBridge + @State private var nudge: String? + @State private var stopWired = true + @State private var busy = false + @State private var failure: String? + + var body: some View { + NativeSettingsCard("Agents", subtitle: "Checks that run when a coding agent ends its turn.") { + NativeSettingsToggle( + "Remind agents to log decisions", + detail: "An agent that asks you to decide in its reply, without sending the decision to your inbox, is sent back once to send it.", + identifier: "widget.agentsQuestionNudge", + isOn: Binding( + get: { (nudge ?? "off") != "off" }, + set: { on in Task { await run(["config", "set", "decisions.questionNudge", on ? "block" : "off", "--write"]) } })) + .disabled(nudge == nil || busy) + if (nudge ?? "off") != "off" && !stopWired { + Divider() + NativeSettingsRow("Stop hook not installed", detail: "The reminder runs in the agents' Stop hook, which is not in your Claude Code settings yet.") { + Button("Install") { Task { await run(["install", "--write"]) } } + .disabled(busy).nativeSettingsPointer() + } + } + if let failure { + Text(failure).font(.caption).foregroundStyle(.orange).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .task { await load() } + } + + private func load() async { + do { + let result = try await bridge.run(subcommand: "agents", args: ["hooks", "doctor", "--json"], timeoutSeconds: 20) + guard result.exitCode == 0 else { throw ToolsBridgeError.refused(result.stderr) } + let doctor = try JSONDecoder().decode(AgentHooksDoctor.self, from: Data(result.stdout.utf8)) + nudge = doctor.config.decisions.questionNudge ?? "off" + stopWired = doctor.stopHookWired + failure = nil + } catch { + if !Task.isCancelled { failure = "Could not read the agents hooks: \(error.localizedDescription)" } + } + } + + /// One `tools agents hooks …` write, then a fresh read so the card shows what the file now holds. + private func run(_ args: [String]) async { + busy = true + defer { busy = false } + do { + let result = try await bridge.run(subcommand: "agents", args: ["hooks"] + args, timeoutSeconds: 30) + guard result.exitCode == 0 else { throw ToolsBridgeError.refused(result.stderr) } + } catch { + failure = error.localizedDescription + return + } + await load() + } +} + /// Stands in for the controls until the first snapshot with the stored preferences arrives, so a page never shows /// the defaults as if they were your settings. A failed load offers a retry. private struct WidgetSettingsLoading: View { @@ -721,3 +991,27 @@ private struct WidgetSettingsError: View { } } } + +/// The hover list's filter and order choices, shared by the settings card and the list's own controls. +enum WidgetPreviewChoices { + static let filters: [NativeSettingsOption<String>] = [ + .init("needs-you", "Needs you", detail: "Sessions with a question or decision waiting for your answer."), + .init("active", "Active too", detail: "Also the sessions that work now or hold unread answers."), + ] + static let sorts: [NativeSettingsOption<String>] = [ + .init("attention", "Waiting first", detail: "Sessions that wait for you first, then working ones, newest first."), + .init("recent", "Newest activity", detail: "The session that did something last comes first."), + .init("project", "By project", detail: "Grouped by project name, newest first inside a project."), + ] + + static func title(sort: String) -> String { sorts.first { $0.value == sort }?.title ?? sort } + /// The one-word name the inbox header shows beside the sort button. + static func shortTitle(sort: String) -> String { + switch sort { + case "attention": return "Waiting" + case "recent": return "Newest" + case "project": return "Project" + default: return title(sort: sort) + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index 04a118c664..fc13b0fcb5 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -39,7 +39,7 @@ struct WidgetHostView: View { private var sideMetrics: WidgetSideStripMetrics { WidgetSideStripMetrics(classic: classicSide, moduleIDs: moduleIDs, - visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.inboxCount > 0) + visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.waitingCount > 0) } private var shape: EdgePanelShape { @@ -58,8 +58,11 @@ struct WidgetHostView: View { joined: joined) } + /// Side panels pin their content to the top: content taller than its window (a measurement still on its way, a + /// screen too short) then loses its footer, never its header. Centred, the hover list opened with its header and + /// its last row both cut off (Martin's screenshot, 2026-10-23). private var edgeAlignment: Alignment { - surface.edge == .top ? .top : (surface.edge == .right ? .trailing : .leading) + surface.edge == .top ? .top : (surface.edge == .right ? .topTrailing : .topLeading) } private var topPadding: CGFloat { @@ -69,6 +72,7 @@ struct WidgetHostView: View { private var keepsExpandedPane: Bool { warm && selected?.id == "agents" } var body: some View { + let _ = RenderProbe.hit("widget.host") Color.clear .overlay(alignment: .topLeading) { panelLayers } // The side rail anchors itself to the screen (ScreenAnchoredWidgetRail), so it needs the whole window @@ -82,15 +86,12 @@ struct WidgetHostView: View { .foregroundStyle(.white) .preferredColorScheme(.dark) .environment( - \.nativeSettingsTheme, - // Glass effect off: the theme's opaque form (Solid for the light themes, as before). - model.snapshot?.state.preferences.glassEffect == false - ? model.appearance.theme.effective(reduceTransparency: true, systemReduceTransparency: false) - : model.appearance.theme + \.nativeSettingsTheme, model.panelTheme ) .nativeSettingsAppearance(model.appearance) .widgetAccessibility(reduceMotion: model.reduceMotion, reduceTransparency: model.reduceTransparency) - .onHover { model.hover(surface, inside: $0) } + // The panel's window tracks the pointer (EdgePanelController.pointerChanged); this only logs a disagreement. + .onHover { model.swiftUIHoverChanged?(surface, $0) } .onChange(of: motion.pointerInside) { _, inside in if inside { warm = true } } @@ -132,7 +133,7 @@ struct WidgetHostView: View { } }) { Button { - if selected?.id == "agents", model.inboxCount > 0 { model.openInboxNotification(on: surface) } + if selected?.id == "agents" { model.toggleInboxOverview(on: surface) } else { expand() } } label: { HStack(spacing: 7) { @@ -147,32 +148,38 @@ struct WidgetHostView: View { Text(selected?.id == "agents" ? "Agents" : selected?.title ?? "Widgets") .font(.system(size: 12, weight: .semibold)) } - if selected?.id == "agents", model.inboxCount > 0 { - WidgetInboxCount(count: model.inboxCount, needsAnswer: model.inbox.needsAnswer > 0, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) + if selected?.id == "agents", model.waitingCount > 0 { + WidgetInboxCount(count: model.waitingCount, needsAnswer: true, + pulse: model.waitingPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) } }.fixedSize() } .buttonStyle(.genHoverPlain()) .accessibilityLabel("Open " + (selected?.title ?? "widgets")) + .accessibilityValue(selected?.id == "agents" ? Self.waitingValue(model.waitingCount) : "") .accessibilityIdentifier("widget.primary." + surface.key) .instantTooltip(Self.moduleTooltip(id: selected?.id, title: selected?.title ?? "Widgets", inbox: model.inbox)) HStack(spacing: 8) { if selected?.id == "agents" { - HStack(spacing: 5) { + // No spacing between the buttons: each bubble takes half the 5 pt gap as its own target (R2). + HStack(spacing: 0) { ForEach(Array(model.railActivitySessions.prefix(3))) { session in Button { - model.openInboxNotification(on: surface, key: session.key) + model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) - .frame(minWidth: 24, minHeight: 30).fixedSize() - }.buttonStyle(.genHoverPlain()) - .instantTooltip(sessionTooltip(session)) + .frame(minWidth: 30, minHeight: 30).fixedSize() + .modifier(bubbleChrome(session, hitPadding: EdgeInsets( + top: 0, leading: Self.topBubbleGap / 2, bottom: 0, trailing: Self.topBubbleGap / 2))) + }.buttonStyle(.plain) + .instantTooltip(model.bubbleTooltip(sessionTooltip(session))) .accessibilityLabel(sessionSummary(session)) .accessibilityIdentifier("widget.agent." + session.key) .onHover { model.hoverSession(session.key, on: surface, inside: $0) } } } + // The outer bubbles' halves of the gap reach past the row; the row keeps its old width. + .padding(.horizontal, -Self.topBubbleGap / 2) } ForEach(Array(moduleIDs.filter { $0 != selected?.id }.prefix(4)), id: \.self) { id in moduleButton(id, size: 25, location: "strip") @@ -267,26 +274,35 @@ struct WidgetHostView: View { ForEach(moduleIDs, id: \.self) { id in moduleButton(id, size: sideMetrics.moduleSize, location: "rail") if id == "agents", sideMetrics.sessionCount > 0 { - VStack(spacing: WidgetSideStripMetrics.sessionSpacing) { + // No spacing between the buttons: each bubble takes half the gap above and below it, and the rail's + // spare width beside it, as its own target, so no click between two bubbles is lost (R2). + VStack(spacing: 0) { ForEach(Array(model.railActivitySessions.prefix(sideMetrics.sessionCount))) { session in Button { - model.openInboxNotification(on: surface, key: session.key) + model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) + .modifier(bubbleChrome(session, hitPadding: WidgetSideStripMetrics.sessionHitPadding)) .overlay(alignment: .bottomTrailing) { // Which agent the count belongs to, at a glance; the tooltip names the session. + // Inset by the hit padding, so it sits on the bubble's corner as before. AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 11) .opacity(0.85).allowsHitTesting(false) + .padding(.bottom, WidgetSideStripMetrics.sessionHitPadding.bottom) + .padding(.trailing, WidgetSideStripMetrics.sessionHitPadding.trailing) } } - .buttonStyle(.genHoverPlain()) - .instantTooltip(sessionTooltip(session)) + .buttonStyle(.plain) + .instantTooltip(model.bubbleTooltip(sessionTooltip(session))) .accessibilityLabel(sessionSummary(session)) .accessibilityIdentifier("widget.agent." + session.key) .onHover { model.hoverSession(session.key, on: surface, inside: $0) } } } + // The first and last bubbles' halves of the gap reach into the stack's neighbours' spacing, so the + // stack keeps the height `WidgetSideStripMetrics.minimumHeight` counts. + .padding(.vertical, -WidgetSideStripMetrics.sessionSpacing / 2) } } } @@ -345,7 +361,17 @@ struct WidgetHostView: View { private func previewPane(_ selected: WidgetModuleDescriptor) -> some View { VStack(spacing: 0) { - if selected.id == "agents" { + if selected.id == "agents", let item = model.peekItem { + // The alert popup (`showPeek`): one item, a click answers it, × or Esc closes it. Measured like the + // hover list, so the panel takes the popup's own height. + // The panel is never shorter than the rail beside it, so the card fills the pane: one inset on every + // side, the panel's 18 pt corner minus that inset, and the alert flash on the card's own edge. Only the + // content is measured, so the fill cannot feed its own height back (Martin's screenshot, 2026-10-25: + // a short card over an empty pane, its flash border hugging the text). + WidgetPeekView(model: model, item: item) { model.reportPreviewFit($0 + 2 * WidgetPeekView.inset) } + .padding(WidgetPeekView.inset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else if selected.id == "agents" { AgentWidgetPreview(model: model, surface: surface) } else { Button(action: expand) { @@ -368,17 +394,27 @@ struct WidgetHostView: View { } } .frame(width: selected.previewSize.width) - .frame(maxHeight: .infinity) + // Pinned to the top: while the window still has the last measured height, an overflow hides the footer, never + // the header (the hover list opened on its middle rows, 2026-10-23). + .frame(maxHeight: .infinity, alignment: .top) } - /// Inbox counts describe only the Agents module; every other module button names its module. + /// Inbox counts describe only the Agents module; every other module button names its module. The badge counts + /// what waits for your answer (the hub's Inbox count); unread answers are only named here. static func moduleTooltip(id: String?, title: String, inbox: WidgetInboxSummary) -> String { - id == "agents" ? "Inbox: \(inbox.unread) unread, \(inbox.needsAnswer) need an answer" : title + guard id == "agents" else { return title } + let waiting = max(0, inbox.needsAnswer), unread = max(0, inbox.unread) + return "Inbox: \(waiting) waiting for your answer, \(unread) unread" + } + + /// The badge's count for VoiceOver, which the button's own label would otherwise hide. + static func waitingValue(_ count: Int) -> String { + count > 0 ? "\(count) waiting for your answer" : "Nothing waits for you" } private func moduleButton(_ id: String, size: CGFloat, location: String = "content") -> some View { Button { - if id == "agents", model.inboxCount > 0 { model.openInboxNotification(on: surface) } + if id == "agents" { model.toggleInboxOverview(on: surface) } else { model.openModule(id, on: surface) } } label: { let layout = surface.edge == .top ? AnyLayout(HStackLayout(spacing: 3)) : AnyLayout(VStackLayout(spacing: 4)) @@ -387,13 +423,13 @@ struct WidgetHostView: View { .font(.system(size: 13, weight: .medium)) .frame(height: 16) .foregroundStyle(classicSide ? Color.white : registry.module(id)?.tint ?? .secondary) - if id == "agents", model.inboxCount > 0 { - WidgetInboxCount(count: model.inboxCount, needsAnswer: model.inbox.needsAnswer > 0, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, + if id == "agents", model.waitingCount > 0 { + WidgetInboxCount(count: model.waitingCount, needsAnswer: true, + pulse: model.waitingPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete, compact: true) } } - .frame(minWidth: size, minHeight: surface.edge != .top && id == "agents" && model.inboxCount > 0 + .frame(minWidth: size, minHeight: surface.edge != .top && id == "agents" && model.waitingCount > 0 ? WidgetSideStripMetrics.badgedModuleHeight : size) .background( selected?.id == id && presentation != .compact ? Color.white.opacity(0.09) : .clear, @@ -402,9 +438,21 @@ struct WidgetHostView: View { .buttonStyle(.genHoverPlain()) .instantTooltip(Self.moduleTooltip(id: id, title: registry.module(id)?.title ?? id, inbox: model.inbox)) .accessibilityLabel("Open " + (registry.module(id)?.title ?? id)) + .accessibilityValue(id == "agents" ? Self.waitingValue(model.waitingCount) : "") .accessibilityIdentifier("widget." + location + "." + surface.key + "." + id) } + /// The top strip's gap between two session bubbles, which the bubbles split as hit padding. + static let topBubbleGap: CGFloat = 5 + + /// The bubble's hover ring and the alert flash, in place of the rectangular hover plate of `.genHoverPlain`. + private func bubbleChrome(_ session: WidgetSession, hitPadding: EdgeInsets = EdgeInsets()) -> WidgetBubbleChrome { + var style = model.attentionStyle + style.cornerRadius = 17 + return WidgetBubbleChrome(trigger: model.attentionTrigger(session.key), attention: style, + reduceMotion: model.effectiveReduceMotion, since: model.attentionStart(session.key), hitPadding: hitPadding) + } + private func sessionActivity(_ session: WidgetSession) -> some View { let inbox = model.inboxFor(session.key) return WidgetSessionActivity(status: session.visualStatus, @@ -422,7 +470,7 @@ struct WidgetHostView: View { if inbox.needsAnswer > 0 { lines.append("\(inbox.needsAnswer) waiting for your answer") } if inbox.unread > 0 { lines.append("\(inbox.unread) unread") } } - lines.append("Click to open its inbox") + lines.append("Click to open its inbox, again to hide it") return TooltipContent(title: session.title, bullets: lines) } @@ -460,40 +508,145 @@ struct WarmPaneVisibility: ViewModifier { } } -/// The alert popup: what arrived and from which session. A click opens it in the inbox. +/// The alert popup: what arrived and from which session. A click opens it in the inbox; the close button, Esc while the +/// widget has the keyboard, and VoiceOver's escape gesture close it without opening anything. struct WidgetPeekView: View { @ObservedObject var model: WidgetModel let item: WidgetInboxItem + /// The card's natural height, before it stretches to fill the pane. + var measured: (CGFloat) -> Void = { _ in } + + /// The gap between the card and the panel edge on every side. + static let inset: CGFloat = 8 + /// The panel's corner (EdgePanelShape's 18) minus the inset, so the card's corner runs parallel to the panel's. + static let cornerRadius: CGFloat = 18 - inset + + /// The kicker names the decision ("Decision 4"), so the title under it does not repeat the number. + private var title: String { + let plain = item.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return plain.isEmpty ? "Open the inbox to see it." : plain + } var body: some View { Button { guard let surface = model.hoveredSurface else { return } model.openInboxNotification(on: surface, key: item.key, needsAnswer: item.needsAnswer) } label: { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: item.needsAnswer ? "questionmark.bubble.fill" : "text.bubble.fill") - .foregroundStyle(item.needsAnswer ? Color.orange : SessionPalette.blue) - Text(WidgetAlerts.kicker(item)).font(.system(size: 11, weight: .semibold)) - .foregroundStyle(item.needsAnswer ? Color.orange : .secondary) - Spacer(minLength: 0) - LiveAgo(date: Date(timeIntervalSince1970: item.at / 1000), style: .brief) - .font(.system(size: 10)).foregroundStyle(.secondary) - } - Text(item.title ?? "Open the inbox to see it.").font(.system(size: 12.5, weight: .medium)) - .lineLimit(3).multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) - if let session = model.session(forKey: item.key) { - Text(session.title + " · " + session.project).font(.system(size: 10.5)).foregroundStyle(.secondary) - .lineLimit(1).truncationMode(.middle) - } - Text(item.needsAnswer ? "Click to answer" : "Click to read").font(.system(size: 10)) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 0) { + header + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { headerHeight = $0; report() } + Spacer(minLength: Self.footerGap) + footer + .onGeometryChange(for: CGFloat.self, of: \.size.height) { footerHeight = $0; report() } } - .frame(maxWidth: .infinity, alignment: .leading) + .padding(Self.padding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(Color.primary.opacity(0.05))) + .overlay(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)) .contentShape(Rectangle()) } - .buttonStyle(RowButtonStyle(cornerRadius: 10)) - .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + (item.title ?? "")) + .buttonStyle(RowButtonStyle(cornerRadius: Self.cornerRadius)) + .attentionPulse(trigger: model.attentionTrigger(item.key), style: popupAttention, since: model.attentionStart(item.key)) + .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + title) + .accessibilityAction(.escape) { model.dismissPeek() } + .accessibilityAction(named: "Close") { model.dismissPeek() } + .overlay(alignment: .topTrailing) { + IconButton(systemName: "xmark", tooltip: "Close without opening it", size: 10) { model.dismissPeek() } + .accessibilityLabel("Close the alert") + .accessibilityIdentifier("widget.peek.close") + .padding(.top, 5).padding(.trailing, 5) + } + } + + @State private var headerHeight: CGFloat = 0 + @State private var footerHeight: CGFloat = 0 + private static let padding: CGFloat = 10 + private static let footerGap: CGFloat = 10 + + /// The card's natural height: header, gap and footer, without the stretch that fills the pane. + private func report() { + guard headerHeight > 0, footerHeight > 0 else { return } + measured(headerHeight + Self.footerGap + footerHeight + 2 * Self.padding) + } + + private var footer: some View { + HStack(spacing: 3) { + Text(item.needsAnswer ? "Click to answer" : "Click to read") + Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) + } + .font(.system(size: 11)).foregroundStyle(.secondary) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: item.needsAnswer ? "questionmark.bubble.fill" : "text.bubble.fill") + .foregroundStyle(item.needsAnswer ? Color.orange : SessionPalette.blue) + Text(WidgetAlerts.kicker(item)).font(.system(size: 11, weight: .semibold)) + .foregroundStyle(item.needsAnswer ? Color.orange : .secondary) + Spacer(minLength: 0) + LiveAgo(date: Date(timeIntervalSince1970: item.at / 1000), style: .brief) + .font(.system(size: 10)).foregroundStyle(.secondary) + // Room for the close button, which sits over this corner outside the row button. + Color.clear.frame(width: 18, height: 1) + } + Text(title).font(.system(size: 12.5, weight: .medium)) + .lineLimit(3).multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) + if let session = model.session(forKey: item.key) { + Text([session.title, session.project].filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 11)).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + } + // The pane is never shorter than the rail beside it; the decision's options fill it with what the click + // will ask, instead of an empty card (Martin, 2026-10-25: "this is now quite ugly"). + if item.needsAnswer, let options = item.options, !options.isEmpty { + WidgetPeekOptions(options: options, recommended: item.recommended).padding(.top, 4) + } + } + } + + private var popupAttention: AttentionPulseStyle { + var style = model.attentionStyle + style.cornerRadius = Self.cornerRadius + return style + } +} + +/// A decision's options in the alert card, up to four, the recommended one marked; read-only, the card's click answers. +struct WidgetPeekOptions: View { + let options: [String] + let recommended: String? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(options.prefix(4).enumerated()), id: \.offset) { _, option in + let picked = Self.isRecommended(option, recommended) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: picked ? "star.fill" : "circle") + .font(.system(size: picked ? 8 : 6, weight: .semibold)) + .foregroundStyle(picked ? Color.green : Color.secondary) + .frame(width: 10) + Text(option).font(.system(size: 11.5)).lineLimit(2).fixedSize(horizontal: false, vertical: true) + .foregroundStyle(picked ? .primary : .secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(option + (picked ? ", recommended" : "")) + } + if options.count > 4 { + Text("\(options.count - 4) more").font(.system(size: 10.5)).foregroundStyle(.secondary).padding(.leading, 16) + } + } + } + + /// The hub names the recommended option by its letter ("b") or its whole text. + static func isRecommended(_ option: String, _ recommended: String?) -> Bool { + guard let recommended = recommended?.trimmingCharacters(in: .whitespaces).lowercased(), !recommended.isEmpty + else { return false } + let lower = option.lowercased() + return lower == recommended || lower.hasPrefix(recommended + ")") || lower.hasPrefix(recommended + ".") } } @@ -511,7 +664,7 @@ struct AgentWidgetModuleView: View { Text(model.selected?.title ?? "Your agent inbox").font(.system(size: 13, weight: .semibold)) .lineLimit(2) if let card = model.card { - Text(card.title).font(.system(size: 12)).lineLimit(3) + Text(card.numberedTitle).font(.system(size: 12)).lineLimit(3) if !card.body.isEmpty { Text(card.body).font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(3) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift new file mode 100644 index 0000000000..6e3ba79fca --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -0,0 +1,382 @@ +import SwiftUI + +/// The inbox button's view: inbox items from every session in one list, the waiting ones first. A row opens in place +/// to show its session and the way to that session's whole inbox (with the entry open) and its cmux pane. +struct WidgetInboxOverview: View { + @ObservedObject var model: WidgetModel + /// The filter picked here; until then the one the `overviewFilter` setting opens with. + @State private var filterPick: String? + @State private var openID: String? + /// The row Up and Down chose (`WidgetModel.listKey`); Return opens or folds it. The pointer never moves it. + @State private var focusID: String? + /// The option letter of the focused row's open decision that the keys chose; nil on the row itself. + @State private var focusedOption: String? + @GenReduceMotion private var reduceMotion + + private var filter: String { filterPick ?? model.snapshot?.state.preferences.overviewFilter ?? "all" } + + private var items: [WidgetInboxItem] { + let all = model.inboxOverviewItems + switch filter { + case "needs": return all.filter(\.needsAnswer) + case "unread": return all.filter { !$0.needsAnswer } + default: return all + } + } + + /// Items under their session ("session", the default) or one list (Widgets → Inbox, and the header button). + private var grouped: Bool { (model.snapshot?.state.preferences.inboxGrouping ?? "session") == "session" } + + /// The rows in the order they are drawn: grouped under their sessions, or the one list. + private func drawnOrder(_ items: [WidgetInboxItem]) -> [WidgetInboxItem] { + grouped ? Self.groups(items).flatMap(\.items) : items + } + + var body: some View { + let items = items + let _ = RenderProbe.hit("widget.overview") + // Inside the pane's scroll view: the proxy scrolls the row the keys chose into view. + ScrollViewReader { proxy in + list(items) + .onChange(of: model.listKey) { _, key in + guard let key else { return } + handle(key.kind, rows: drawnOrder(items), proxy: proxy) + } + .onChange(of: focusID) { _, id in model.inboxOverviewHasFocus = id != nil } + // A row that left the list (a filter, the grouping or a snapshot) cannot keep the keys' highlight, or + // Return would still answer it. + .onChange(of: drawnOrder(items).map(\.id)) { _, ids in + if let id = focusID, !ids.contains(id) { + focusID = nil + focusedOption = nil + } + } + .onDisappear { model.inboxOverviewHasFocus = false } + } + } + + /// The option letters of `item` when it is an open decision that waits for an answer: "a", "b", …. + private func openOptions(_ item: WidgetInboxItem) -> [String] { + guard openID == item.id, item.kind == "decision", item.needsAnswer, let options = item.options else { return [] } + return options.indices.map(Self.letter) + } + + static func letter(_ index: Int) -> String { String(UnicodeScalar(97 + index).map(Character.init) ?? "?") } + + /// Up and Down move the highlight, and inside an open decision walk its options first; Return opens or folds the + /// highlighted row, or stages the highlighted option as a click does; with "Send at once" Return does not send, + /// as in a session's inbox. No motion: keys repeat. + private func handle(_ kind: WidgetListKey.Kind, rows: [WidgetInboxItem], proxy: ScrollViewProxy) { + let current = focusID.flatMap { id in rows.firstIndex { $0.id == id } } + let options = current.map { openOptions(rows[$0]) } ?? [] + let option = focusedOption.flatMap { options.firstIndex(of: $0) } + switch kind { + case .open: + guard let current else { return } + let item = rows[current] + if let option { + guard model.decisionClickStages else { + model.postNote("Return does not send an answer. Click the option to send it.") + return + } + model.choose(item, option: options[option]) + return + } + model.releaseAgentFit() + focusedOption = nil + openID = openID == item.id ? nil : item.id + case .up, .down: + let step = kind == .up ? -1 : 1 + if !options.isEmpty { + switch WidgetModel.optionMove(focused: option, step: step, count: options.count) { + case .option(let index): + focusedOption = options[index] + proxy.scrollTo(rows[current ?? 0].id + "#" + options[index]) + return + case .cardRow: + focusedOption = nil + proxy.scrollTo(rows[current ?? 0].id) + return + case .leave: + break + } + } + guard let target = WidgetModel.listTarget(current: current, step: step, count: rows.count), + target != current else { return } + focusedOption = nil + focusID = rows[target].id + proxy.scrollTo(rows[target].id) + } + } + + private func list(_ items: [WidgetInboxItem]) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + GenSegmentedTabs( + "Inbox items", + items: [.init("all", "All"), .init("needs", "Needs you"), .init("unread", "Unread")], + selection: Binding(get: { filter }, set: { value in + model.holdAgentFit() + filterPick = value + }), minSegmentWidth: 70, identifier: "widget.overview.filter") + Spacer(minLength: 4) + IconButton(systemName: grouped ? "list.bullet" : "rectangle.3.group", + tooltip: grouped ? "Show one list" : "Group the items by session") { + model.holdAgentFit() + model.updatePreferences(["inboxGrouping": .string(grouped ? "list" : "session")]) + }.accessibilityLabel(grouped ? "Show one list" : "Group by session") + if model.inboxCount > 0 { + Button("Mark all read", action: model.markAllRead) + .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) + .instantTooltip("Answers become read; older questions and decisions stop counting. Nothing is deleted.") + } + } + if items.isEmpty { + VStack(spacing: 10) { + GenesisWidgetMark() + Text(filter == "needs" ? "Nothing waits for your answer." : "Nothing new in the inbox.") + .font(.headline) + Text("Questions, screenshots and finished work from every session land here.") + .font(.caption).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity).padding(.vertical, 28) + } else { + Text(Self.summary(items)).font(.system(size: 11)).foregroundStyle(.secondary) + // Hundreds of entries: lazy, so only the rows on screen exist (swift skill, dense lists). + LazyVStack(alignment: .leading, spacing: 10) { + if grouped { + ForEach(Self.groups(items), id: \.key) { group in + Section { + ForEach(group.items, id: \.id) { item in row(item) } + } header: { + header(group.key, items: group.items) + } + } + } else { + ForEach(items, id: \.id) { item in row(item) } + } + } + if model.inbox.truncated { + Text("The newest items are shown. Open a session for its whole inbox.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + } + + /// The sessions in the order their first entry appears (the waiting ones first), each with its entries. + static func groups(_ items: [WidgetInboxItem]) -> [(key: String, items: [WidgetInboxItem])] { + var order: [String] = [] + var byKey: [String: [WidgetInboxItem]] = [:] + for item in items { + if byKey[item.key] == nil { order.append(item.key) } + byKey[item.key, default: []].append(item) + } + return order.map { ($0, byKey[$0] ?? []) } + } + + private func header(_ key: String, items: [WidgetInboxItem]) -> some View { + let session = model.session(forKey: key) + let waiting = items.filter(\.needsAnswer).count + return HStack(spacing: 7) { + if let session { + AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 14) + } + Text(session?.title ?? "Unknown session").font(.system(size: 11.5, weight: .semibold)).lineLimit(1) + Text([Plural.count(items.count, "item"), waiting > 0 ? "\(waiting) need you" : nil] + .compactMap { $0 }.joined(separator: " · ")) + .font(.system(size: 10)).foregroundStyle(waiting > 0 ? Color.orange : .secondary) + Spacer(minLength: 0) + if let session { + Button("Whole inbox") { + model.select(session.key) + model.section = "Inbox" + }.buttonStyle(.genHoverPlain()).font(.system(size: 10.5)) + } + } + .padding(.top, 6).padding(.horizontal, 2) + .accessibilityElement(children: .combine) + } + + private static func summary(_ items: [WidgetInboxItem]) -> String { + let waiting = items.filter(\.needsAnswer).count + let sessions = Set(items.map(\.key)).count + return [Plural.count(items.count, "item"), waiting > 0 ? "\(waiting) need your answer" : nil, + "from " + Plural.count(sessions, "session")].compactMap { $0 }.joined(separator: " · ") + } + + private func toggle(_ item: WidgetInboxItem) { + let opening = openID != item.id + model.releaseAgentFit() + GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { openID = opening ? item.id : nil } + } + + private func row(_ item: WidgetInboxItem) -> some View { + let open = openID == item.id + let focused = focusID == item.id + let session = model.session(forKey: item.key) + return VStack(alignment: .leading, spacing: 0) { + Button { toggle(item) } label: { + HStack(alignment: .top, spacing: 9) { + Circle().fill(item.needsAnswer ? Color.orange : SessionPalette.blue) + .frame(width: 7, height: 7).padding(.top, 5) + Image(systemName: LiveWidgetView.kindSymbol(item.kind)).font(.system(size: 11)) + .foregroundStyle(.secondary).frame(width: 14).padding(.top, 1) + VStack(alignment: .leading, spacing: 3) { + Text(Self.title(item)).font(.system(size: 12.5, weight: open ? .semibold : .medium)) + .lineLimit(open ? 6 : 2).fixedSize(horizontal: false, vertical: true) + if let staged = stagedLine(item) { + Text("Staged: " + staged).font(.system(size: 11)).foregroundStyle(SessionPalette.blue) + .lineLimit(1) + } + HStack(spacing: 4) { + Text(Self.kindLabel(item)) + Text("·") + // Under its session header the row does not repeat the session. + if !grouped { + Text(session?.title ?? "Unknown session").lineLimit(1) + Text("·") + } + LiveAgo(date: Date(timeIntervalSince1970: item.at / 1000), style: .brief) + }.font(.system(size: 10)).foregroundStyle(item.needsAnswer ? Color.orange : .secondary) + } + Spacer(minLength: 4) + if let count = item.count, count > 1 { + Text(verbatim: "×\(count)").font(.system(size: 10, weight: .semibold)).monospacedDigit() + .foregroundStyle(.secondary).padding(.top, 3) + .instantTooltip("\(count) items like this one in this session") + } + Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary).rotationEffect(.degrees(open ? 90 : 0)).padding(.top, 4) + } + .padding(.horizontal, 10).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 11)) + .accessibilityLabel(Self.kindLabel(item) + ": " + Self.title(item)) + .accessibilityValue(open ? "Expanded" : "Collapsed") + .accessibilityAddTraits(focused ? .isSelected : []) + .accessibilityIdentifier("widget.overview.row." + item.id) + if open { + detail(item, session: session).padding(.horizontal, 12).padding(.top, 2).padding(.bottom, 12) + .transition(.genReveal) + } + } + .clipShape(RoundedRectangle(cornerRadius: 11)) + .background(Color.white.opacity(open ? 0.055 : 0.03), in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11) + .strokeBorder(item.needsAnswer ? Color.orange.opacity(0.28) : .clear, lineWidth: 1)) + // The keyboard's row: a focus ring, the look of the system's own. + .overlay(RoundedRectangle(cornerRadius: 11) + .strokeBorder(focused ? Color(nsColor: .keyboardFocusIndicatorColor) : .clear, lineWidth: 2)) + .id(item.id) + } + + private func stagedLine(_ item: WidgetInboxItem) -> String? { + let option = model.stagedOption(item.sourceId, stored: item.draftOption) + guard item.kind == "decision", item.needsAnswer, option != nil || item.draft != nil else { return nil } + return WidgetStagedText.line(item, option: option) + } + + /// One option row of an open decision: the letter badge, the text, Staged, the recommended mark, the focus ring. + private func optionLabel(_ letter: String, title: String, picked: Bool, focused: Bool, recommended: Bool) -> some View { + let shape = RoundedRectangle(cornerRadius: 8) + let fill: Color = picked ? SessionPalette.blue.opacity(0.16) : .white.opacity(0.06) + let border: Color = picked ? SessionPalette.blue.opacity(0.7) : .clear + let ring: Color = focused ? Color(nsColor: .keyboardFocusIndicatorColor) : .clear + return HStack(alignment: .top, spacing: 8) { + Text(verbatim: letter).font(.system(size: 11, weight: .semibold, design: .rounded)) + .frame(width: 20, height: 20).background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) + Text(title).font(.system(size: 12)).fixedSize(horizontal: false, vertical: true).padding(.top, 2) + Spacer(minLength: 0) + if picked { + Text("Staged").font(.system(size: 10, weight: .semibold)).foregroundStyle(SessionPalette.blue) + .padding(.top, 3) + } + if recommended { Image(systemName: "sparkle").foregroundStyle(.orange) } + } + .padding(8).frame(maxWidth: .infinity, alignment: .leading) + .background(fill, in: shape) + .overlay(shape.strokeBorder(border, lineWidth: 1)) + // The keyboard's option: the same focus ring as the row's. + .overlay(shape.strokeBorder(ring, lineWidth: 2)) + .contentShape(shape) + } + + /// One option of an open decision as a button. It stages, or with "Send at once" sends, as the decision click + /// setting says (`WidgetModel.choose(_:option:)` for an inbox item), and its words follow that setting. + private func optionButton(_ item: WidgetInboxItem, letter: String, title: String, picked: Bool) -> some View { + let focused = focusID == item.id && focusedOption == letter + let recommended = item.recommended == letter + let stages = model.decisionClickStages + let tooltip = LiveWidgetView.choiceTooltip(WidgetChoice(id: letter, title: title, recommended: recommended), + index: 10, stages: stages, picked: picked) + return Button { model.choose(item, option: letter) } label: { + optionLabel(letter, title: title, picked: picked, focused: focused, recommended: recommended) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .instantTooltip(tooltip) + .accessibilityLabel((stages ? "Stage " : "Answer ") + letter + ": " + title) + .accessibilityValue(picked ? "Staged" : "") + .accessibilityAddTraits(focused ? .isSelected : []) + .id(item.id + "#" + letter) + } + + /// An open decision's options, each staging (or sending, by the setting) that pick from here. + @ViewBuilder private func choices(_ item: WidgetInboxItem) -> some View { + if item.kind == "decision", item.needsAnswer, let options = item.options, !options.isEmpty { + let staged = model.stagedOption(item.sourceId, stored: item.draftOption) + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(options.enumerated()), id: \.offset) { index, title in + optionButton(item, letter: Self.letter(index), title: title, picked: staged == Self.letter(index)) + } + } + } + } + + private func detail(_ item: WidgetInboxItem, session: WidgetSession?) -> some View { + VStack(alignment: .leading, spacing: 10) { + choices(item) + if let session { + HStack(spacing: 8) { + AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 18) + VStack(alignment: .leading, spacing: 2) { + Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) + Text([session.project, session.visualStatus.label, String(session.target.sessionId.prefix(8))] + .filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } + } + } + HStack(spacing: 8) { + Button(item.needsAnswer ? "Answer in its session" : "Open in its session") { model.openInboxItem(item) } + .buttonStyle(.genHover(accent: item.needsAnswer ? .orange : KitTheme.accent)) + .accessibilityIdentifier("widget.overview.open." + item.id) + if let session { + Button("Whole inbox") { + model.select(session.key) + model.section = "Inbox" + }.buttonStyle(.genHover()) + Spacer(minLength: 0) + WidgetCmuxButton(model: model, session: session, size: 12) + } + }.font(.caption) + } + } + + static func title(_ item: WidgetInboxItem) -> String { + if let title = item.numberedTitle, item.title?.isEmpty == false { + return title + } + return kindLabel(item) + } + + static func kindLabel(_ item: WidgetInboxItem) -> String { + if item.needsAnswer { return item.kind == "decision" ? "Decision · needs your answer" : "Needs your answer" } + switch item.kind { + case "answer": return "New answer" + case "result": return "Agent result" + default: return item.kind.capitalized + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift index d11f434378..1b80cefc6a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift @@ -22,8 +22,13 @@ public enum WidgetKeymap { public static let quit = "widget.app.quit" public static let paste = "widget.media.paste" public static let collapse = "widget.panel.collapse" + /// J and K: the previous and next session. The ids keep their old names, so saved keys still apply. public static let previous = "widget.message.previous" public static let next = "widget.message.next" + /// Up, Down and Return in the list on screen: the inbox of every session, a session's inbox, the sessions. + public static let listUp = "widget.list.up" + public static let listDown = "widget.list.down" + public static let listOpen = "widget.list.open" public static let previewClose = "widget.preview.close" public static let previewPrevious = "widget.preview.previous" public static let previewNext = "widget.preview.next" @@ -37,7 +42,9 @@ public enum WidgetKeymap { public static let library = KeymapLibrary(id: "widget", title: "Agent Widget", commands: [ KeymapCommand(settings, "Widget Sessions", group: "App", keys: [.character(",", .command)], scope: .app), - KeymapCommand(hub, "Open Hub", group: "App", keys: [.character("h", .command)], scope: .app), + // ⌘O, not ⌘H: ⌘H hides the app on every Mac, and the hub binds it to Hide. + KeymapCommand(hub, "Open Hub", group: "App", detail: "Opens the hub at its Agents view. ⌘H stays Hide.", + keys: [.character("o", .command)], scope: .app), KeymapCommand(quit, "Quit", group: "App", keys: [.character("q", .command)], scope: .app, configurable: false), KeymapCommand(paste, "Paste Media", group: "Composer", keys: [ .character("v", .command), @@ -48,8 +55,21 @@ public enum WidgetKeymap { KeymapCommand(collapse, "Collapse", group: "Panel", detail: "Also while a text field has the focus. An open preview takes Esc first.", keys: [.code(53)]), - KeymapCommand(previous, "Previous Message", group: "Messages", keys: [.character("j")]), - KeymapCommand(next, "Next Message", group: "Messages", keys: [.character("k")]), + KeymapCommand(previous, "Previous Session", group: "Sessions", + detail: "Opens the session above the selected one in the widget's session list.", + keys: [.character("j")]), + KeymapCommand(next, "Next Session", group: "Sessions", + detail: "Opens the session below the selected one in the widget's session list.", + keys: [.character("k")]), + KeymapCommand(listUp, "Previous Row", group: "Lists", + detail: "Moves up in the inbox or session list on screen. Not while a text field has the focus.", + keys: [.code(126)]), + KeymapCommand(listDown, "Next Row", group: "Lists", + detail: "Moves down in the inbox or session list on screen. Not while a text field has the focus.", + keys: [.code(125)]), + KeymapCommand(listOpen, "Open Row", group: "Lists", + detail: "Opens or folds the row the arrows chose; in the session list, opens that session.", + keys: [.code(36)]), ] + numberRow.map { number, code in KeymapCommand(choiceID(number), "Answer \(number)", group: "Answers", detail: "The digit, or that place on the number row.", @@ -98,13 +118,9 @@ public enum WidgetKeymap { public static func label(_ id: String) -> String { shared.label(for: id) ?? "" } - /// "1–3 answer · J/K switch · ⎋ close", with the keys in force. + /// "1 to 3 answer · ↑/↓ move · J/K session · ⎋ close", with the keys in force. No dash in a visible string. public static var agentFooter: String { - "\(label(choiceID(1)))–\(label(choiceID(3))) answer · \(label(previous))/\(label(next)) switch · \(label(collapse)) close" - } - - /// "⌘V media · ⎋ close". - public static var mediaFooter: String { - "\(label(paste)) media · \(label(collapse)) close" + "\(label(choiceID(1))) to \(label(choiceID(3))) answer · \(label(listUp))/\(label(listDown)) move · " + + "\(label(previous))/\(label(next)) session · \(label(collapse)) close" } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift index 77f301b73f..c6371222bb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift @@ -121,6 +121,11 @@ struct WidgetSideStripMetrics { static let sessionWidth: CGFloat = 38 static let sessionHeight: CGFloat = 34 static let sessionSpacing: CGFloat = 6 + /// A rail session bubble's target beyond its drawn 38 by 34 pt: half the gap to each neighbour and the rail's + /// spare width beside it, so a click between two bubbles reaches one of them (ux checklist, Hit targets). + static let sessionHitPadding = EdgeInsets( + top: sessionSpacing / 2, leading: (width - sessionWidth) / 2, + bottom: sessionSpacing / 2, trailing: (width - sessionWidth) / 2) static let minimumSpacer: CGFloat = 2 static let sessionLimit = 4 diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift index 9c4571b255..122f8cf480 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift @@ -109,10 +109,7 @@ struct WidgetMediaView: View { .padding(22) .titlebarBackground(NativeSettingsBackdrop()) .titlebarZone() - .environment(\.nativeSettingsTheme, - model.snapshot?.state.preferences.glassEffect == false - ? model.appearance.theme.effective(reduceTransparency: true, systemReduceTransparency: false) - : model.appearance.theme) + .environment(\.nativeSettingsTheme, model.panelTheme) .nativeSettingsAppearance(model.appearance) .preferredColorScheme(.dark) .onAppear { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift new file mode 100644 index 0000000000..7cf45cad00 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift @@ -0,0 +1,379 @@ +import Foundation + +/// Staged decisions: a pick or a note kept to send later, together with others (Martin, 2026-10-23: "click on the +/// decision option should not send it immediatelly, but stage that change"). They are the decision log's own unsent +/// state (`drafted` with `draftOption` or `draft`), so every inbox, the hub and the CLI see one list, and nothing is +/// lost when the widget restarts. +extension WidgetModel { + /// A click on a decision option or its number key stages it ("stage", the default) or sends it at once ("send"). + public var decisionClickStages: Bool { (snapshot?.state.preferences.decisionClick ?? "stage") == "stage" } + + /// The pick a card shows as staged: the one staged here a moment ago, else the snapshot's. + public func stagedOption(_ sourceId: String, stored: String?) -> String? { + if let pending = pendingStage[sourceId] { return pending.isEmpty ? nil : pending } + return stored + } + + /// The selected session's staged decisions. + public var sessionStaged: [WidgetInboxItem] { stagedItems.filter { $0.key == selectedKey } } + + /// A click on a decision option in a session's inbox. A second click on the staged option clears the pick. + public func choose(_ card: WidgetCard, option: String) { + guard decisionClickStages else { + // `submit` answers the selected card; another open row's click must answer its own decision. + selectedCardID = card.id + submit(choice: option) + return + } + guard let selected, let revision = card.revision else { return } + let current = stagedOption(card.sourceId, stored: card.draftOption) + stage(id: card.sourceId, sessionId: selected.target.sessionId, revision: revision, + option: current == option ? nil : option) + } + + /// The number keys: the open decision's option, as a click on it. + public func choose(option: String) { + guard let card = answerCard else { + submit(choice: option) + return + } + choose(card, option: option) + } + + /// A click on a decision option in the inbox of every session. With "Send at once" the pick is staged on the + /// item's own session and sent once the hub has it. + public func choose(_ item: WidgetInboxItem, option: String) { + guard let session = session(forKey: item.key), let revision = item.revision else { return } + guard decisionClickStages else { + stage(id: item.sourceId, sessionId: session.target.sessionId, revision: revision, option: option, + completed: { [weak self] in + self?.pendingStage[item.sourceId] = nil + self?.deliverDecisions([item], action: "send-staged") + }) + return + } + let current = stagedOption(item.sourceId, stored: item.draftOption) + stage(id: item.sourceId, sessionId: session.target.sessionId, revision: revision, + option: current == option ? nil : option) + } + + /// Save draft on a decision card: the composer's text becomes that decision's note, staged with the others. Once + /// the hub has it, the composer is cleared, so another decision's own answer does not open with this text; a + /// refused save keeps the text. + public func saveDraft(_ card: WidgetCard) { + guard let selected, let revision = card.revision else { return } + let key = selectedKey + let typed = draft.text + let text = typed.trimmingCharacters(in: .whitespacesAndNewlines) + stage(id: card.sourceId, sessionId: selected.target.sessionId, revision: revision, + option: stagedOption(card.sourceId, stored: card.draftOption), note: text, + completed: { [weak self] in + guard let self else { return } + self.draftSavedAt[card.id] = Date() + // Only the text that was saved: a session switch or new typing keeps the composer as it is. + if !text.isEmpty, self.selectedKey == key, self.draft.text == typed { self.setText("") } + }) + } + + /// The own answer composer of a decision with a saved note opens with that note. + public func openOwnAnswer(_ card: WidgetCard) { + ownAnswerCardID = card.id + if draft.text.isEmpty, let note = card.draft, !note.isEmpty { setText(note) } + } + + private func stage( + id: String, sessionId: String, revision: Int, option: String?, note: String? = nil, + completed: (() -> Void)? = nil + ) { + pendingStage[id] = option ?? "" + var fields: [String: WidgetJSON] = [ + "action": "ledger", "id": .string(id), "sessionId": .string(sessionId), + "expectedRevision": .number(Double(revision)), "state": "drafted", "draftOption": .string(option ?? ""), + ] + if let note { fields["draft"] = .string(note) } + action(.object(fields), failed: { [weak self] in self?.pendingStage[id] = nil }, completed: completed) + } + + /// The staged items as the snapshot shows them, and the picks staged here that it now shows. + func reconcileStaged(_ items: [WidgetInboxItem]) { + let staged = items.filter(\.isStaged) + if staged != stagedItems { stagedItems = staged } + guard !pendingStage.isEmpty else { return } + let shown = Dictionary(items.map { ($0.sourceId, $0.draftOption ?? "") }, uniquingKeysWith: { first, _ in first }) + // A decision the list no longer holds shows no pick, so a pending unstage ("") is done. + for (id, option) in pendingStage where (shown[id] ?? "") == option { + pendingStage[id] = nil + } + } + + /// "3 decisions will be sent to 2 sessions". + nonisolated public static func stagedSummary(_ items: [WidgetInboxItem], future: Bool = true) -> String { + let sessions = Set(items.map(\.key)).count + let verb = future ? "will be sent" : (items.count == 1 ? "was sent" : "were sent") + return Plural.count(items.count, "decision") + " \(verb) to " + Plural.count(sessions, "session") + } + + /// Sends the staged decisions `ids`: one message per session. With `thenClose` (a send on the way out of the + /// panel) the panel stays open until the hub answers and closes only when every answer was delivered, so a send + /// that failed is read where it happened instead of in a closed panel (ux catalogue P1). + public func sendStaged(_ ids: [String], thenClose: Bool = false) { + let chosen = stagedItems.filter { ids.contains($0.sourceId) } + guard !chosen.isEmpty else { + // Nothing to send used to close the panel without a word, which read as a send that did nothing. + PerfLog.mark("widget.send-staged nothing to send: \(ids.count) ids picked, \(stagedItems.count) staged") + postNote(ids.isEmpty ? "No decision is checked, so nothing was sent." + : "These decisions are no longer staged, so nothing was sent.") + return + } + deliverDecisions(chosen, action: "send-staged", thenClose: thenClose) + } + + /// Sends again answered decisions that no session took (the notice's Send again), for every kind of agent. + public func resendAnswers(_ items: [WidgetInboxItem]) { + guard !items.isEmpty else { return } + postNote("Sending again…") + deliverDecisions(items, action: "resend-answers") + } + + /// One message per session through the hub, then a note that says what really happened: delivered, waiting + /// for the session's next prompt (with why, and Send again), or not sent at all (an error, with the hub's own + /// words behind Details). When the panel was closed meanwhile, the sessions that took nothing flash on the rail. + private func deliverDecisions(_ items: [WidgetInboxItem], action: String, thenClose: Bool = false) { + let staged = action == "send-staged" + let opened = openedAt + if thenClose { postNote("Sending " + Plural.count(items.count, "decision") + "…") } + // The "Sending…" note this send put up; a failure takes it down, unless another note replaced it meanwhile. + let sendingNote = thenClose || action == "resend-answers" ? notice : nil + var targets: [String: WidgetJSON] = [:] + var titles: [String: String] = [:] + for item in items { + guard let session = session(forKey: item.key) else { continue } + let target = session.target + var fields: [String: WidgetJSON] = ["provider": .string(target.provider)] + if !target.sourceHome.isEmpty { fields["sourceHome"] = .string(target.sourceHome) } + if !target.cwd.isEmpty { fields["cwd"] = .string(target.cwd) } + targets[target.sessionId] = .object(fields) + titles[target.sessionId] = session.title + } + PerfLog.mark("widget.decision-send \(action) start: \(items.count) to " + + titles.values.sorted().joined(separator: ", ")) + afterMutations { [weak self] in + guard let self else { return } + do { + let result = try await self.call([ + "action": .string(action), "ids": .array(items.map { .string($0.sourceId) }), + "targets": .object(targets), + ]) + let outcome = WidgetDecisionSendOutcome(result, titles: titles) + let text = outcome.notice(items: items, staged: staged) + if let details = outcome.details { PerfLog.mark("widget.decision-send \(action) not delivered: \(details)") } + let waiting = items.filter { item in + self.session(forKey: item.key).map { outcome.queuedSessions.contains($0.target.sessionId) } ?? false + } + PerfLog.mark("widget.decision-send \(action) result: \(text)") + if outcome.delivered { + postNote(text) + // Only the panel this send left from: one the user opened again meanwhile stays open. It folds + // after the note was readable: closing at once hid "delivered" and read as "Send did nothing" + // (Martin, 2026-10-25). + if thenClose, expanded != nil, openedAt == opened { + Task { [weak self] in + try? await Task.sleep(for: .milliseconds(2500)) + guard let self, self.expanded != nil, self.openedAt == opened else { return } + self.collapse() + } + } + return + } + if outcome.failed > 0 { + if let sendingNote, notice == sendingNote { notice = nil } + fail(text, detail: outcome.details) + if staged { keptStaged.subtract(items.map(\.sourceId)) } + } else { + postNote(text, action: waiting.isEmpty ? nil + : WidgetNoticeAction(title: "Send again") { [weak self] in self?.resendAnswers(waiting) }) + } + flashUnsent(waiting.isEmpty ? items : waiting) + } catch { + if let sendingNote, self.notice == sendingNote { self.notice = nil } + PerfLog.mark("widget.decision-send \(action) failed: \(error.localizedDescription)") + fail(staged ? "The decisions could not be sent. They stay staged, so you can send them again." + : "The answers could not be sent again. They still wait for the session's next prompt.", + detail: error.localizedDescription) + if staged { keptStaged.subtract(items.map(\.sourceId)) } + flashUnsent(items) + } + } + } + + /// A send result that arrives after the panel closed: the sessions it concerns flash on the rail (the alert flash + /// setting), so the note waiting in the panel is noticed. + private func flashUnsent(_ items: [WidgetInboxItem]) { + guard expanded == nil else { return } + let seconds = WidgetAlerts.flashSeconds(snapshot?.state.preferences) + for key in Set(items.map(\.key)) { flashAttention(key, seconds: seconds) } + } + + /// Discards the picks and notes of the staged decisions `ids`; they still wait for an answer. + public func discardStaged(_ ids: [String]) { + guard !ids.isEmpty else { return } + for id in ids { pendingStage[id] = "" } + action(["action": "unstage", "ids": .array(ids.map { .string($0) })], failed: { [weak self] in + for id in ids { self?.pendingStage[id] = nil } + }) + } + + /// A close the user asked for (Esc, the close button, a click outside, the idle timer): with staged decisions it + /// asks first, as `stagedOnLeave` says ("ask" by default; "keep" closes, "send" sends them and closes). + public func requestCollapse(file: StaticString = #fileID, line: UInt = #line) { + // Staged decisions answered at an earlier leave (kept, or already on their way) do not ask again, so a + // second Esc while a send runs closes the panel. + let fresh = Set(stagedItems.map(\.sourceId)).subtracting(keptStaged) + guard expanded != nil, !fresh.isEmpty else { + collapse(file: file, line: line) + return + } + switch snapshot?.state.preferences.stagedOnLeave ?? "ask" { + case "keep": + collapse(file: file, line: line) + case "send": + keptStaged.formUnion(fresh) + sendStaged(Array(fresh), thenClose: true) + default: + stagedLeavePrompt = true + } + } + + /// The answer to the leave question: "send", "keep" or "discard". Keep and Discard close the panel at once; + /// Send closes it once the hub says every answer was delivered, and leaves it open with the reason otherwise. + public func resolveStagedLeave(_ choice: String) { + let ids = stagedItems.map(\.sourceId) + stagedLeavePrompt = false + stagedView = nil + switch choice { + case "send": + keptStaged.formUnion(ids) + sendStaged(ids, thenClose: true) + return + case "discard": discardStaged(ids) + default: keptStaged = Set(ids) + } + collapse() + } +} + +/// How long an Undo row holds a removed item back (Remove from shelf, Discard of a voice note): as long as a note with +/// a button stays (`noteSeconds`, at least 10 s), so every Undo in the widget lasts the same. With notes set to stay +/// until closed (`noteSeconds` 0), the row stays until it is closed, but 60 s at most, since the delete must happen. +public extension WidgetModel { + nonisolated static func undoSeconds(_ preferences: WidgetPreferences?) -> Double { + noteSeconds(preferences, hasAction: true) ?? 60 + } + + /// `undoSeconds` for the current preferences; the coordinator hands this to the shelf and voice-notes stores. + var undoSeconds: Double { Self.undoSeconds(snapshot?.state.preferences) } +} + +/// A button beside the widget's notice line. +public struct WidgetNoticeAction { + public let title: String + public let run: @MainActor () -> Void + + public init(title: String, run: @escaping @MainActor () -> Void) { + self.title = title + self.run = run + } +} + +/// What a decision send did, read from the hub's answer: how many went out, waited or failed, and why a session +/// took nothing. +struct WidgetDecisionSendOutcome { + var sent = 0 + var queued = 0 + var failed = 0 + /// Sessions whose answers no receiver took: the session id, its title, the reason as one sentence, and the hub's + /// own words (for Details and the log). + var waiting: [(session: String, title: String, reason: String, detail: String?)] = [] + /// The hub's own words for each session whose send threw, as "<title>: <error>". + var failures: [String] = [] + + var queuedSessions: Set<String> { Set(waiting.map(\.session)) } + /// Every answer reached a live receiver. + var delivered: Bool { waiting.isEmpty && failed == 0 } + /// The hub's raw reasons, one line per session, for Details and the log; nil when it gave none. + var details: String? { + let lines = waiting.compactMap { entry in entry.detail.map { "\(entry.title): \($0)" } } + failures + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + + init(_ result: WidgetJSON, titles: [String: String]) { + guard case .object(let fields) = result else { return } + if case .number(let value) = fields["sent"] { sent = Int(value) } + if case .number(let value) = fields["queued"] { queued = Int(value) } + if case .number(let value) = fields["failed"] { failed = Int(value) } + guard case .array(let sessions) = fields["sessions"] else { return } + for case .object(let entry) in sessions { + guard case .string(let session) = entry["session"] else { continue } + let title = titles[session] ?? "the session" + if case .string(let error) = entry["error"], !error.isEmpty { + failures.append("\(title): \(error)") + continue + } + guard case .object(let answer) = entry["result"], answer["delivered"] != .bool(true) else { continue } + var detail: String? + if case .string(let text) = answer["detail"], !text.isEmpty { detail = text } + waiting.append((session, title, Self.sentence(for: detail), detail)) + } + } + + /// Why no receiver took the answers, in one sentence. The hub words its reasons for logs ("No native Claude + /// receiver claims this session.", "no cmux pane runs this session"); the known ones are matched here, and + /// anything else reads as the generic sentence with the hub's text behind Details (ux catalogue P24). The hub's + /// reasons: `src/question/lib/decisions/deliver.ts` (`resolveDeliveryTarget`, `nativeWorkers`, `undelivered`) + /// and `delivery-text.ts` (`deliverySentence`). + static func sentence(for detail: String?) -> String { + let generic = "No running agent took them." + guard let text = detail?.lowercased(), !text.isEmpty else { return generic } + let known: [(needle: String, sentence: String)] = [ + ("worker is busy", "Its worker is busy with another turn."), + ("worker session has not started", "Its worker has not started yet."), + ("not ready for another turn", "Its worker is not ready for another turn."), + ("claude terminals run this session", + "Several Claude terminals run this session, so none was picked. Close the extra one, then send again."), + ("no native claude receiver", "No open Claude terminal takes messages for this session."), + ("receiver belongs to another source home", "Its Claude terminal runs under another account."), + ("worker belongs to another source home", "Its worker runs under another account."), + ("several owned workers match", "Several workers match this session, so none was picked."), + ("worker runs this thread", "No Codex worker runs this conversation."), + ("cmux is not running", "cmux is not running."), + ("workspace of this session was closed", "Its cmux workspace was closed."), + ("no cmux pane runs this session", "No cmux pane runs this session."), + ("cmux panes match this session", "Several cmux panes match this session, so none was picked."), + ("does not identify the exact recipient", "No cmux pane could be matched to this session for certain."), + ("cmux send failed", "cmux could not type the answers."), + ("too large to save for later", "The answers are too long to keep for a later delivery."), + ("already saved this message", "An earlier send already saved them for this session."), + ] + return known.first { text.contains($0.needle) }?.sentence ?? generic + } + + /// The note: a plain summary when everything went out, else what did not and why. + func notice(items: [WidgetInboxItem], staged: Bool) -> String { + var parts: [String] = [] + if sent > 0 { parts.append(Plural.count(sent, "decision") + " delivered.") } + if let first = waiting.first { + if waiting.count == 1 { + parts.append("Not delivered to \(first.title): \(first.reason) The answers wait for its next prompt.") + } else { + // One reason for all of them is worth saying; several differ per session and live behind Details. + let why = Set(waiting.map(\.reason)).count == 1 ? first.reason + " " : "" + parts.append("Not delivered to \(Plural.count(waiting.count, "session")): \(why)" + + "The answers wait for each session's next prompt.") + } + } + if failed > 0 { + parts.append("\(Plural.count(failed, "decision")) could not be sent" + (staged ? " and stay staged." : ".")) + } + return parts.isEmpty ? WidgetModel.stagedSummary(items, future: false) + "." : parts.joined(separator: " ") + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index 5613ee104b..142e869686 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -26,12 +26,23 @@ public final class WidgetModel: ObservableObject { /// The height the agents pane needs to show everything without scrolling (LiveWidgetView measures it), or nil /// before it was laid out. The expanded panel fits it, up to its maximum (W3). public private(set) var agentFitHeight: CGFloat? + /// Each session's cards as last seen, so a switch never waits for the next snapshot (`WidgetSessionCache`). + private(set) var sessionCache = WidgetSessionCache() + /// The session whose first load keeps the pane at its height: the loading row is short, and the pane shrank to it + /// and grew back a moment later (app-perf.log, 2026-10-24: 1004x900, 1004x321, 1004x900 on each switch). + private var loadingFitHold: String? public var railSessions: [WidgetSession] { sessionRoster.rail } /// Rail sessions that are working or hold unread items. Stored, because every layout pass of every edge panel reads /// it: filtering ~1000 sessions there cost a quarter of the main thread while panels animated. public private(set) var railActivitySessions: [WidgetSession] = [] + /// The agents hover list (`previewRows`): every session that needs you, with the "active" filter also the + /// sessions that work now. Stored with the inbox, because every hover layout reads it. + public private(set) var previewList: [WidgetSession] = [] @Published public private(set) var inbox = WidgetInboxSummary.empty @Published public private(set) var inboxPulse = 0 + /// Bumped only when an item that waits for an answer arrives: the rail badge counts those (`waitingCount`), so it + /// pulses only when its own number rises, never for an unread answer it does not count. + @Published public private(set) var waitingPulse = 0 private var inboxSessions: [String: WidgetInboxSession] = [:] private var inboxObserved = false private var inboxStartedAt = 0.0 @@ -40,6 +51,9 @@ public final class WidgetModel: ObservableObject { private var openingInbox: WidgetInboxItem? private var inboxReadRequests: Set<String> = [] @Published public var selectedKey = "" + /// The expanded agents pane shows the inbox entries of every session instead of one session (the inbox button). + /// Opening a session ends it. + @Published public var inboxOverview = false { didSet { if inboxOverview != oldValue { presentationChanged?() } } } @Published public var selectedCardID: String? { didSet { presentationChanged?() } } @Published public var section = "Inbox" { didSet { @@ -73,7 +87,18 @@ public final class WidgetModel: ObservableObject { private var exitedDuringDrag: WidgetSurfaceID? private var hoveredSession: (key: String, surface: WidgetSurfaceID)? private var transcriptHoverTask: Task<Void, Never>? - @Published public var error: String? + /// One sentence about what failed. An error never fades: it stays until the user closes it or a new one replaces + /// it. A new error is announced to VoiceOver. A new or cleared error drops `errorDetail`, so set the detail after + /// the sentence (`fail(_:detail:)` does both). + @Published public var error: String? { + didSet { + guard error != oldValue else { return } + errorDetail = nil + if let error { GenAnnounce.post(error, priority: .high) } + } + } + /// The raw text behind `error` (a process's stderr), shown only behind a "Details" disclosure. + @Published public var errorDetail: String? @Published public var drafts: [String: WidgetDraft] = [:] /// The watch worker exited on its own. The snapshot is stale and nothing dispatches queued messages until /// `start()` runs again, so the view keeps a Reconnect action visible even after the error is dismissed. @@ -91,6 +116,10 @@ public final class WidgetModel: ObservableObject { @Published public var transcriptLoading = false @Published public var importing = 0 @Published public var voiceText = "" + /// Everything said in the running dictation: the finished sentences and the one still being recognised. + public var voiceTranscript: String { + (voiceFinals + [voiceText]).filter { !$0.isEmpty }.joined(separator: " ") + } @Published public var voiceLevel = 0.0 @Published public var voiceActive = false @Published public var reading = false @@ -109,29 +138,144 @@ public final class WidgetModel: ObservableObject { @Published private var hostDialogOpen = false /// Set by the session chooser alone, so closing it (or its view going away) cannot release a window still open. @Published public var sessionChooserOpen = false + /// Set by a session's cmux popover alone, for the same reason. + @Published public var cmuxPopoverOpen = false + /// A list row's "Open cmux" accessibility action, for that row's `WidgetCmuxButton` (which exists only under the + /// pointer) to open its popover. The button clears it. + @Published public var cmuxRequest: WidgetCmuxRequest? + + /// Opens the cmux popover of `key`'s row in the list `scope` (its `WidgetCmuxButton(requestScope:)`). + public func requestCmux(_ key: String, scope: String?) { + cmuxRequest = WidgetCmuxRequest(key: key, scope: scope) + } /// Hover previews and the outside-click collapse stay off while a window, picker or the chooser is open. public var dialogOpen: Bool { - get { hostDialogOpen || sessionChooserOpen } + get { hostDialogOpen || sessionChooserOpen || cmuxPopoverOpen } set { hostDialogOpen = newValue } } private var mediaPicker: NSOpenPanel? public var presentationChanged: (() -> Void)? /// The pointer entered or left an edge panel, before any hover delay. public var pointerChanged: ((WidgetSurfaceID, Bool) -> Void)? + /// SwiftUI's own hover of a panel, for the log only (the coordinator names what is under the pointer). + public var swiftUIHoverChanged: ((WidgetSurfaceID, Bool) -> Void)? public var showSettings: (() -> Void)? + /// Opens Settings at a page, or at a card on it (`widgets.sessions#inbox-preview`), which then pulses. + public var showSettingsPage: ((String) -> Void)? public var showMedia: ((WidgetMediaSelection) -> Void)? public var openHub: ((WidgetSession?) -> Void)? - public var recordVoiceNote: ((String) -> Void)? + /// A path for a voice note's audio; nil where no voice notes run (the preview studio), so a note is only dictated. + public var voiceNoteClip: (() async throws -> String)? + /// Keeps a finished dictation as a voice note: its audio file, its text, and where it was spoken. + public var fileVoiceNote: ((_ clip: String, _ text: String, _ context: WidgetVoiceNoteContext) -> Void)? + /// The mode of the dictation that runs, or nil. + @Published public private(set) var voiceMode: WidgetVoiceMode? + private var voiceNotePlan: (clip: String, context: WidgetVoiceNoteContext)? + static let lastVoiceModeKey = "widget.voice.last-mode" + + /// `composerVoice`: "ask" (default) offers both modes on every press of the microphone (Martin, 2026-10-23: "i want + /// to be able to pick when i click the "record" every time"); "note" or "live" starts that one at once. + public var composerVoiceChoice: String { snapshot?.state.preferences.composerVoice ?? "ask" } + /// What a press starts without asking: the setting, or the mode picked last when it asks. + public var preferredVoiceMode: WidgetVoiceMode { + if let fixed = WidgetVoiceMode(rawValue: composerVoiceChoice) { return fixed } + return defaults.string(forKey: Self.lastVoiceModeKey).flatMap(WidgetVoiceMode.init) ?? .note + } public var voiceActionLabel: String { - voiceActive ? "Stop dictation" : (recordVoiceNote == nil ? "Dictate" : "Record voice note") + if voiceActive { return voiceMode == .note ? "Stop and keep the voice note" : "Stop dictation" } + switch composerVoiceChoice { + case "note": return "Record a voice note into the reply box" + case "live": return "Dictate into the reply box" + default: return "Dictate, or record a voice note, into the reply box" + } } public var openDestination: ((WidgetSession, String, String?) -> Void)? /// Called with each preference patch once the hub has stored it. public var preferencesSaved: (([String: WidgetJSON]) -> Void)? - @Published public var notice: String? + /// One line under the header; a new line drops the button the old one had. A note fades by itself + /// (`scheduleNoticeFade`) and is announced to VoiceOver; prefer `postNote(_:action:)`, which also restarts the fade + /// and the announcement when the same text is posted again. + @Published public var notice: String? { + didSet { + guard notice != oldValue else { return } + noticeAction = nil + // A hover that ended with the panel (it closed under the pointer) must not keep the next note forever. + noticeHeld = false + if let notice { GenAnnounce.post(notice) } + scheduleNoticeFade() + } + } + /// A button beside `notice`: "Send again" after a send no session took. A note with a button stays longer. + @Published public var noticeAction: WidgetNoticeAction? { + didSet { scheduleNoticeFade() } + } + private var noticeFade: Task<Void, Never>? + /// The pointer is on the note: its fade waits until the pointer leaves (ux checklist, Attention). + private var noticeHeld = false + + /// Shows `text` as the widget's note, with an optional button (Undo, Send again). It is announced, and it fades + /// after the note duration setting (`noteSeconds`, 6 s by default); a note with a button stays at least 10 s. A + /// newer note replaces it and cancels its fade. Errors go to `error`, which never fades. + public func postNote(_ text: String, action: WidgetNoticeAction? = nil) { + // The same text again does not change `notice`, so its didSet would neither announce nor restart the fade. + if notice == text { GenAnnounce.post(text) } + notice = text + noticeAction = action + scheduleNoticeFade() + } + + /// Seconds a note stays: the `noteSeconds` preference (0 keeps it until it is closed), at least 10 with a button. + nonisolated static func noteSeconds(_ preferences: WidgetPreferences?, hasAction: Bool) -> Double? { + let base = max(0, min(60, preferences?.noteSeconds ?? 6)) + guard base > 0 else { return nil } + return hasAction ? max(base, 10) : base + } + + /// The pointer entered or left the note: a note under the pointer does not fade, and leaving it starts the full + /// duration again. For the view that draws the note. + public func noticeHovered(_ inside: Bool) { + noticeHeld = inside + scheduleNoticeFade() + } + + private func scheduleNoticeFade() { + noticeFade?.cancel() + noticeFade = nil + guard let text = notice, !noticeHeld, + let seconds = Self.noteSeconds(snapshot?.state.preferences, hasAction: noticeAction != nil) else { return } + noticeFade = Task { [weak self] in + do { try await Task.sleep(for: .seconds(seconds)) } catch { return } + guard let self, self.notice == text, !self.noticeHeld else { return } + self.notice = nil + } + } + + /// An error in one sentence, with the raw text behind it for "Details". + public func fail(_ message: String, detail: String? = nil) { + error = message + let trimmed = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + errorDetail = trimmed?.isEmpty == false ? trimmed : nil + } public private(set) var openedAt: TimeInterval = 0 public let bridge: ToolsBridge public let appearance: NativeSettingsAppearance + + /// The theme the widget panel and its media window draw with: the widget's own Panel background setting, so the + /// hub's View > Look or the General theme no longer turns the widget from dark to glass (Martin, 2026-10-25: "most + /// of the time it shows like this"). Reduce Transparency still turns glass solid. + public var panelTheme: NativeSettingsTheme { + switch snapshot?.state.preferences.panelBackground ?? "dark" { + case "glass": + return NativeSettingsTheme.glass.effective(reduceTransparency: appearance.reduceTransparency, + systemReduceTransparency: false) + case "theme": + return snapshot?.state.preferences.glassEffect == false + ? appearance.theme.effective(reduceTransparency: true, systemReduceTransparency: false) + : appearance.theme + default: + return .graphite + } + } private let defaults: UserDefaults private var appearanceSubscription: AnyCancellable? private var settingsOnly = false @@ -231,43 +375,118 @@ public final class WidgetModel: ObservableObject { /// The agents preview's measured height. The old estimate (100 + 54 per row) was 11 pt plus 2 pt per row short, /// so the centred content lost its header and the top of its first row (2026-10-10, Martin's screenshot). public private(set) var previewFitHeight: CGFloat? + /// The hover list's last measured height. The list is built again on every hover; it starts at this height so its + /// first layout is already the real one (`FittedScroll.initialHeight`). Not published: it must not re-render. + var previewListHeight: CGFloat = 0 public var previewHeight: CGFloat { if let previewFitHeight { return previewFitHeight } return sessionRoster.preview.isEmpty ? 180 : 112 + CGFloat(sessionRoster.preview.count) * 56 } /// Whole points and a 2 pt hysteresis, as `reportAgentFit`, so the measurement cannot chase its own resize. + /// + /// A taller measurement applies at once; a shorter one only after it held for `previewShrinkDelay`. The list is + /// built again on every hover, and its first layout passes measured it short (header and footer, then the rows): + /// one hover asked for 336, 295 and 489 pt within 42 ms, the panel shrank and grew back, and its content blinked + /// or opened cut at the top (edge.request log and Martin's screenshots, 2026-10-23). + /// + /// Before the first measurement the panel opens at the estimate (`previewHeight`), so a short first pass waits as + /// well: the first alert after a launch asked for 336 (the estimate), 295 and 489 pt (2026-10-23 17:10). func reportPreviewFit(_ height: CGFloat) { let rounded = ceil(height) - if let current = previewFitHeight, abs(rounded - current) < 2 { return } + let current = previewFitHeight ?? previewHeight + previewShrink?.cancel() + if abs(rounded - current) < 2 { + if previewFitHeight == nil { previewFitHeight = rounded } + return + } + if rounded < current { + previewShrink = Task { [weak self] in + do { try await Task.sleep(for: Self.previewShrinkDelay) } catch { return } + guard let self else { return } + self.previewFitHeight = rounded + self.presentationChanged?() + } + return + } previewFitHeight = rounded presentationChanged?() } + static let previewShrinkDelay = Duration.milliseconds(300) + private var previewShrink: Task<Void, Never>? + public var selected: WidgetSession? { sessionIndex[selectedKey] ?? (lastSelected?.key == selectedKey ? lastSelected : nil) } + /// The height a filter change may not shrink the pane below, for the view (overview, section, session) it was + /// taken in. A filter that shows fewer rows, or none, used to drop the open panel by hundreds of points and grow + /// it back on the next click (Martin, 2026-10-23: "it probably doesn't need to lower the height"). + private var agentFitFloor: (view: String, height: CGFloat)? + private var fitView: String { "\(inboxOverview)|\(section)|\(selectedKey)" } + + /// Called just before a filter changes what an open pane lists: the pane keeps at least its current height. + public func holdAgentFit() { + guard expanded != nil, let height = agentFitHeight else { return } + agentFitFloor = (fitView, max(height, agentFitFloor?.view == fitView ? agentFitFloor?.height ?? 0 : 0)) + } + + /// A row folding or another view: the pane fits its content again. + public func releaseAgentFit() { + agentFitFloor = nil + } + /// Whole points, and only a change of 2 pt or more, so a measurement cannot chase its own resize. func reportAgentFit(_ height: CGFloat?) { - let rounded = height.map { ceil($0) } + if loadingFitHold != nil, loadingFitHold != selectedKey || !inboxLoading { + loadingFitHold = nil + agentFitFloor = nil + } + var rounded = height.map { ceil($0) } + if let floor = agentFitFloor { + if floor.view == fitView, let value = rounded { rounded = max(value, floor.height) } else { agentFitFloor = nil } + } if let rounded, let current = agentFitHeight, abs(rounded - current) < 2 { return } guard rounded != agentFitHeight else { return } agentFitHeight = rounded presentationChanged?() } - public var cards: [WidgetCard] { snapshot?.cards.filter { $0.sessionKey == selectedKey } ?? [] } + /// The selected session's cards: from the snapshot that names it, else as last seen (`sessionCache`). + public var cards: [WidgetCard] { + guard let snapshot else { return [] } + if snapshot.selectedKey != selectedKey, let cached = sessionCache[selectedKey] { return cached.cards } + return snapshot.cards.filter { $0.sessionKey == selectedKey } + } + /// Nothing is known about the selected session yet: no snapshot named it, and none carried its cards. public var inboxLoading: Bool { - !selectedKey.isEmpty && snapshot?.selectedKey != selectedKey && cards.isEmpty && error == nil + !selectedKey.isEmpty && snapshot?.selectedKey != selectedKey && sessionCache[selectedKey] == nil + && cards.isEmpty && error == nil + } + /// For the test driver's `state` command and logs. + public var selectionDescription: String { + "selected=\(selectedKey) snapshot=\(snapshot?.selectedKey ?? "-") loading=\(inboxLoading) cards=\(cards.count) " + + "cached=\(sessionCache.count) warm=\(snapshot?.warmKeys?.count ?? 0)" + } + /// The selected session's decision activity and changes, under the same rule as `cards`. + public var sessionActivity: [WidgetActivityEvent] { + snapshot?.selectedKey == selectedKey ? snapshot?.activity ?? [] : sessionCache[selectedKey]?.activity ?? [] + } + public var sessionChanges: WidgetSnapshot.Changes? { + snapshot?.selectedKey == selectedKey ? snapshot?.changes : sessionCache[selectedKey]?.changes } public var card: WidgetCard? { cards.first { $0.id == selectedCardID } ?? cards.last(where: \.needsAnswer) ?? cards.last } /// Inbox rows the user folded. A folded decision stays selected, but nothing answers it until it opens again. @Published public var foldedCardIDs: Set<String> = [] - /// The decision the composer and the number keys answer: the selected one, unanswered and not folded. + /// Inbox rows shown as dismissed or done while their Undo runs or their change is on its way + /// (`WidgetLedgerUndo`). Opened again, such a row is still not answered. + @Published public var heldCardIDs: Set<String> = [] + /// The decision the composer and the number keys answer: the selected one, unanswered, not folded and not held. public var answerCard: WidgetCard? { - guard let card, card.kind == "decision", card.needsAnswer, !foldedCardIDs.contains(card.id) else { return nil } + guard let card, card.kind == "decision", card.needsAnswer, !foldedCardIDs.contains(card.id), + !heldCardIDs.contains(card.id) else { return nil } return card } /// The decision whose card shows the composer for the user's own answer instead of the options. @@ -387,6 +606,9 @@ public final class WidgetModel: ObservableObject { } } + /// What the widget says when its watch process ends by itself; the process's stderr is `errorDetail`. + public static let connectionLostMessage = "Lost the connection to the hub. Reconnect to try again." + public func start() { guard watcher == nil else { return } stopping = false @@ -405,7 +627,9 @@ public final class WidgetModel: ObservableObject { guard let self, !self.stopping, !exit.stopped else { return } self.watcher = nil self.connectionLost = true - self.error = "Widget connection stopped. " + exit.stderr.suffix(600) + // One sentence on screen; the process's own words go behind Details (ux checklist, Errors). + self.fail(Self.connectionLostMessage, detail: String(exit.stderr.suffix(4000))) + PerfLog.mark("widget.connection lost status=\(exit.status) \(exit.stderr.suffix(600))") }) // Cleared only once a watcher runs: a reconnect that cannot launch keeps Reconnect on screen. connectionLost = false @@ -478,6 +702,7 @@ public final class WidgetModel: ObservableObject { savedPreferences.removeAll { entry in entry.fence.map { decoded.state.revision >= $0 } ?? false } var next = decoded next.state.preferences = try overlaid(next.state.preferences) + sessionCache.remember(next) snapshot = next if !draggingSide, let position = draggedSidePosition, abs((next.state.preferences.sidePosition ?? 0.5) - position) < 0.0001 @@ -509,6 +734,7 @@ public final class WidgetModel: ObservableObject { persisted: next.state.selectedKey, visibleKeys: next.sessions.filter(\.visible).map(\.key)) selectedCardID = nil if !selectedKey.isEmpty && !settingsOnly { + pickOnWatch(selectedKey) action(["action": "selection", "key": .string(selectedKey)]) resumeTranscript() } @@ -582,6 +808,10 @@ public final class WidgetModel: ObservableObject { return } guard layout.hoverPreviews, !dialogOpen, !draggingSide, expanded == nil else { return } + // The panel's own hover fires only when the pointer crosses its edge. After a click collapsed the panel + // under a resting pointer it never fired again, so the next bubble showed its tooltip and no preview + // (Martin, 2026-10-23). A bubble the pointer reaches starts the panel's hover itself. + if hoveredSurface != surface { hover(surface, inside: true) } cancelHoverPrewarm() hoveredSession = (key, surface) transcriptHoverTask = Task { [weak self] in @@ -626,6 +856,19 @@ public final class WidgetModel: ObservableObject { } } + /// The popup's close button, Esc and VoiceOver's escape: the popup ends without opening anything. + public func dismissPeek() { + guard let item = peekItem else { return } + PerfLog.mark("widget.peek dismissed id=\(item.id)") + hoverTask?.cancel() + endPeek() + // The pointer is on the panel (it pressed ×): without this the hover list would open in the popup's place. + if expanded == nil, let surface = hoveredSurface, presentation(for: surface) == .preview { + hoveredSurface = nil + presentationChanged?() + } + } + private func endPeek() { peekTask?.cancel() peekTask = nil @@ -718,6 +961,7 @@ public final class WidgetModel: ObservableObject { expanded = edge prewarmTranscript() resumeTranscript() + refreshContext() presentationChanged?() scheduleQuietReduction() if openingInbox == nil, let card, card.kind == "answer", !card.read { @@ -751,6 +995,7 @@ public final class WidgetModel: ObservableObject { } inboxObserved = inboxObserved || value.complete if arrived { inboxPulse += 1 } + if arrivals.contains(where: \.needsAnswer) { waitingPulse += 1 } if !arrivals.isEmpty { inboxArrived?(arrivals) } for key in arrivedKeys { inboxSessionPulses[key, default: 0] += 1 } if inboxWatermarks.count > 4096 { @@ -772,15 +1017,120 @@ public final class WidgetModel: ObservableObject { inboxSessionPulses = inboxSessionPulses.filter { activeKeys.contains($0.key) } inbox = value inboxSessions = Dictionary(value.sessions.map { ($0.key, $0) }, uniquingKeysWith: { _, last in last }) - railActivitySessions = railSessions.filter { session in + let railActive = railSessions.filter { session in let inbox = inboxSessions[session.key] return session.visualStatus == .working || (inbox?.unread ?? 0) + (inbox?.needsAnswer ?? 0) > 0 } + // The rail reads in the hover list's order, so a bubble and its row sit at the same place (Martin, + // 2026-10-23: "the order should be the same in the right and the expanded list"); "stable" keeps places. + railActivitySessions = snapshot?.state.preferences.railOrder == "stable" + ? railActive : railActive.sorted(by: Self.previewOrder(inbox: inboxSessions, sort: previewSort)) + inboxOverviewItems = Self.overviewItems(value.sessions) + reconcileStaged(value.staged ?? value.sessions.flatMap { $0.items ?? [] }) + if !inboxFilterPicked, let opening = snapshot?.state.preferences.inboxFilter, opening != inboxFilter { + inboxFilter = opening + } + previewList = Self.previewRows( + sessions: snapshot?.sessions ?? [], inbox: inboxSessions, + filter: previewFilter, sort: previewSort) + } + + public var previewFilter: String { snapshot?.state.preferences.previewFilter ?? "needs-you" } + public var previewSort: String { snapshot?.state.preferences.previewSort ?? "attention" } + + /// The hover list. "Needs you": a session with an unanswered item, or a shown session that waits. With the + /// "active" filter, shown top-level sessions that work or hold unread items join it. Orders: "attention" (waiting + /// for you, then working, then newest), "recent" (newest activity) and "project" (by project, then newest). + nonisolated static func previewRows( + sessions: [WidgetSession], inbox: [String: WidgetInboxSession], filter: String, sort: String + ) -> [WidgetSession] { + let rows = sessions.filter { session in + if needsYou(session, inbox: inbox) { return true } + guard filter == "active", session.visible, session.parentKey == nil, session.agentId == nil else { return false } + return session.status == "working" || (inbox[session.key]?.unread ?? 0) > 0 + } + return rows.sorted(by: previewOrder(inbox: inbox, sort: sort)) + } + + nonisolated static func needsYou(_ session: WidgetSession, inbox: [String: WidgetInboxSession]) -> Bool { + (inbox[session.key]?.needsAnswer ?? 0) > 0 || (session.visible && session.status == "waiting") + } + + /// The hover list's order, which the rail's session bubbles share. Equal times fall back to the key, so the two + /// lists never disagree on a tie. + nonisolated static func previewOrder( + inbox: [String: WidgetInboxSession], sort: String + ) -> (WidgetSession, WidgetSession) -> Bool { + func rank(_ session: WidgetSession) -> Int { + needsYou(session, inbox: inbox) ? 0 : session.status == "working" ? 1 : 2 + } + func newer(_ lhs: WidgetSession, _ rhs: WidgetSession) -> Bool { + lhs.activityAt == rhs.activityAt ? lhs.key < rhs.key : lhs.activityAt > rhs.activityAt + } + return { lhs, rhs in + switch sort { + case "recent": + return newer(lhs, rhs) + case "project": + let order = lhs.project.localizedCaseInsensitiveCompare(rhs.project) + return order == .orderedSame ? newer(lhs, rhs) : order == .orderedAscending + default: + let (left, right) = (rank(lhs), rank(rhs)) + return left == right ? newer(lhs, rhs) : left < right + } + } + } + + /// Stores the hover list's filter or order. + public func setPreview(filter: String? = nil, sort: String? = nil) { + var patch: [String: WidgetJSON] = [:] + if let filter { patch["previewFilter"] = .string(filter) } + if let sort { patch["previewSort"] = .string(sort) } + guard !patch.isEmpty else { return } + updatePreferences(patch) } public func inboxFor(_ key: String) -> WidgetInboxSession? { inboxSessions[key] } public func inboxPulseFor(_ key: String) -> Int { inboxSessionPulses[key] ?? 0 } + + /// The session an alert came from, flashed for `attentionSeconds` on its rail bubble, its hover row and its + /// popup. The counter restarts a running flash (`AttentionPulse`). + /// One counter per session, so flashing one session never changes another session's trigger. + @Published public private(set) var attentionCounts: [String: Int] = [:] + public private(set) var attentionSeconds: Double = 5 + + public func flashAttention(_ key: String, seconds: Double) { + guard seconds > 0 else { return } + attentionSeconds = seconds + attentionStarts[key] = Date() + attentionCounts[key, default: 0] += 1 + } + + /// When each session's flash began, so a row built while it runs flashes for the rest (`AttentionPulse.since`). + /// Not published: the counter already re-renders the views that flash. + private var attentionStarts: [String: Date] = [:] + + /// The pulse trigger for one session: changes only when that session is flashed. + public func attentionTrigger(_ key: String) -> Int { attentionCounts[key] ?? 0 } + + /// When the session's current flash began. + public func attentionStart(_ key: String) -> Date? { attentionStarts[key] } + + /// The style every flash of a session that needs you uses. + var attentionStyle: AttentionPulseStyle { + var style = AttentionPulseStyle.borderFlash(color: .orange, cornerRadius: 12) + style.duration = attentionSeconds + style.period = 0.8 + style.fadeIn = 0.18 + style.fadeOut = 0.3 + return style + } + /// Every unread or waiting inbox item: what "Mark all read" acts on. public var inboxCount: Int { max(0, inbox.unread) + max(0, inbox.needsAnswer) } + /// The rail badge: only the items that wait for the user's answer (open or drafted decisions and pending + /// questions), the kind of item the hub's Inbox counts. Unread answers and results do not count: a badge of weeks + /// of read-later items read as 99+ while the hub said 26 (ux catalogue R1). + public var waitingCount: Int { max(0, inbox.needsAnswer) } /// Expands one inbox card in place. An unread answer or result counts as read once the user opens it, the same /// way opening it from a notification does. @@ -821,6 +1171,7 @@ public final class WidgetModel: ObservableObject { public func openInboxNotification(on surface: WidgetSurfaceID, key: String? = nil, needsAnswer: Bool? = nil) { guard !stopping else { return } + listOrigin = .overview let candidates: [WidgetInboxSession] if let key { candidates = inboxSessions[key].map { [$0] } ?? [] @@ -847,6 +1198,85 @@ public final class WidgetModel: ObservableObject { acknowledgeOpenedInbox() } + /// The overview's entries from every session: the waiting ones first, then newest first. A hub from before the + /// per-session list gives each session's pending and unread item. Stored when the inbox changes: up to 512 + /// sessions × 8 entries, sorted, is too much to derive in a body that reads it several times per pass. + public private(set) var inboxOverviewItems: [WidgetInboxItem] = [] + /// Every staged decision of every session (the decision log's unsent picks and notes), stored with the inbox. + @Published public internal(set) var stagedItems: [WidgetInboxItem] = [] + /// Picks staged or cleared here that the next snapshot has not shown yet, by decision id ("" is a cleared pick). + @Published var pendingStage: [String: String] = [:] + /// The question about staged decisions before the panel closes (`requestCollapse`). + @Published public var stagedLeavePrompt = false + /// The staged decisions view: nil closed, "session" for the selected session's, "all" for every session's. + @Published public var stagedView: String? + /// The staged decisions the user kept at the last leave; the prompt asks again only for a newly staged one. + var keptStaged: Set<String> = [] + /// When this widget last saved a decision's draft, by card id, for the card's "Draft saved" line. + @Published public var draftSavedAt: [String: Date] = [:] + + nonisolated static func overviewItems(_ sessions: [WidgetInboxSession]) -> [WidgetInboxItem] { + sessions.flatMap { entry in entry.items ?? [entry.pendingItem, entry.unreadItem].compactMap { $0 } } + .sorted { lhs, rhs in + lhs.needsAnswer == rhs.needsAnswer ? (lhs.at == rhs.at ? lhs.id < rhs.id : lhs.at > rhs.at) : lhs.needsAnswer + } + } + + /// An overview entry's "Open in session": that session's inbox with the entry open. + /// The list a session was opened from, which the first Esc goes back to (Martin, 2026-10-23: "it should first go + /// to the list and the second escape should close the expansion entirely"). + public enum ListOrigin: Sendable { case overview, sessions } + public var listOrigin: ListOrigin = .overview + + /// Esc in a session: back to the list it came from. False when a list (or no session) is on screen, so Esc then + /// closes the panel. + @discardableResult + public func escapeToList() -> Bool { + guard activeModuleID == "agents", selected != nil, !inboxOverview, section != "Sessions" else { return false } + switch listOrigin { + case .overview: inboxOverview = true + case .sessions: section = "Sessions" + } + return true + } + + public func openInboxItem(_ item: WidgetInboxItem) { + guard !stopping else { return } + listOrigin = .overview + openingInbox = item + select(item.key) + selectedCardID = item.id + foldedCardIDs.remove(item.id) + section = "Inbox" + acknowledgeOpenedInbox() + } + + /// The inbox button: every session's entries in one list. A second click while that list is open hides the panel. + public func toggleInboxOverview(on surface: WidgetSurfaceID) { + guard !stopping else { return } + if presentation(for: surface) == .expanded, activeModuleID == "agents", inboxOverview { + requestCollapse() + return + } + + inboxOverview = true + section = "Inbox" + openModule("agents", on: surface) + } + + /// A session button on the rail or the top strip: opens that session's inbox, or hides the panel when the same + /// session is already open there (Martin, 2026-10-23: a second click did nothing). + public func toggleSession(_ key: String, on surface: WidgetSurfaceID) { + guard !stopping else { return } + PerfLog.mark("widget.toggle-session surface=\(surface.key) presentation=\(presentation(for: surface)) module=\(activeModuleID) overview=\(inboxOverview) same=\(selectedKey == key) group=\(activeSideGroup)") + if presentation(for: surface) == .expanded, activeModuleID == "agents", !inboxOverview, selectedKey == key { + requestCollapse() + return + } + + openInboxNotification(on: surface, key: key) + } + private func acknowledgeOpenedInbox() { guard !stopping, let item = openingInbox, expanded != nil, activeModuleID == "agents", section == "Inbox", selectedKey == item.key, let displayed = cards.first(where: { $0.id == item.id }), displayed.at == item.at @@ -862,8 +1292,14 @@ public final class WidgetModel: ObservableObject { }) } - public func collapse() { + /// Every fold logs its caller, so "the widget folded" is found in app-perf.log (18 fold paths logged nothing, + /// 2026-10-25 hover hunt). + public func collapse(file: StaticString = #fileID, line: UInt = #line) { + if expanded != nil { + PerfLog.mark("widget.collapse \(expanded?.rawValue ?? "") from \(file):\(line)") + } cancelHoverPrewarm() + agentFitFloor = nil openingInbox = nil followedTranscript = nil hoveredSurface = nil @@ -885,15 +1321,34 @@ public final class WidgetModel: ObservableObject { public func select(_ key: String, edge: EdgePanelPlacement? = nil) { if openingInbox?.key != key { openingInbox = nil } + inboxOverview = false selectedKey = key + choiceFocus = nil lastSelected = snapshot?.sessions.first { $0.key == key } + if expanded != nil { refreshContext() } selectedCardID = nil + if inboxLoading { + holdAgentFit() + loadingFitHold = key + } + // How often a switch still has to wait: `loading=true` means no snapshot carried this session yet. + PerfLog.mark("widget.select loading=\(inboxLoading) cards=\(cards.count) cached=\(sessionCache.count)") + pickOnWatch(key) action(["action": "selection", "key": .string(key)]) if let edge { open(edge) } if expanded != nil || hoveredSurface != nil { prewarmTranscript(on: expanded == nil ? hoveredSurface : nil) } resumeTranscript() } + /// Tells the running watch at once, so its next snapshot names this session (about 0.1 s) instead of after the + /// `selection` action reaches its state file (a process and a 180 ms debounce, about 0.5 s). The action still runs: + /// it keeps the choice for the next launch. + private func pickOnWatch(_ key: String) { + guard let watcher, let data = try? JSONEncoder().encode(["select": key]), + let line = String(data: data, encoding: .utf8) else { return } + do { try watcher.sendInput(line + "\n") } catch { PerfLog.mark("widget.pick not sent: \(error.localizedDescription)") } + } + public func unpinSelected() { let old = selectedKey action(["action": "visibility", "key": .string(old), "pinned": false]) @@ -906,6 +1361,141 @@ public final class WidgetModel: ObservableObject { select(sessions[(index + direction + sessions.count) % sessions.count].key) } + // MARK: Up, Down and Return in the lists (ux catalogue R7) + + /// The last list key, for the view that draws the list on screen: the inbox of every session moves its own + /// highlight with it, and a session's inbox and the session list scroll to the row it chose. + @Published public private(set) var listKey: WidgetListKey? + /// The session inbox's filter ("all", "inbox", "decisions"), so the keys walk the rows the user sees. The + /// inbox's filter control writes it. + @Published public var inboxFilter = "all" + /// Set once the reader picks a filter; until then the filter follows the `inboxFilter` setting. + public var inboxFilterPicked = false + /// The option of the open decision card that Up and Down chose; nil while the card row itself is chosen. Kept + /// with its card, because option ids ("a", "b") repeat across decisions. + @Published public var choiceFocus: WidgetChoiceFocus? + /// The session list's rows in the order it draws them (its search, pins and open groups applied), written by + /// the list as it changes. Empty: the list has not said, and the keys leave it alone. + public var sessionListOrder: [String] = [] + /// Whether the inbox of every session has a row the keys chose, written by that view. Without one, Return is not + /// the list's and reaches the focused control (Mark all read). + public var inboxOverviewHasFocus = false + + /// Whether the agents pane shows a list that takes Up, Down and Return: the inbox of every session, a session's + /// inbox with entries, or the session list. Not the staged view, the leave question or the conversation. + public var keyboardList: Bool { + guard expanded != nil, activeModuleID == "agents", stagedView == nil, !stagedLeavePrompt else { return false } + if inboxOverview { return !inboxOverviewItems.isEmpty } + guard selected != nil else { return false } + return (section == "Sessions" && !sessionListOrder.isEmpty) || (section == "Inbox" && !cards.isEmpty) + } + + /// Up, Down or Return in the list on screen. True when the list took the key; false lets it through (the scroll + /// view then scrolls). The ends of a list stop the move; they do not wrap. + @discardableResult + public func moveInList(_ kind: WidgetListKey.Kind) -> Bool { + guard keyboardList else { return false } + if inboxOverview, kind == .open, !inboxOverviewHasFocus { return false } + listKey = WidgetListKey(kind: kind, serial: (listKey?.serial ?? 0) + 1) + // The inbox of every session owns its filter, grouping and highlight (WidgetInboxOverview). + if inboxOverview { return true } + switch section { + case "Inbox": return moveCard(kind) + case "Sessions": return moveSession(kind) + default: return false + } + } + + /// The row `step` rows from `current` in `count` rows, stopping at the ends; with no current row, the first row + /// going down and the last going up. + nonisolated static func listTarget(current: Int?, step: Int, count: Int) -> Int? { + guard count > 0 else { return nil } + guard let current else { return step < 0 ? count - 1 : 0 } + return min(max(current + step, 0), count - 1) + } + + /// Where Up or Down goes among an open decision's options: to an option, back to the card row (Up from the first + /// option), or out of the card (Up from the card row, Down from the last option). + enum OptionMove: Equatable { case option(Int), cardRow, leave } + + nonisolated static func optionMove(focused: Int?, step: Int, count: Int) -> OptionMove { + guard count > 0 else { return .leave } + guard let focused else { return step < 0 ? .leave : .option(0) } + let target = focused + step + if target < 0 { return .cardRow } + return target < count ? .option(target) : .leave + } + + /// A session's inbox, as drawn: the filter's rows, newest first. Up and Down open the row above or below (the + /// inbox shows one open row) and, in an open decision, walk its options first; Return folds or opens the current + /// row, or picks the chosen option the way a click does. No motion: the keys repeat. + private func moveCard(_ kind: WidgetListKey.Kind) -> Bool { + let shown = Array(LiveWidgetView.filtered(cards, by: inboxFilter).reversed()) + guard !shown.isEmpty else { return false } + let current = card.flatMap { open in shown.firstIndex { $0.id == open.id } } + // The open decision's options, when the row the keys stand on is that decision. + let decision = current.flatMap { index in answerCard.flatMap { $0.id == shown[index].id ? $0 : nil } } + let focused = decision.flatMap { open in + choiceFocus?.cardID == open.id ? open.choices.firstIndex(where: { $0.id == choiceFocus?.choiceID }) : nil + } + releaseAgentFit() + switch kind { + case .open: + guard let current else { return false } + if let decision, let focused { + guard decisionClickStages else { + // Return never sends (ux checklist, Keyboard); with "Send at once" the number key does. + postNote("Return does not send an answer. Press \(focused + 1) to send this one.") + return true + } + choose(decision, option: decision.choices[focused].id) + } else if foldedCardIDs.contains(shown[current].id) { + openCard(shown[current]) + } else { + foldedCardIDs.insert(shown[current].id) + } + case .up, .down: + let step = kind == .up ? -1 : 1 + if let decision { + switch Self.optionMove(focused: focused, step: step, count: decision.choices.count) { + case .option(let index): + choiceFocus = WidgetChoiceFocus(cardID: decision.id, choiceID: decision.choices[index].id) + return true + case .cardRow: + choiceFocus = nil + return true + case .leave: + break + } + } + guard let target = Self.listTarget(current: current, step: step, count: shown.count), + target != current || foldedCardIDs.contains(shown[target].id) else { return true } + choiceFocus = nil + openCard(shown[target]) + } + return true + } + + /// The session list: Up and Down select the session above or below (its row shows the selection), Return opens + /// its inbox, and Esc comes back to the list. + private func moveSession(_ kind: WidgetListKey.Kind) -> Bool { + let order = sessionListOrder + guard !order.isEmpty else { return false } + let current = order.firstIndex(of: selectedKey) + switch kind { + case .open: + guard current != nil else { return false } + listOrigin = .sessions + section = "Inbox" + case .up, .down: + guard let target = Self.listTarget(current: current, step: kind == .up ? -1 : 1, count: order.count), + order[target] != selectedKey else { return true } + select(order[target]) + } + return true + } + + public func setText(_ text: String) { let key = selectedKey guard !key.isEmpty else { return } @@ -1152,8 +1742,17 @@ public final class WidgetModel: ObservableObject { } } + /// Runs `work` after every hub write queued before it, and holds the later ones until it is done. + func afterMutations(_ work: @escaping @MainActor () async -> Void) { + let previous = mutationTask + mutationTask = Task { + await previous?.value + await work() + } + } + @discardableResult - private func call(_ value: WidgetJSON) async throws -> WidgetJSON { + func call(_ value: WidgetJSON, timeoutSeconds: Int = 120) async throws -> WidgetJSON { if let actionRunner { return try await actionRunner(value) } let file = journal.appendingPathComponent("action-" + UUID().uuidString + ".tmp") try JSONEncoder().encode(value).write(to: file, options: .atomic) @@ -1162,12 +1761,12 @@ public final class WidgetModel: ObservableObject { PerfLog.mark("widget.action cleanup \(error.localizedDescription)") } } - return try await runAction(file) + return try await runAction(file, timeoutSeconds: timeoutSeconds) } - private func runAction(_ file: URL) async throws -> WidgetJSON { + private func runAction(_ file: URL, timeoutSeconds: Int = 120) async throws -> WidgetJSON { let result = try await bridge.run( - subcommand: "hub", args: widgetArgs + ["call", "--input", file.path], timeoutSeconds: 120) + subcommand: "hub", args: widgetArgs + ["call", "--input", file.path], timeoutSeconds: timeoutSeconds) guard result.exitCode == 0 else { throw ToolsBridgeError.refused(String(result.stderr.suffix(1200))) } @@ -1489,6 +2088,61 @@ public final class WidgetModel: ObservableObject { static let screenRecordingRefused = "macOS refused the screenshot: Screen Recording is off for this app. Turn it on " + "in System Settings → Privacy & Security → Screen & System Audio Recording, then try again." + /// A screen recording for the reply box runs (from the picker until it ends): the video button shows Stop. + @Published public var recordingVideo = false + + /// The recorder behind the video button: GenesisKit's generic target picker, floating controls and session. + public private(set) lazy var screenRecorder = ScreenRecorder(bridge: bridge) + private var screenRecorderActivity: AnyCancellable? + + /// The video button: lets the person pick a window, an app or a display, records it with floating controls next to + /// it (pause, switch what is recorded, restart, "Stop and paste", stop and discard), and adds the movie to this + /// session's draft as a video, with the frame review a pasted video gets (Martin, 2026-10-23: "it should let me + /// pick what i want to record ... a widget somewhere around the recording panel"). A second press stops and pastes. + public func toggleVideoCapture() { + if recordingVideo { + screenRecorder.stopAndUse() + return + } + guard !stopping, !selectedKey.isEmpty else { return } + let key = selectedKey + let recorder = screenRecorder + screenRecorderActivity = recorder.$isActive.removeDuplicates().sink { [weak self] active in + self?.recordingVideo = active + } + recorder.begin(host: ScreenRecordingHost( + useTitle: "Stop and paste", + permissionReason: "The video button records the window, app or display you choose and adds the movie to this session's draft.", + onUse: { [weak self] movie in + guard let self, !self.stopping else { return } + try await self.importRecording(movie, to: key) + }, + onDiscard: { [weak self] in self?.notice = "Recording discarded. Nothing was added to the draft." }, + onError: { [weak self] message in self?.report(ToolsBridgeError.refused(message)) }, + onStart: { [weak self] in self?.collapse() })) + } + + /// Imports a finished recording into the draft of `key` behind every pending mutation, and removes the recorder's + /// file once the draft holds its copy. A failed import keeps the file and throws, so the recorder names where it is. + func importRecording(_ movie: URL, to key: String) async throws { + importing += 1 + defer { importing -= 1 } + let previous = mutationTask + let imported = Task { [weak self] in + await previous?.value + guard let self else { throw CancellationError() } + _ = try await self.call([ + "action": "import", "key": .string(key), "input": .string(movie.path), "type": "video", + ], timeoutSeconds: 300) + } + mutationTask = Task { + do { try await imported.value } catch { PerfLog.mark("widget.recording import \(error.localizedDescription)") } + } + try await imported.value + removeStaging(movie) + notice = "The recording is in the draft. Review its frames before you send it." + } + /// The panel steps aside for the selection, then reopens with the new attachment in the draft. Escape returns /// `{cancelled: true, reason: "user"}`, which is not an error and reopens nothing. A missing Screen Recording grant /// (an error whose text starts with `WidgetCaptureError.screenRecordingDenied`) opens the permission dialog. @@ -1525,39 +2179,69 @@ public final class WidgetModel: ObservableObject { } } + /// The microphone key and a press without a menu: stops the dictation that runs, else starts the preferred mode. public func toggleVoice() { - if let recordVoiceNote { - guard !selectedKey.isEmpty else { return } - recordVoiceNote(selectedKey) - return - } if voiceActive { voice?.finishInput() return } - guard let prefs = snapshot?.state.preferences, !selectedKey.isEmpty else { return } + startVoice(preferredVoiceMode) + } + + /// Live dictation into the reply box: the words appear while the user talks, with the voice level as waves. In + /// `.note` mode the audio is kept too and becomes a voice note with this session and decision when it stops. + public func startVoice(_ mode: WidgetVoiceMode) { + guard !voiceActive, let prefs = snapshot?.state.preferences, !selectedKey.isEmpty else { return } + defaults.set(mode.rawValue, forKey: Self.lastVoiceModeKey) + let card = ownAnswerCard + let context = WidgetVoiceNoteContext( + sessionKey: selectedKey, sessionTitle: selected?.title, decisionId: card?.sourceId, + decisionNumber: card?.number, title: card?.title) voiceKey = selectedKey voiceFinals = [] voiceText = "" + voiceLevel = 0 voiceActive = true + voiceMode = mode var args = [ "listen", "--provider", prefs.voiceProvider, "--language", prefs.voiceLanguage, "--input", "mic", "--json", "--stop-on-stdin", ] if let account = prefs.voiceAccount, !account.isEmpty { args += ["--account", account] } if let model = prefs.voiceModel, !model.isEmpty { args += ["--model", model] } - do { - voice = try ToolsLineStream( - bridge: bridge, subcommand: "voice", args: args, - onLines: { [weak self] lines in self?.receiveVoice(lines) }, - onExit: { [weak self] exit in - guard let self else { return } - self.finishVoice() - if exit.status != 0 && !exit.stopped { self.error = String(exit.stderr.suffix(600)) } - }) - } catch { - voiceActive = false - report(error) + let clipSource = mode == .note ? voiceNoteClip : nil + Task { [weak self] in + var args = args + if let clipSource { + do { + let clip = try await clipSource() + args += ["--save-audio", clip] + self?.voiceNotePlan = (clip, context) + } catch { + // The words still go into the reply box; only the recording is not kept. + self?.report(error) + } + } + guard let self, self.voiceActive, self.voice == nil else { return } + do { + self.voice = try ToolsLineStream( + bridge: self.bridge, subcommand: "voice", args: args, + onLines: { [weak self] lines in self?.receiveVoice(lines) }, + onExit: { [weak self] exit in + guard let self else { return } + // Only a clean exit proves the audio writer closed the clip. + self.finishVoice(fileClip: exit.status == 0) + if exit.status != 0 && !exit.stopped { + self.fail("Dictation stopped unexpectedly. Press the microphone to try again.", + detail: String(exit.stderr.suffix(4000))) + } + }) + } catch { + self.voiceActive = false + self.voiceMode = nil + self.voiceNotePlan = nil + self.report(error) + } } } @@ -1580,11 +2264,11 @@ public final class WidgetModel: ObservableObject { voiceText = "" } case "complete": + // The listener still flushes the audio after this line; the exit finishes the note. if let text = event.text { voiceFinals = [text] voiceText = "" } - finishVoice() case "error": error = event.error ?? event.text default: break } @@ -1592,11 +2276,23 @@ public final class WidgetModel: ObservableObject { } } - private func finishVoice() { + /// `fileClip` false: the recording may be incomplete, so it is removed instead of filed as a voice note. + private func finishVoice(fileClip: Bool = true) { guard voiceActive else { return } voiceActive = false + voiceMode = nil voice = nil let text = (voiceFinals + [voiceText]).filter { !$0.isEmpty }.joined(separator: " ") + if let plan = voiceNotePlan { + voiceNotePlan = nil + if fileClip, !text.isEmpty, let fileVoiceNote { + fileVoiceNote(plan.clip, text, plan.context) + } else { + do { try FileManager.default.removeItem(atPath: plan.clip) } catch { + PerfLog.mark("widget.voice clip cleanup \(error.localizedDescription)") + } + } + } if let key = voiceKey, !text.isEmpty { let existing = drafts[key]?.text ?? "" drafts[key] = WidgetDraft( @@ -1647,8 +2343,18 @@ public final class WidgetModel: ObservableObject { } } + /// The nearby messages last read for a card, however old: its section shows them at once while a fresh read runs, + /// instead of "Loading nearby messages…" each time a session opens again. + public func cachedReceiptContext(for card: WidgetCard) -> WidgetReceiptContext? { + receiptContextCache[Self.receiptContextKey(card)]?.value + } + + private static func receiptContextKey(_ card: WidgetCard) -> String { + card.sessionKey + "|" + card.id + "|" + String(card.at) + } + public func receiptContext(for card: WidgetCard) async throws -> WidgetReceiptContext { - let key = card.sessionKey + "|" + card.id + "|" + String(card.at) + let key = Self.receiptContextKey(card) if let cached = receiptContextCache[key], Date().timeIntervalSince(cached.at) < 15 { return cached.value } @@ -1659,7 +2365,7 @@ public final class WidgetModel: ObservableObject { try Task.checkCancellation() guard result.exitCode == 0 else { throw ToolsBridgeError.refused(String(result.stderr.suffix(1000))) } let value = try JSONDecoder().decode(WidgetReceiptContext.self, from: Data(result.stdout.utf8)) - if receiptContextCache.count >= 12, let oldest = receiptContextCache.min(by: { $0.value.at < $1.value.at }) { + if receiptContextCache.count >= 48, let oldest = receiptContextCache.min(by: { $0.value.at < $1.value.at }) { receiptContextCache.removeValue(forKey: oldest.key) } receiptContextCache[key] = (Date(), value) @@ -1671,6 +2377,31 @@ public final class WidgetModel: ObservableObject { provider: session.target.provider) } + /// Context size per session key, for the header (`WidgetSessionFacts`). Read from the transcript cache when a + /// session opens: the conversation itself loads only on the Conversation tab, and the header shows on every tab. + @Published public private(set) var contextTokens: [String: Int] = [:] + private var contextTask: Task<Void, Never>? + + private func refreshContext() { + guard let session = selected, session.target.provider != "unknown" else { return } + let key = session.key + let query = transcriptQuery(session) + contextTask?.cancel() + contextTask = Task { [weak self] in + guard let self, let envelope = try? await self.transcriptCache.value(for: query), !Task.isCancelled, + let tokens = Self.contextTokens(envelope.turns) else { return } + if self.contextTokens[key] != tokens { self.contextTokens[key] = tokens } + } + } + + /// The context the session's last model call carried: its fresh input plus its cache reads. nil when the turns + /// have no call with usage (a provider that reports none, or nothing loaded yet). + nonisolated static func contextTokens(_ turns: [TranscriptTurn]) -> Int? { + guard let usage = turns.last(where: { $0.role == "assistant" && $0.usage != nil })?.usage else { return nil } + let total = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + return total > 0 ? total : nil + } + private func prewarmTranscript(on surface: WidgetSurfaceID? = nil) { let module = surface.map { moduleSelections[$0.key] ?? "agents" } ?? activeModuleID let hovered = surface.flatMap { surface in @@ -1740,7 +2471,19 @@ public final class WidgetModel: ObservableObject { let delay = max(5, snapshot?.state.preferences.quietSeconds ?? 15) quietTask = Task { [weak self] in do { try await Task.sleep(for: .seconds(delay)) } catch { return } - self?.collapse() + guard let self else { return } + // A pointer over the panel, or a decision waiting in it, is someone reading: the quiet timer folded a + // session with open decisions under the reader's pointer (Martin, 2026-10-25). It tries again later. + // `pointerInside` and not `hoveredSurface`: opening a module clears the hover, not the pointer. + if !self.pointerInside.isEmpty || self.cards.contains(where: \.needsAnswer) { + PerfLog.mark("widget.quiet-collapse skipped: " + + (!self.pointerInside.isEmpty ? "pointer over the panel" : "a decision waits")) + self.quietSignature = "" + return + } + PerfLog.mark("widget.quiet-collapse after \(delay)s quiet in \(self.selectedKey)") + // With staged decisions this asks first and the panel stays open (Martin, 2026-10-23). + self.requestCollapse() } } @@ -1750,7 +2493,7 @@ public final class WidgetModel: ObservableObject { } } - private func report(_ error: Error) { + func report(_ error: Error) { self.error = error.localizedDescription PerfLog.mark("widget.error \(error.localizedDescription)") } @@ -1830,3 +2573,31 @@ final class WidgetSnapshotDecoder: @unchecked Sendable { } } } + +/// The reply box microphone's two live modes. The raw values are the `composerVoice` setting's. +public enum WidgetVoiceMode: String, Sendable { + /// Words into the reply box only. + case dictate = "live" + /// Words into the reply box, and the audio kept as a voice note with its session and decision. + case note +} + +/// A key the user pressed in the widget's list on screen (Up, Down, Return). `serial` makes two presses of the same +/// key two changes, so a view can react to each. +public struct WidgetListKey: Equatable, Sendable { + public enum Kind: Equatable, Sendable { case up, down, open } + public let kind: Kind + public let serial: Int +} + +/// Which session's cmux popover a row's accessibility action asked for, and in which list. +public struct WidgetCmuxRequest: Equatable, Sendable { + public let key: String + public let scope: String? +} + +/// The option of a decision card that the arrow keys chose (`WidgetModel.choiceFocus`). +public struct WidgetChoiceFocus: Equatable, Sendable { + public let cardID: String + public let choiceID: String +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMotion.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMotion.swift index 2f9f269499..d93572ab71 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMotion.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMotion.swift @@ -44,7 +44,8 @@ struct WidgetGlassControl: ViewModifier { if systemOpaque || requestedOpaque { content.background(Color(white: 0.16), in: Capsule()) } else if #available(macOS 26.0, *) { - content.glassEffect(.regular.tint(.black.opacity(0.4)).interactive(), in: Capsule()) + // Glass is not hit-tested; the capsule is, as the opaque form's fill is. + content.glassEffect(.regular.tint(.black.opacity(0.4)).interactive(), in: Capsule()).contentShape(Capsule()) } else { content.background(.ultraThinMaterial, in: Capsule()) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetReceiptContext.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetReceiptContext.swift index 1cb9806702..62eeaeda5a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetReceiptContext.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetReceiptContext.swift @@ -81,6 +81,7 @@ struct WidgetReceiptContextView: View { hasPrewarmed = true let requestID = UUID() loadingID = requestID + if context == nil { context = model.cachedReceiptContext(for: card) } loading = context == nil failure = nil defer { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index 17004a8dbe..24a73333a2 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -3,19 +3,29 @@ import SwiftUI public struct WidgetSessionBrowser: View { @ObservedObject private var model: WidgetModel private let onOpen: ((WidgetSession) -> Void)? + /// True only for the panel's Sessions tab: the session chooser popover and the settings page share the model's + /// `sessionListOrder` and must not overwrite or clear the order of the list still on screen. + private let reportsOrder: Bool @State private var query = "" @State private var onlyPinned = false @State private var limit = 40 @State private var expanded = Set<String>() @State private var collapsed = Set<String>() + @GenReduceMotion private var reduceMotion - public init(model: WidgetModel, onOpen: ((WidgetSession) -> Void)? = nil) { + public init(model: WidgetModel, reportsOrder: Bool = false, onOpen: ((WidgetSession) -> Void)? = nil) { self.model = model + self.reportsOrder = reportsOrder self.onOpen = onOpen } public var body: some View { + let _ = RenderProbe.hit("widget.sessions") let groups = WidgetAgentTree.groups(model.snapshot?.sessions ?? [], query: query, onlyPinned: onlyPinned) + // The rows in the order drawn, for the arrow keys. + let order = groups.prefix(limit).flatMap { group in + [group.parent.key] + (isOpen(group) ? group.children.map(\.session.key) : []) + } LazyVStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) @@ -52,29 +62,15 @@ public struct WidgetSessionBrowser: View { .frame(maxWidth: .infinity) } else { ForEach(Array(groups.prefix(limit))) { group in - if group.children.isEmpty { - row(group.parent, title: group.parent.title, depth: 0) - } else { - GenDisclosure(isExpanded: Binding(get: { - if !query.isEmpty { return true } - if collapsed.contains(group.id) { return false } - return expanded.contains(group.id) || group.id == model.selectedKey - || group.children.contains { $0.id == model.selectedKey } - }, set: { value in - if value { expanded.insert(group.id); collapsed.remove(group.id) } - else { expanded.remove(group.id); collapsed.insert(group.id) } - }), minHeight: 34, identifier: "widget.sessions.group." + group.id) { + // Every top-level session is the same row, with or without sub-agents; a chevron folds them. + VStack(alignment: .leading, spacing: 2) { + topRow(group) + if !group.children.isEmpty && isOpen(group) { LazyVStack(spacing: 2) { - row(group.parent, title: "Main", depth: 0) - ForEach(group.children) { child in row(child.session, title: child.session.title, depth: child.depth) } - }.padding(.top, 6) - } label: { - AgentRosterGroupLabel(title: group.parent.title, provider: group.parent.target.provider, - project: group.parent.project, account: group.parent.account, - total: group.children.count, running: group.running, - live: group.parent.status == "working" || group.running > 0, - lastAt: Date(timeIntervalSince1970: group.parent.activityAt / 1000)) - .padding(.vertical, 4) + ForEach(group.children) { child in + row(child.session, title: child.session.title, depth: child.depth + 1) + } + }.transition(.genReveal) } } } @@ -86,6 +82,8 @@ public struct WidgetSessionBrowser: View { } .onChange(of: query) { _, _ in limit = 40 } .onChange(of: onlyPinned) { _, _ in limit = 40 } + .onChange(of: order, initial: true) { _, keys in if reportsOrder { model.sessionListOrder = keys } } + .onDisappear { if reportsOrder { model.sessionListOrder = [] } } } private var projectFilter: some View { @@ -111,12 +109,77 @@ public struct WidgetSessionBrowser: View { .fixedSize() } + private func isOpen(_ group: WidgetAgentTree.Group) -> Bool { + if !query.isEmpty { return true } + if collapsed.contains(group.id) { return false } + return expanded.contains(group.id) || group.children.contains { $0.id == model.selectedKey } + } + + private func toggle(_ group: WidgetAgentTree.Group) { + let opening = !isOpen(group) + GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { + if opening { expanded.insert(group.id); collapsed.remove(group.id) } + else { expanded.remove(group.id); collapsed.insert(group.id) } + } + } + + private func open(_ session: WidgetSession) { + if let onOpen { onOpen(session) } + else { + model.select(session.key) + model.section = "Inbox" + model.listOrigin = .sessions + } + } + + /// A top-level session: its name, project, account and sub-agent count, the same for every session. + private func topRow(_ group: WidgetAgentTree.Group) -> some View { + let session = group.parent + let selected = session.key == model.selectedKey + return WidgetHoverRow { hovering in HStack(alignment: .center, spacing: 2) { + if group.children.isEmpty { + Color.clear.frame(width: 22, height: 22) + } else { + Button { toggle(group) } label: { + Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary).rotationEffect(.degrees(isOpen(group) ? 90 : 0)) + .frame(width: 16, height: 16).contentShape(Rectangle()) + } + .buttonStyle(.genHoverIcon(diameter: 22)) + .instantTooltip(isOpen(group) ? "Hide its sub-agents" : "Show its \(Plural.count(group.children.count, "sub-agent"))") + .accessibilityLabel((isOpen(group) ? "Hide sub-agents of " : "Show sub-agents of ") + session.title) + .accessibilityIdentifier("widget.sessions.group." + group.id) + } + Button { open(session) } label: { + VStack(alignment: .leading, spacing: 3) { + AgentRosterGroupLabel(title: session.title, provider: session.target.provider, + project: session.project, account: session.account, + total: group.children.count, running: group.running, + live: session.status == "working" || group.running > 0, + lastAt: Date(timeIntervalSince1970: session.activityAt / 1000), + unread: model.inboxFor(session.key).map { $0.unread + $0.needsAnswer } ?? 0) + if session.hiddenByFilter { + Text("Hidden by widget filters").font(.caption2).foregroundStyle(.orange).padding(.leading, 31) + } + } + .padding(.vertical, 7).padding(.horizontal, 4) + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + trailing(session, hovering: hovering) + } } + .padding(.horizontal, 4) + .accessibilityAction(named: "Open cmux for " + session.title) { model.requestCmux(session.key, scope: "sessions") } + .id(session.key) + .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) + } + private func row(_ session: WidgetSession, title: String, depth: Int) -> some View { - HStack(alignment: .top, spacing: 5) { - Button { - if let onOpen { onOpen(session) } - else { model.select(session.key); model.section = "Inbox" } - } label: { + let selected = session.key == model.selectedKey + return WidgetHoverRow { hovering in HStack(alignment: .center, spacing: 2) { + Button { open(session) } label: { VStack(alignment: .leading, spacing: 3) { AgentRosterRow(title: title, provider: session.target.provider, role: session.role ?? "lead", model: session.model, account: session.account, @@ -125,28 +188,44 @@ public struct WidgetSessionBrowser: View { lastAt: Date(timeIntervalSince1970: session.activityAt / 1000), toolCalls: session.toolCalls ?? 0, unread: model.inboxFor(session.key).map { $0.unread + $0.needsAnswer } ?? 0, - selected: session.key == model.selectedKey, showsRunningLabel: session.agentId == nil) + selected: selected, showsRunningLabel: session.agentId == nil) if session.hiddenByFilter { Text("Hidden by widget filters").font(.caption2).foregroundStyle(.orange).padding(.leading, 15) } - }.frame(maxWidth: .infinity, alignment: .leading) - }.buttonStyle(.genHoverRow()) - .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + } + .padding(.vertical, 6).padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + trailing(session, hovering: hovering) + } } + .padding(.leading, CGFloat(min(depth, 4)) * 14).padding(.trailing, 4) + .accessibilityAction(named: "Open cmux for " + session.title) { model.requestCmux(session.key, scope: "sessions") } + .id(session.key) + .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) + } + + /// cmux and pin, inside the row's own box so its selection and hover cover them. cmux is live only on the + /// hovered or selected row. + private func trailing(_ session: WidgetSession, hovering: Bool) -> some View { + HStack(spacing: 0) { + WidgetCmuxButton(model: model, session: session, size: 10.5, + live: hovering || session.key == model.selectedKey, requestScope: "sessions") Button { model.action(["action": "visibility", "key": .string(session.key), "pinned": .bool(!session.pinned)]) } label: { Image(systemName: session.pinned ? "pin.fill" : "pin") .font(.system(size: 10)).foregroundStyle(session.pinned ? Color.blue : .secondary) - .frame(width: 22, height: 25) + .frame(width: 24, height: 24).contentShape(Rectangle()) }.buttonStyle(.genHoverPlain()).instantTooltip(session.pinned ? "Unpin from widget" : "Pin to widget") .accessibilityLabel((session.pinned ? "Unpin " : "Pin ") + session.title) } - .padding(.leading, CGFloat(min(depth, 4)) * 12).padding(.vertical, 5).padding(.horizontal, 6) - .background(session.key == model.selectedKey ? Color.blue.opacity(0.12) : .clear, - in: RoundedRectangle(cornerRadius: 9)) } private func setProjects(_ projects: [String]) { model.action(["action": "preferences", "patch": ["projects": .array(projects.map(WidgetJSON.string))]]) } } + diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionCache.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionCache.swift new file mode 100644 index 0000000000..cf9fdf99da --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionCache.swift @@ -0,0 +1,48 @@ +import Foundation + +/// What the widget last saw of each session, so a switch shows the session's cards at once instead of "Loading this +/// session's inbox…" until a snapshot names it (Martin, 2026-10-24: "can't it just be cached and be prepared at all +/// times and the new decisions would be just appended"). Every snapshot refreshes the sessions it carries: the selected +/// one in full (cards, decision activity, changes), the warm ones (`warmKeys`, the sessions that need you) with their +/// newest cards. The snapshot that names a session after the switch replaces what is shown, so new cards just appear. +struct WidgetSessionCache { + struct Detail: Equatable { + var cards: [WidgetCard] + var activity: [WidgetActivityEvent]? + var changes: WidgetSnapshot.Changes? + } + + /// Sessions kept; the least recently refreshed goes first. + static let limit = 48 + private var details: [String: Detail] = [:] + private var order: [String] = [] + + subscript(key: String) -> Detail? { details[key] } + + var count: Int { details.count } + + mutating func remember(_ snapshot: WidgetSnapshot) { + let warm = snapshot.warmKeys ?? [] + let selected = snapshot.selectedKey.flatMap { $0.isEmpty ? nil : $0 } + guard !warm.isEmpty || selected != nil else { return } + let grouped = Dictionary(grouping: snapshot.cards, by: \.sessionKey) + for key in warm where key != selected { + // Activity and changes come only with a selection; the last ones seen stay until the next. + var detail = details[key] ?? Detail(cards: []) + detail.cards = grouped[key] ?? [] + store(key, detail) + } + if let selected { + store(selected, Detail(cards: grouped[selected] ?? [], activity: snapshot.activity, changes: snapshot.changes)) + } + } + + private mutating func store(_ key: String, _ detail: Detail) { + details[key] = detail + order.removeAll { $0 == key } + order.append(key) + while order.count > Self.limit { + details.removeValue(forKey: order.removeFirst()) + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift new file mode 100644 index 0000000000..05188be5f8 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift @@ -0,0 +1,190 @@ +import SwiftUI + +/// A decision's staged pick or note, as one line: "a) Back both up" or the note. +enum WidgetStagedText { + static func line(_ item: WidgetInboxItem, option: String?) -> String { + if let option, let index = option.unicodeScalars.first.map({ Int($0.value) - 97 }), + let options = item.options, options.indices.contains(index) { + let note = item.draft.map { " · " + $0 } ?? "" + return "\(option)) " + options[index] + note + } + return item.draft ?? "Nothing picked" + } +} + +/// The row under a session inbox's filter that says what is staged and opens the staged view. +struct WidgetStagedChip: View { + @ObservedObject var model: WidgetModel + @GenReduceMotion private var reduceMotion + + var body: some View { + let here = model.sessionStaged.count + let all = model.stagedItems.count + if all > 0 { + HStack(spacing: 8) { + Image(systemName: "tray.and.arrow.up").font(.system(size: 11)).foregroundStyle(SessionPalette.blue) + Text(here > 0 && here < all ? "\(Plural.count(here, "decision")) staged here · \(all) in all" + : "\(Plural.count(all, "decision")) staged") + .font(.system(size: 11, weight: .medium)) + Spacer(minLength: 4) + Button("Review and send") { + GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { + model.stagedView = here > 0 ? "session" : "all" + } + } + .buttonStyle(.genHover(accent: SessionPalette.blue)).font(.system(size: 11, weight: .semibold)) + .instantTooltip("The picks and notes you staged. Choose which go now; each session gets one message.") + } + .padding(.horizontal, 10).padding(.vertical, 7) + .background(SessionPalette.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 9)) + .accessibilityElement(children: .combine) + } + } +} + +/// The staged decisions, with a checkbox each: the checked ones go now, one message per session. "This session" +/// shows the selected session's; "All sessions" every one (Martin, 2026-10-23: "an easy button which shows all of +/// them not just the filtered by that specific session"). +struct WidgetStagedList: View { + @ObservedObject var model: WidgetModel + @GenReduceMotion private var reduceMotion + /// Unchecked decision ids; everything else is checked, so a newly staged one goes by default. + @State private var skipped: Set<String> = [] + + private var scopeAll: Bool { model.stagedView == "all" || model.sessionStaged.isEmpty } + private var items: [WidgetInboxItem] { scopeAll ? model.stagedItems : model.sessionStaged } + private var chosen: [WidgetInboxItem] { items.filter { !skipped.contains($0.sourceId) } } + + var body: some View { + let items = items + let chosen = chosen + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + IconButton(systemName: "chevron.left", tooltip: "Back to the inbox") { + GenMotion.perform(expanding: false, reduceMotion: reduceMotion) { model.stagedView = nil } + }.accessibilityLabel("Back to the inbox") + Text("Staged decisions").font(.system(size: 13, weight: .semibold)) + Spacer(minLength: 4) + if !model.sessionStaged.isEmpty && model.sessionStaged.count < model.stagedItems.count { + GenSegmentedTabs( + "Staged scope", + items: [.init("session", "This session"), .init("all", "All sessions (\(model.stagedItems.count))")], + selection: Binding(get: { scopeAll ? "all" : "session" }, set: { model.stagedView = $0 }), + minSegmentWidth: 90, identifier: "widget.staged.scope") + } + } + if items.isEmpty { + Text("Nothing is staged. A click on a decision's option stages it here.") + .font(.caption).foregroundStyle(.secondary).padding(.vertical, 20).frame(maxWidth: .infinity) + } else { + ForEach(groups(items), id: \.key) { group in + VStack(alignment: .leading, spacing: 6) { + if scopeAll { + Text(model.session(forKey: group.key)?.title ?? "Unknown session") + .font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary) + } + ForEach(group.items, id: \.sourceId) { item in row(item) } + } + } + Text(chosen.isEmpty ? "Nothing is checked." : WidgetModel.stagedSummary(chosen)) + .font(.system(size: 11)).foregroundStyle(.secondary) + HStack(spacing: 8) { + Button("Send \(chosen.count) now") { + // The pane keeps its height while the staged list closes, so the panel does not shrink + // away from the pointer (hover hunt H2, 2026-10-25). + model.holdAgentFit() + model.sendStaged(chosen.map(\.sourceId)) + model.stagedView = nil + } + .buttonStyle(.borderedProminent).disabled(chosen.isEmpty) + // The leave question over this list owns ⌘↩ while it is shown. + .keyboardShortcut(model.stagedLeavePrompt ? nil : KeyboardShortcut(.return, modifiers: [.command])) + .instantTooltip("Sends the checked decisions now, one message per session (⌘↩)") + Button("Discard checked") { model.discardStaged(chosen.map(\.sourceId)) } + .buttonStyle(.genHover()).disabled(chosen.isEmpty) + .instantTooltip("Clears the picks and notes; the decisions still wait for an answer.") + Spacer(minLength: 0) + Button("Keep staged") { + GenMotion.perform(expanding: false, reduceMotion: reduceMotion) { model.stagedView = nil } + }.buttonStyle(.genHover()) + }.font(.caption) + } + } + .padding(12) + .background(.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 12)) + } + + private func groups(_ items: [WidgetInboxItem]) -> [(key: String, items: [WidgetInboxItem])] { + var order: [String] = [] + var byKey: [String: [WidgetInboxItem]] = [:] + for item in items { + if byKey[item.key] == nil { order.append(item.key) } + byKey[item.key, default: []].append(item) + } + return order.map { ($0, byKey[$0] ?? []) } + } + + private func row(_ item: WidgetInboxItem) -> some View { + let checked = !skipped.contains(item.sourceId) + return Button { + if checked { skipped.insert(item.sourceId) } else { skipped.remove(item.sourceId) } + } label: { + HStack(alignment: .top, spacing: 9) { + Image(systemName: checked ? "checkmark.square.fill" : "square") + .foregroundStyle(checked ? SessionPalette.blue : .secondary).font(.system(size: 13)) + VStack(alignment: .leading, spacing: 2) { + Text(WidgetInboxOverview.title(item)).font(.system(size: 12, weight: .medium)).lineLimit(2) + Text(WidgetStagedText.line(item, option: model.stagedOption(item.sourceId, stored: item.draftOption))) + .font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(8).frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .accessibilityLabel(WidgetInboxOverview.title(item)) + .accessibilityValue(checked ? "Will be sent" : "Stays staged") + } +} + +/// The question before the panel closes with staged decisions: "You have 3 decisions staged. What to do with them?" +struct WidgetStagedLeavePrompt: View { + @ObservedObject var model: WidgetModel + + var body: some View { + let items = model.stagedItems + ZStack { + Color.black.opacity(0.35).allowsHitTesting(true) + VStack(alignment: .leading, spacing: 10) { + Text("You have \(Plural.count(items.count, "decision")) staged. What to do with \(items.count == 1 ? "it" : "them")?") + .font(.system(size: 13, weight: .semibold)).fixedSize(horizontal: false, vertical: true) + Text(WidgetModel.stagedSummary(items) + (items.count == 1 ? " if you send it now." : " if you send them now.")) + .font(.system(size: 11)).foregroundStyle(.secondary) + // Return and Esc keep the answers (the safe choice); sending cannot be undone, so it needs ⌘↩ or a click. + HStack(spacing: 8) { + Button("Keep staged (send later)") { model.resolveStagedLeave("keep") } + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + .instantTooltip("Closes the panel; the decisions stay staged for later (↩)") + Button("Send now") { model.resolveStagedLeave("send") } + .buttonStyle(.genHover(accent: SessionPalette.blue)) + .keyboardShortcut(.return, modifiers: [.command]) + .instantTooltip("Sends them now, one message per session (⌘↩)") + Button("Discard") { model.resolveStagedLeave("discard") } + .buttonStyle(.genHover(accent: .red)) + .instantTooltip("Clears the picks and notes; the decisions still wait for an answer.") + }.font(.caption) + Button("Review them first") { + model.stagedLeavePrompt = false + model.stagedView = "all" + }.buttonStyle(.genHoverPlain()).font(.caption) + } + .padding(16).frame(maxWidth: 420) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.white.opacity(0.12))) + .shadow(radius: 18) + } + .transition(.opacity) + .accessibilityElement(children: .contain) + .accessibilityLabel("Staged decisions before closing") + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index 7eed9bbd47..2960e38247 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -167,6 +167,10 @@ public struct WidgetCard: Codable, Identifiable, Equatable, Sendable { public var answeredVia: String? = nil public var comments: [String]? = nil public var supersededBy: SupersededBy? = nil + /// A staged decision's unsent pick and note (the decision log's `drafted` state); sent with the other staged ones. + public var draftOption: String? = nil + public var draft: String? = nil + public var isStaged: Bool { kind == "decision" && needsAnswer && (draftOption != nil || draft != nil) } public var needsAnswer: Bool { (kind == "decision" && ["open", "drafted"].contains(status)) || (kind == "form" && status == "pending") @@ -279,6 +283,10 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var sideStyle: String? public var joinedEdges: Bool? public var glassEffect: Bool? + /// "dark", "glass" or "theme"; nil reads as "dark". + public var panelBackground: String? + /// The hover list's rows show their waiting question; nil reads as on. + public var previewQuestion: Bool? public var display: String? public var providers: [String]? /// Widget alerts for new items: a chime (`alertSound`, a system sound name or "none") and a popup. @@ -286,6 +294,41 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var alertAnswers: Bool? public var alertSound: String? public var alertPopup: Bool? + /// The agents hover list: "needs-you" (default) or "active" (sessions that work now too). + public var previewFilter: String? + /// The agents hover list's order: "attention" (default), "recent" or "project". + public var previewSort: String? + /// An answer's sound: "same" (default, `alertSound`), "none" or a system sound name. + public var alertAnswerSound: String? + /// Seconds the session that raised an alert flashes; 0 does not flash it. Default 5. + public var alertFlashSeconds: Double? + /// A session inbox's composer: "top" (default, under the tabs) or "bottom". + public var inboxComposer: String? + /// A rail bubble's tooltip: "auto" (default, only without hover previews), "always" or "never". + public var bubbleTooltip: String? + /// The rail's session bubbles: "list" (default, the hover list's order) or "stable" (each keeps its place). + public var railOrder: String? + /// A click on a decision option: "stage" (default, sent later with the other staged ones) or "send" (at once). + public var decisionClick: String? + /// Leaving the inbox with staged decisions: "ask" (default), "keep" or "send". + public var stagedOnLeave: String? + /// The inbox of every session: "session" (default, grouped under each session) or "list". + public var inboxGrouping: String? + /// The filter a session's inbox opens with: "all" (default), "inbox" or "decisions". + public var inboxFilter: String? + /// The filter the inbox of every session opens with: "all" (default), "needs" or "unread". + public var overviewFilter: String? + /// The composer's microphone: "note" (default, a voice note whose transcript fills the field) or "live". + public var composerVoice: String? + /// Seconds the alert popup stays when the pointer is not on it. Default 7. + public var alertPopupSeconds: Double? + /// Seconds a widget note stays before it fades; 0 keeps it until it is closed. Default 6. A note with a button + /// stays at least 10 s. + public var noteSeconds: Double? + /// A global shortcut opens and closes the widget (`openShortcut`). Default on, with ⌃⌥⌘W. + public var openShortcutEnabled: Bool? + /// The shortcut, as a Carbon key code and modifier mask (`HotkeyChord`). + public var openShortcut: WidgetShortcutChord? public var layout: WidgetLayoutConfiguration { WidgetLayoutConfiguration( @@ -300,6 +343,34 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var voiceModel: String? public var voiceLanguage: String } +/// A global shortcut as the widget preferences store it: a Carbon virtual key code and a Carbon modifier mask. +public struct WidgetShortcutChord: Codable, Equatable, Sendable { + public var keyCode: UInt32 + public var modifiers: UInt32 + + public init(keyCode: UInt32, modifiers: UInt32) { + self.keyCode = keyCode + self.modifiers = modifiers + } + + /// ⌃⌥⌘W: the dictation shortcut's family (⌃⌥⌘D), and no macOS or common app shortcut. + public static let defaultOpen = WidgetShortcutChord( + keyCode: 0x0D, modifiers: HotkeyChord.control | HotkeyChord.option | HotkeyChord.command) + + public var hotkey: HotkeyChord { HotkeyChord(keyCode: keyCode, modifiers: modifiers) } +} + +extension WidgetPreferences { + /// The shortcut that opens the widget, or nil while it is off. A stored chord without ⌘, ⌥ or ⌃ would take a + /// plain key from every app, so the default stands in for it. + public var openShortcutChord: WidgetShortcutChord? { + guard openShortcutEnabled ?? true else { return nil } + let chord = openShortcut ?? .defaultOpen + let required = HotkeyChord.command | HotkeyChord.option | HotkeyChord.control + return chord.modifiers & required != 0 ? chord : .defaultOpen + } +} + public struct WidgetOutgoing: Codable, Identifiable, Equatable, Sendable { public struct Receipt: Codable, Equatable, Sendable { public var channel: String @@ -375,6 +446,16 @@ public struct WidgetInboxItem: Codable, Equatable, Sendable { /// A short title (a decision's title or prompt, an answer's question) and a decision's number, for alerts. public var title: String? = nil public var number: Int? = nil + /// How many items this entry stands for: an answers group lists only its newest item. + public var count: Int? = nil + /// A decision's options and recommended letter, so the inbox of every session can stage a pick in place. + public var options: [String]? = nil + public var recommended: String? = nil + public var revision: Int? = nil + /// A staged decision's unsent pick and note; see `WidgetCard.draftOption`. + public var draftOption: String? = nil + public var draft: String? = nil + public var isStaged: Bool { kind == "decision" && needsAnswer && (draftOption != nil || draft != nil) } } public struct WidgetInboxSession: Codable, Equatable, Sendable { public var key: String @@ -383,6 +464,8 @@ public struct WidgetInboxSession: Codable, Equatable, Sendable { public var latest: WidgetInboxItem? public var unreadItem: WidgetInboxItem? public var pendingItem: WidgetInboxItem? + /// The session's listed entries for the inbox overview, waiting ones first, then newest; nil from an older hub. + public var items: [WidgetInboxItem]? = nil } public struct WidgetInboxSummary: Codable, Equatable, Sendable { public struct Profile: Codable, Equatable, Sendable { public var hostId: String } @@ -391,6 +474,8 @@ public struct WidgetInboxSummary: Codable, Equatable, Sendable { public var complete: Bool public var truncated: Bool public var sessions: [WidgetInboxSession] + /// Every staged decision, uncapped (a session's `items` stop at eight); nil from an older hub. + public var staged: [WidgetInboxItem]? = nil public var profile: Profile? public static let empty = WidgetInboxSummary(unread: 0, needsAnswer: 0, complete: false, truncated: false, sessions: []) } @@ -417,6 +502,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public var changes: Changes? public var errors: [String] public var selectedKey: String? + /// Other sessions whose cards this snapshot carries too (the ones that need you), so opening one waits for nothing. + public var warmKeys: [String]? = nil } public enum WidgetSelection { @@ -469,3 +556,20 @@ public struct WidgetReceiptContext: Codable, Equatable, Sendable { public var transcript: Window? public var error: String? } + +/// A numbered decision shows its number wherever its title shows: "DECISION 20: Which mods to build next" (Martin, +/// 2026-10-23: "i dont see decision number anywhere around this and i should"). The chat, the log and the widget +/// then name it the same way. +public enum WidgetDecisionTitle { + public static func numbered(_ title: String, number: Int?) -> String { + number.map { "DECISION \($0): " + title } ?? title + } +} + +public extension WidgetCard { + var numberedTitle: String { WidgetDecisionTitle.numbered(title, number: number) } +} + +public extension WidgetInboxItem { + var numberedTitle: String? { title.map { WidgetDecisionTitle.numbered($0, number: number) } } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift index bfeb413c40..385639c21f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift @@ -45,9 +45,16 @@ public extension EnvironmentValues { /// outline. The window itself changes size once per transition, so no frame of the motion re-lays out SwiftUI. private final class EdgePanelRootView: NSView { let maskLayer = CAShapeLayer() + /// The pointer entered or left the window, by geometry. SwiftUI's hover is a hit test, and it said "left" with the + /// pointer still on the panel when a scroll moved a part it does not count (the overlay scroller, an AppKit view) + /// under it, so the hover list closed under a scrolling pointer (Martin, 2026-10-24). + var pointerChanged: ((Bool) -> Void)? override init(frame frameRect: NSRect) { super.init(frame: frameRect) + // Follows the bounds by itself (`inVisibleRect`); `activeAlways`, because the panel never activates the app. + addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, userInfo: nil)) wantsLayer = true maskLayer.fillColor = NSColor.black.cgColor // A standalone layer animates every property change by default; only the explicit path animation may move it. @@ -59,6 +66,27 @@ private final class EdgePanelRootView: NSView { override var isFlipped: Bool { false } + override func mouseEntered(with event: NSEvent) { pointerChanged?(true) } + /// AppKit reports an exit when the window reshapes under a still pointer (5 of 17 such exits folded the preview, + /// app-perf.log 2026-10-25): an exit with the pointer still inside the window waits and checks again, and only a + /// pointer that really left reports false. Every ignored exit is logged. + override func mouseExited(with event: NSEvent) { + guard let window, window.frame.contains(NSEvent.mouseLocation) else { + pointerChanged?(false) + return + } + PerfLog.mark("edge.hover exit ignored: pointer still inside \(window.title)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + MainActor.assumeIsolated { + guard let self, let window = self.window else { return } + if !window.frame.contains(NSEvent.mouseLocation) { + PerfLog.mark("edge.hover left after a reshape: \(window.title)") + self.pointerChanged?(false) + } + } + } + } + var clipping = false { didSet { guard clipping != oldValue else { return } @@ -75,23 +103,31 @@ private final class EdgePanelRootView: NSView { /// Wakes once per display frame while a transition runs: it keeps the window shadow on the moving outline and records /// the callback timing. The motion itself runs in the render server and does not depend on these callbacks. -private final class EdgeFrameTicker: NSObject { +/// +/// A display link keeps a strong reference to its target, so a ticker nobody stops fires every frame for the life +/// of the process: an overwritten transition left one behind per lost reshape (2026-10-23, `EdgeFrameTicker.step` +/// in a sample of the collapsed widget). `live` counts the running ones for the tests. +final class EdgeFrameTicker: NSObject { + @MainActor static var live = 0 private var link: CADisplayLink? private let tick: () -> Void - init(view: NSView, tick: @escaping () -> Void) { + @MainActor init(view: NSView, tick: @escaping () -> Void) { self.tick = tick super.init() let link = view.displayLink(target: self, selector: #selector(step)) link.add(to: .main, forMode: .common) self.link = link + Self.live += 1 } @objc private func step(_ link: CADisplayLink) { tick() } - func stop() { - link?.invalidate() - link = nil + @MainActor func stop() { + guard let link else { return } + link.invalidate() + self.link = nil + Self.live -= 1 } } @@ -131,12 +167,18 @@ public final class EdgePanelController<Content: View> { private var sideCenterY: CGFloat? private var lastTarget: CGRect? private var lastPresentation: WidgetModulePresentation? - private var transition: Transition? + /// A transition that is replaced stops its ticker, whoever replaces it. + private var transition: Transition? { + didSet { + if let old = oldValue, old.token != transition?.token { old.ticker.stop() } + } + } private var generation = 0 public private(set) var isExpanded = false /// An expanded panel takes the keyboard (its composer). Off only for scripted motion checks on the user's own - /// screen, which must not take the keyboard from the app they are typing in. - public var becomesKeyOnExpand = true + /// screen, which must not take the keyboard from the app they are typing in. Off under XCTest: every expand in + /// a test run made the test process the key window, which took the user's focus about ten times per run. + public var becomesKeyOnExpand = NSClassFromString("XCTestCase") == nil /// Driver callback timing only; neither callback count nor gaps establish compositor frame delivery. public private(set) var lastTransitionTiming: AnimationTiming.Summary? /// Slows every transition by this factor (`GENESIS_WIDGET_MOTION_SCALE`), for frame-by-frame review. @@ -174,6 +216,10 @@ public final class EdgePanelController<Content: View> { panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = true + // A clear window that never set this passes its alpha-0 pixels to the window below. While the hover list grows + // and lays out, the point under a resting pointer is blank for a moment, so SwiftUI's hover and the root's + // tracking area both saw "left" and the list shrank and grew about once a second (2026-10-24 20:26). + panel.ignoresMouseEvents = false let hosting = NSHostingView(rootView: content()) // The controller owns the frame. Intrinsic sizing otherwise snaps an opening panel to the // content's full width between animation callbacks, moving its anchored edge off screen. @@ -191,6 +237,12 @@ public final class EdgePanelController<Content: View> { hosting.rootView = content() } + /// The pointer entered (true) or left (false) the panel's window. The host's "pointer on the panel" signal. + public var pointerChanged: ((Bool) -> Void)? { + get { root.pointerChanged } + set { root.pointerChanged = newValue } + } + public func setCompactSize(_ size: CGSize) { compactSize = size } public func setExpandedSize(_ size: CGSize) { expandedSize = size } public func setPreviewSize(_ size: CGSize) { previewSize = size } @@ -211,6 +263,7 @@ public final class EdgePanelController<Content: View> { public func hide() { lastTarget = nil + deferredPresentation = nil generation += 1 end(outcome: "interrupted") if let presentation = lastPresentation, motion.presentation != presentation { @@ -224,7 +277,29 @@ public final class EdgePanelController<Content: View> { setPresentation(expanded ? .expanded : .compact, reduceMotion: reduceMotion) } + /// Set while a request sets up its transition. Moving the window lays SwiftUI out at once, and content that + /// measures itself there (the widget's hover list) asks for its real size from inside that layout: the nested + /// request then started a reshape that the outer one overwrote with its own transition, and the panel stayed at + /// the estimate (2026-10-23 17:48, a reshape to 489 pt logged, the window kept 336 pt). + private var applyingPresentation = false + private var deferredPresentation: (presentation: WidgetModulePresentation, reduceMotion: Bool)? + public func setPresentation(_ presentation: WidgetModulePresentation, reduceMotion: Bool) { + if applyingPresentation { + deferredPresentation = (presentation, reduceMotion) + return + } + applyingPresentation = true + applyPresentation(presentation, reduceMotion: reduceMotion) + applyingPresentation = false + // The latest nested request runs against the transition that is now in place. + if let next = deferredPresentation { + deferredPresentation = nil + setPresentation(next.presentation, reduceMotion: next.reduceMotion) + } + } + + private func applyPresentation(_ presentation: WidgetModulePresentation, reduceMotion: Bool) { let expanded = presentation == .expanded let becameExpanded = expanded && !isExpanded let size: CGSize @@ -249,6 +324,9 @@ public final class EdgePanelController<Content: View> { let wasReshaping = transition?.reshape == true end(outcome: "interrupted") isExpanded = expanded + let branch = reduceMotion || !panel.isVisible || from == target ? "settle" + : motion.presentation == presentation && (!wasTransitioning || wasReshaping) ? "reshape" : "begin" + PerfLog.mark("edge.request window=\(panel.windowNumber) edge=\(placement.rawValue) shown=\(motion.presentation) to=\(presentation) \(branch) from=\(Int(from.width))x\(Int(from.height)) target=\(Int(target.width))x\(Int(target.height)) transitioning=\(wasTransitioning)") if reduceMotion || !panel.isVisible || from == target { settle(presentation, frame: target) } else if motion.presentation == presentation, !wasTransitioning || wasReshaping { diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationEngine.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationEngine.swift new file mode 100644 index 0000000000..e56b0d41ed --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationEngine.swift @@ -0,0 +1,322 @@ +import Foundation +import UserNotifications + +/// One macOS notification to post through `NotificationEngine`. +public struct EngineNotification { + /// Ledger source: `monitor.cache`, `disk`, `hang`, `tools`, ... Quiet rules match on it too. + public var source: String + public var title: String + public var subtitle: String + public var body: String + /// A fixed id replaces the card with that id in place. nil posts a fresh `<idPrefix>.<UUID>`. + public var identifier: String? + public var idPrefix: String + /// After the post, older delivered cards with the same key are removed, so one countdown or one + /// alert leaves one card. A fresh id plus a sweep, because re-using an id lets macOS update the old + /// card silently with no banner. + public var replaceKey: String? + public var category: String? + public var threadId: String? + public var userInfo: [String: Any] + public var sound: UNNotificationSound? + public var interruption: UNNotificationInterruptionLevel? + public var attachments: [UNNotificationAttachment] + /// Buttons for this card only. They are registered as a category named after the card's id, in a + /// union with every category already registered. + public var actions: [UNNotificationAction] + /// Extra ledger fields: the session behind a banner, the alert key, ... + public var ledgerFields: [String: Any] + + public init( + source: String, + title: String, + subtitle: String = "", + body: String, + identifier: String? = nil, + idPrefix: String = "genesis", + replaceKey: String? = nil, + category: String? = nil, + threadId: String? = nil, + userInfo: [String: Any] = [:], + sound: UNNotificationSound? = .default, + interruption: UNNotificationInterruptionLevel? = nil, + attachments: [UNNotificationAttachment] = [], + actions: [UNNotificationAction] = [], + ledgerFields: [String: Any] = [:] + ) { + self.source = source + self.title = title + self.subtitle = subtitle + self.body = body + self.identifier = identifier + self.idPrefix = idPrefix + self.replaceKey = replaceKey + self.category = category + self.threadId = threadId + self.userInfo = userInfo + self.sound = sound + self.interruption = interruption + self.attachments = attachments + self.actions = actions + self.ledgerFields = ledgerFields + } +} + +public enum NotificationPostResult: Equatable, Sendable { + case posted(id: String) + case suppressed(reason: String) + /// No notification center in this process (`swift test`, a raw binary outside its `.app`). + case unavailable +} + +/// A delivered card, as much of it as the engine needs. +public struct DeliveredNotification { + public var identifier: String + public var category: String + public var threadId: String + public var userInfo: [AnyHashable: Any] + + public init(identifier: String, category: String, threadId: String, userInfo: [AnyHashable: Any]) { + self.identifier = identifier + self.category = category + self.threadId = threadId + self.userInfo = userInfo + } +} + +/// The part of `UNUserNotificationCenter` the engine uses. Tests pass a recorder. +public protocol NotificationCenterPort: AnyObject { + func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void) + func delivered(_ completion: @escaping ([DeliveredNotification]) -> Void) + func removeDelivered(identifiers: [String]) + func categories(_ completion: @escaping (Set<UNNotificationCategory>) -> Void) + func setCategories(_ categories: Set<UNNotificationCategory>) +} + +final class SystemNotificationCenter: NotificationCenterPort { + static let shared = SystemNotificationCenter() + private var center: UNUserNotificationCenter { UNUserNotificationCenter.current() } + + func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void) { + center.add(request, withCompletionHandler: completion) + } + + func delivered(_ completion: @escaping ([DeliveredNotification]) -> Void) { + center.getDeliveredNotifications { notes in + completion(notes.map { + DeliveredNotification(identifier: $0.request.identifier, category: $0.request.content.categoryIdentifier, + threadId: $0.request.content.threadIdentifier, userInfo: $0.request.content.userInfo) + }) + } + } + + func removeDelivered(identifiers: [String]) { + center.removeDeliveredNotifications(withIdentifiers: identifiers) + } + + func categories(_ completion: @escaping (Set<UNNotificationCategory>) -> Void) { + center.getNotificationCategories(completionHandler: completion) + } + + func setCategories(_ categories: Set<UNNotificationCategory>) { + center.setNotificationCategories(categories) + } +} + +/// The one door to macOS Notification Center for Genesis.app, GenesisTools.app and GenesisKit (Martin, +/// 2026-10-23: "one GenesisKit NotificationEngine"). Every post goes through the same steps: quiet rules, +/// category registration as a union, the add, the replacement sweep, and a line in `NotificationLedger`. +/// Before it, four posting paths each did their own subset (two kept a ledger of nothing, one replaced +/// the whole category set, three had no replacement). +public final class NotificationEngine: @unchecked Sendable { + public static let shared = NotificationEngine() + + /// The userInfo key the replacement sweep reads. Posters keep their own keys beside it. + public static let replaceKeyInfoKey = "genesis.replaceKey" + + /// nil when this process must not touch Notification Center: `UNUserNotificationCenter.current()` + /// aborts outside a real `.app` (raw `swift run`, `swift test`). + public var centerProvider: () -> NotificationCenterPort? + /// Where ledger lines go; nil is the shared ledger. Tests point it at a temp file. + public var ledgerFile: String? + + private let lock = NSLock() + private var rules: [(name: String, rule: (EngineNotification) -> String?)] = [] + private var registered: [String: UNNotificationCategory] = [:] + + public init(centerProvider: @escaping () -> NotificationCenterPort? = NotificationEngine.systemCenterIfAvailable, + ledgerFile: String? = nil) { + self.centerProvider = centerProvider + self.ledgerFile = ledgerFile + } + + public static func systemCenterIfAvailable() -> NotificationCenterPort? { + guard NSClassFromString("XCTestCase") == nil, + Bundle.main.bundleIdentifier?.isEmpty == false, + Bundle.main.bundlePath.hasSuffix(".app") + else { return nil } + return SystemNotificationCenter.shared + } + + // MARK: - Quiet rules + + /// A rule returns a reason to hold the notification back, or nil. Rules run in the order added; the + /// first reason wins and is written to the ledger as the suppression reason. + public func addQuietRule(_ name: String, _ rule: @escaping (EngineNotification) -> String?) { + lock.lock() + defer { lock.unlock() } + rules.removeAll { $0.name == name } + rules.append((name, rule)) + } + + public func removeQuietRule(_ name: String) { + lock.lock() + defer { lock.unlock() } + rules.removeAll { $0.name == name } + } + + func suppression(for note: EngineNotification) -> String? { + lock.lock() + let current = rules + lock.unlock() + for entry in current { + if let reason = entry.rule(note) { return "\(entry.name): \(reason)" } + } + return nil + } + + // MARK: - Categories + + /// Adds categories to whatever is registered, never replacing the set: `setNotificationCategories` + /// replaces it whole, so one feature registering `[mine]` used to wipe every other card's buttons. + public func register(categories: [UNNotificationCategory], then: (() -> Void)? = nil) { + guard let center = centerProvider() else { + then?() + return + } + lock.lock() + for category in categories { registered[category.identifier] = category } + lock.unlock() + center.categories { [weak self] existing in + guard let self else { return } + self.lock.lock() + let mine = self.registered + self.lock.unlock() + var next = existing.filter { mine[$0.identifier] == nil } + next.formUnion(mine.values) + center.setCategories(next) + then?() + } + } + + // MARK: - Posting + + /// `completion` runs once macOS answered the add, with its error. It does not run for a suppressed or + /// unavailable result: nothing was added, and the return value already says so. + @discardableResult + public func post(_ note: EngineNotification, completion: ((Error?) -> Void)? = nil) -> NotificationPostResult { + if let reason = suppression(for: note) { + record(note, outcome: .suppressed, reason: reason, id: nil) + return .suppressed(reason: reason) + } + guard let center = centerProvider() else { return .unavailable } + let identifier = note.identifier ?? "\(note.idPrefix).\(UUID().uuidString)" + let request = UNNotificationRequest(identifier: identifier, content: content(note, identifier: identifier), + trigger: nil) + let add = { [weak self] in + center.add(request) { error in + if let error { + self?.record(note, outcome: .failed, reason: error.localizedDescription, id: identifier) + } else if let key = note.replaceKey { + self?.sweep(center: center, replaceKey: key, keep: identifier) + } + completion?(error) + } + } + record(note, outcome: .posted, reason: nil, id: identifier) + if note.actions.isEmpty { + add() + } else { + register(categories: [UNNotificationCategory(identifier: identifier, actions: note.actions, + intentIdentifiers: [], options: [])], then: add) + } + return .posted(id: identifier) + } + + func content(_ note: EngineNotification, identifier: String) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.title = note.title + if !note.subtitle.isEmpty { content.subtitle = note.subtitle } + content.body = note.body + content.sound = note.sound + if let interruption = note.interruption { content.interruptionLevel = interruption } + if !note.actions.isEmpty { + content.categoryIdentifier = identifier + } else if let category = note.category { + content.categoryIdentifier = category + } + if let thread = note.threadId ?? note.replaceKey { content.threadIdentifier = thread } + var info = note.userInfo + if let key = note.replaceKey { info[Self.replaceKeyInfoKey] = key } + content.userInfo = info + content.attachments = note.attachments + return content + } + + /// Older cards with the same replace key, never the one just posted (the delivered list is read + /// asynchronously and may already hold it). + static func stale(_ delivered: [DeliveredNotification], replaceKey: String, keep: String) -> [String] { + delivered + .filter { $0.identifier != keep && $0.userInfo[replaceKeyInfoKey] as? String == replaceKey } + .map(\.identifier) + } + + private func sweep(center: NotificationCenterPort, replaceKey: String, keep: String) { + center.delivered { delivered in + let stale = Self.stale(delivered, replaceKey: replaceKey, keep: keep) + if !stale.isEmpty { center.removeDelivered(identifiers: stale) } + } + } + + // MARK: - Withdrawing and counting + + /// Removes delivered cards that match, and answers with their ids. + public func withdraw(where match: @escaping (DeliveredNotification) -> Bool, + completion: (([String]) -> Void)? = nil) { + guard let center = centerProvider() else { + completion?([]) + return + } + center.delivered { delivered in + let ids = delivered.filter(match).map(\.identifier) + if !ids.isEmpty { center.removeDelivered(identifiers: ids) } + completion?(ids) + } + } + + public func withdraw(replaceKey: String, completion: (([String]) -> Void)? = nil) { + withdraw(where: { $0.userInfo[Self.replaceKeyInfoKey] as? String == replaceKey }, completion: completion) + } + + /// Delivered cards that match, without removing them. + public func delivered(where match: @escaping (DeliveredNotification) -> Bool, + completion: @escaping ([String]) -> Void) { + guard let center = centerProvider() else { + completion([]) + return + } + center.delivered { completion($0.filter(match).map(\.identifier)) } + } + + // MARK: - Ledger + + private func record(_ note: EngineNotification, outcome: NotificationLedger.Outcome, reason: String?, id: String?) { + var fields = note.ledgerFields + if let id { fields["id"] = id } + if let key = note.replaceKey { fields["replaceKey"] = key } + if let category = note.category { fields["category"] = category } + NotificationLedger.record(source: note.source, channel: "macos", outcome: outcome, title: note.title, + subtitle: note.subtitle.isEmpty ? nil : note.subtitle, body: note.body, + reason: reason, fields: fields, file: ledgerFile) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift new file mode 100644 index 0000000000..441f03e98c --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Notifications/NotificationLedger.swift @@ -0,0 +1,87 @@ +import Foundation + +/// The one record of every notification a GenesisTools or Genesis surface sent, or decided not to send: +/// `~/.genesis-tools/notifications/ledger.jsonl` (under `GENESIS_TOOLS_HOME` when set), one JSON object per line. +/// The TypeScript side writes the same file with the same fields (`src/utils/notifications/ledger.ts`), so "what +/// pinged me in the last 40 hours, and why" is one read (Martin, 2026-10-23). Before this, Genesis.app's cache +/// banners were logged nowhere, and the macOS unified log kept about 14 hours of them without titles. +/// +/// Fields every line has: `at`, `source` (`monitor.cache`, `disk`, `hang`, ...), `channel` (`macos`, `in-app`), +/// `outcome` (`posted`, `failed`, `suppressed`, `withdrawn`), `writer`. A suppressed line names its `reason`. +public enum NotificationLedger { + public enum Outcome: String, Sendable { + case posted + case failed + case suppressed + /// A delivered card was taken away on purpose (a countdown's card once its session went cold). + case withdrawn + } + + public static var path: String { + let override = ProcessInfo.processInfo.environment["GENESIS_TOOLS_HOME"] + let home = override?.isEmpty == false ? override! : NSHomeDirectory() + return (home as NSString).appendingPathComponent(".genesis-tools/notifications/ledger.jsonl") + } + + /// Past this size the file moves to `ledger.jsonl.1` (replacing the older one) and a new file starts. + public static let maxBytes: Int64 = 20 * 1024 * 1024 + + /// Writes happen off the caller's thread, in order. A poster on the main actor never waits on the disk. + private static let queue = DispatchQueue(label: "genesis.notification-ledger", qos: .utility) + + /// Appends one line. Values that are not JSON are dropped and logged, never thrown: a ledger write must not + /// stop a notification. + public static func record( + source: String, + channel: String, + outcome: Outcome = .posted, + title: String? = nil, + subtitle: String? = nil, + body: String? = nil, + reason: String? = nil, + fields: [String: Any] = [:], + file: String? = nil + ) { + var entry = fields.filter { JSONSerialization.isValidJSONObject(["value": $0.value]) } + if entry.count != fields.count { + KitHost.log("notification ledger \(source): dropped non-JSON fields \(Set(fields.keys).subtracting(entry.keys).sorted())") + } + entry["at"] = ISO8601DateFormatter().string(from: Date()) + entry["source"] = source + entry["channel"] = channel + entry["outcome"] = outcome.rawValue + if let title { entry["title"] = title } + if let subtitle { entry["subtitle"] = subtitle } + if let body { entry["body"] = body } + if let reason { entry["reason"] = reason } + entry["writer"] = [ + "app": Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName, + "pid": Int(getpid()), + ] + guard let data = try? JSONSerialization.data(withJSONObject: entry, options: [.sortedKeys]) else { + KitHost.log("notification ledger \(source): the entry is not JSON") + return + } + // A test run must not write into the user's real ledger; a test that checks the ledger passes `file`. + if file == nil, NSClassFromString("XCTestCase") != nil { return } + let target = file ?? path + queue.async { + rotateIfFull(target) + GateAudit.appendLine(data, to: target) + } + } + + /// Waits for the lines queued so far. Tests only: posters never wait. + public static func flush() { + queue.sync {} + } + + private static func rotateIfFull(_ file: String) { + guard let size = (try? FileManager.default.attributesOfItem(atPath: file)[.size] as? NSNumber)?.int64Value, + size > maxBytes + else { return } + let older = file + ".1" + try? FileManager.default.removeItem(atPath: older) + try? FileManager.default.moveItem(atPath: file, toPath: older) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift index 1bc1ae036c..6533e13968 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift @@ -11,6 +11,10 @@ // A block on the main queue only runs late when the main thread is actually // busy or starved. // +// A loop that returns to the run loop between its passes never delays the ping, so the same watchdog also +// reads the main thread's own CPU time: busy above 85% for 5 s is a "main-spin", sampled the same way (the +// widget sat at 100% CPU for three minutes in a SwiftUI update loop and logged nothing, 2026-10-23 17:10). +// // Stalls > 0.5 s land in PerfLog (the app's perf file + unified log). // When one crosses 1 s the watchdog runs `sample` on this process and writes // the main-thread call graph to `<log folder>/hangs/<time>.txt`, so the @@ -35,6 +39,16 @@ public enum HangWatch { private nonisolated(unsafe) static var alertedThisStall = false /// Set by the test that must not raise a real user notification. public nonisolated(unsafe) static var onWedge: ((CFAbsoluteTime) -> Void)? + /// Runs on the main thread when a main-spin begins (5 s above 85% CPU), after it is logged and sampled. A face can + /// take down the view that spins: the widget collapses its open panel (2026-10-23, an inbox list that relaid itself + /// out on every run-loop pass for minutes). The main queue still runs between the passes of such a loop. + public nonisolated(unsafe) static var onSpin: (@MainActor @Sendable (CFAbsoluteTime) -> Void)? + + /// The main thread's port, read from the watchdog's queue for its CPU time. + private nonisolated(unsafe) static var mainThread: thread_act_t = 0 + /// Timer queue only (the timer's handler runs serially). + private nonisolated(unsafe) static var spin = SpinTracker() + private nonisolated(unsafe) static var lastCPU: (wall: CFAbsoluteTime, cpu: Double)? public static let stallThreshold: CFAbsoluteTime = 0.5 public static let sampleThreshold: CFAbsoluteTime = 1.0 @@ -84,7 +98,11 @@ public enum HangWatch { // An explicit leeway: the default one lets App Nap push a tick of a background window by // ~0.5 s, and a 843 ms stall was first seen 2 ms before it ended (GenesisTools hub). timer.schedule(deadline: .now() + 1, repeating: 0.25, leeway: .milliseconds(10)) - timer.setEventHandler { tick() } + mainThread = mach_thread_self() + timer.setEventHandler { + checkSpin() + tick() + } timer.resume() monitor = timer // `sample` attaches about a second in, so every Genesis capture on 2026-09-26 said @@ -151,7 +169,7 @@ public enum HangWatch { } lock.unlock() - if shouldSample { captureStack(stallSoFar: gap) } + if shouldSample { captureStack(stallSoFar: gap, reason: "main-stall") } if shouldLog { PerfLog.mark(String(format: "main-stall ongoing %.1fs load=%.1f", gap, PerfLog.loadAverage()) + PerfContext.describe()) } if shouldAlert { reportWedge(stallSoFar: gap) } } @@ -195,18 +213,18 @@ public enum HangWatch { // the test runner has none and no notification center to abort in. let config = PerfConfiguration.current guard let prefix = config.alertBundlePrefix, Bundle.main.bundleIdentifier?.hasPrefix(prefix) == true else { return } - let content = UNMutableNotificationContent() - content.title = "\(config.appName) is not responding" - content.body = String( - format: "The main thread has been blocked for %.0f seconds. %@", - stallSoFar, - lastCaptureFile.map { "Stack: \(($0.path as NSString).abbreviatingWithTildeInPath)" } - ?? "No stack was captured." - ) - content.sound = .default - UNUserNotificationCenter.current().add( - UNNotificationRequest(identifier: "genesis.hang.\(UUID().uuidString)", content: content, trigger: nil) - ) + NotificationEngine.shared.post(EngineNotification( + source: "hang", + title: "\(config.appName) is not responding", + body: String( + format: "The main thread has been blocked for %.0f seconds. %@", + stallSoFar, + lastCaptureFile.map { "Stack: \(($0.path as NSString).abbreviatingWithTildeInPath)" } + ?? "No stack was captured." + ), + idPrefix: "genesis.hang", + ledgerFields: ["stallSeconds": Int(stallSoFar)] + )) } /// Test seam: the reporter without a 20 s real stall in front of it. @@ -216,12 +234,12 @@ public enum HangWatch { /// `sample <pid> 2` from a utility thread. Only the main thread's call /// graph is kept; the rest of a full sample is idle worker threads. - private static func captureStack(stallSoFar: CFAbsoluteTime) { + private static func captureStack(stallSoFar: CFAbsoluteTime, reason: String) { let stamp = Self.fileStamp.string(from: Date()) let file = hangsDirectory.appendingPathComponent("hang-\(stamp).txt") lastCaptureFile = file try? FileManager.default.createDirectory(at: hangsDirectory, withIntermediateDirectories: true) - PerfLog.mark(String(format: "main-stall %.1fs so far — sampling to %@", stallSoFar, file.lastPathComponent) + PerfContext.describe()) + PerfLog.mark(String(format: "%@ %.1fs so far — sampling to %@", reason, stallSoFar, file.lastPathComponent) + PerfContext.describe()) // The ping this stall belongs to. `sample` attaches ~1 s after the // stall was noticed and then samples for 2 s, so a 1.2 s stall is over // before the first sample lands and the capture shows an idle main @@ -282,6 +300,94 @@ public enum HangWatch { try? trimmed.write(to: file, atomically: true, encoding: .utf8) } + // MARK: Spin + + /// Busy and quiet stretches of the main thread, one tick at a time. Pure, so a test can feed it shares. + public struct SpinTracker { + public enum Event: Equatable { + case began(seconds: CFAbsoluteTime) + case ended(seconds: CFAbsoluteTime) + } + + public static let threshold: CFAbsoluteTime = 5 + public static let busyShare = 0.85 + private var since: CFAbsoluteTime? + private var reported = false + + public init() {} + + /// `share` is the main thread's CPU over the last tick (1 = a whole core). + public mutating func step(share: Double, now: CFAbsoluteTime) -> Event? { + guard share >= Self.busyShare else { + defer { + since = nil + reported = false + } + guard let since, reported else { return nil } + return .ended(seconds: now - since) + } + let start = since ?? now + since = start + guard !reported, now - start >= Self.threshold else { return nil } + reported = true + return .began(seconds: now - start) + } + } + + /// A thread's user and system CPU time in seconds, or nil when the kernel refuses. + static func cpuTime(of thread: thread_act_t) -> Double? { + guard thread != 0 else { return nil } + var info = thread_basic_info() + var count = mach_msg_type_number_t(MemoryLayout<thread_basic_info_data_t>.size / MemoryLayout<integer_t>.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + thread_info(thread, thread_flavor_t(THREAD_BASIC_INFO), $0, &count) + } + } + guard result == KERN_SUCCESS else { return nil } + return Double(info.user_time.seconds) + Double(info.user_time.microseconds) / 1_000_000 + + Double(info.system_time.seconds) + Double(info.system_time.microseconds) / 1_000_000 + } + + private static func checkSpin() { + let now = CFAbsoluteTimeGetCurrent() + guard let cpu = cpuTime(of: mainThread) else { return } + defer { lastCPU = (now, cpu) } + guard let last = lastCPU, now - last.wall > 0.05 else { return } + guard now - last.wall < sleepGap else { + // A sleep or a long stall: a busy streak is not continuous across it, so it must not count as spin time. + spin = SpinTracker() + return + } + let share = (cpu - last.cpu) / (now - last.wall) + switch spin.step(share: share, now: now) { + case let .began(seconds)?: + PerfLog.mark(String(format: "main-spin %.0fs at %.0f%% cpu: the main thread never idles", seconds, share * 100) + + PerfContext.describe()) + lock.lock() + let sample = now - lastSampleAt > sampleCooldown && PerfContext.run == nil + if sample { lastSampleAt = now } + lock.unlock() + if sample { captureStack(stallSoFar: seconds, reason: "main-spin") } + // Which bodies the loop evaluates, for two seconds: one Bool read per body while off. + let wasOn = RenderProbe.enabled + let start = RenderProbe.snapshot() + RenderProbe.enabled = true + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2) { + PerfLog.mark("main-spin bodies in 2s:" + RenderProbe.summary(since: start)) + if !wasOn { RenderProbe.enabled = false } + // After the bodies are counted, so the recovery does not hide what the loop evaluated. + if let onSpin { + DispatchQueue.main.async { MainActor.assumeIsolated { onSpin(seconds + 2) } } + } + } + case let .ended(seconds)?: + PerfLog.mark(String(format: "main-spin ended after %.0fs", seconds) + PerfContext.describe()) + case nil: + break + } + } + private static let fileStamp: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd-HHmmss" diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/SpawnMeter.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/SpawnMeter.swift new file mode 100644 index 0000000000..458010ed84 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/SpawnMeter.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Counts the child processes this app starts and writes a `spawn.storm` line to the perf log when they come in a +/// burst, so `tools hub dev monitor` reports it. A burst that hurts the Mac can be invisible otherwise: Genesis +/// Markdown started about 9,300 `tools agents blame` an hour, 51 to 66 at once (2026-10-24, load average 380-580, +/// launchservicesd at 69-159%), and its log held one line per blame only when a single run was slow. +public enum SpawnMeter { + private static let lock = NSLock() + nonisolated(unsafe) private static var storm = SpawnStorm() + + /// Records that a child started; call the returned closure once when it has ended. + public static func started(executable: URL?, arguments: [String]) -> @Sendable () -> Void { + let label = label(executable: executable, arguments: arguments) + let lines = lock.withLock { storm.start(label, at: CFAbsoluteTimeGetCurrent()) } + lines.forEach { PerfLog.mark($0) } + return { + let lines = lock.withLock { storm.finish(at: CFAbsoluteTimeGetCurrent()) } + lines.forEach { PerfLog.mark($0) } + } + } + + /// What a child is, in two or three words: `git status`, `GenesisTools agents blame`. Paths and options are left out, + /// except that a launcher argument `…/gt-agents` names the tool (`agents`). + public static func label(executable: URL?, arguments: [String]) -> String { + var words = [executable?.lastPathComponent ?? "?"] + var skipValue = false + for argument in arguments where words.count < 3 { + if skipValue { + skipValue = false + continue + } + if argument == "-C" || argument == "-c" || argument == "--git-dir" { + skipValue = true + continue + } + let name = (argument as NSString).lastPathComponent + if argument.contains("/"), name.hasPrefix("gt-") { + words.append(String(name.dropFirst(3))) + } else if !argument.contains("/"), !argument.hasPrefix("-"), !argument.isEmpty, !isRevision(argument) { + words.append(argument) + } + } + return words.joined(separator: " ") + } + + /// A commit id or a revision built on one (`96c986d3^{commit}`, `HEAD~2`), which would split one command's count. + private static func isRevision(_ argument: String) -> Bool { + let base = argument.prefix { $0 != "^" && $0 != "~" && $0 != ":" } + return base.count >= 7 && base.allSatisfy(\.isHexDigit) || base == "HEAD" && base.count < argument.count + } +} + +/// The burst rule, apart from the clock and the log: `burstStarts` starts within `window` seconds, or `burstRunning` +/// children at once. A burst is reported when it starts, every `reportEvery` seconds while it lasts, and when it ends: +/// at the first start or finish that finds it quiet, timed to its last start, so quiet minutes are not counted. +/// The hub opening a pull request starts about 24 in 10 s with 5 running (2026-10-24 19:45), which is normal; the blame +/// waves had 51 to 66 running. +public struct SpawnStorm { + public static let burstStarts = 40 + public static let burstRunning = 16 + public static let window: TimeInterval = 10 + public static let reportEvery: TimeInterval = 30 + + private var starts: [(at: Double, label: String)] = [] + private var running = 0 + private var since: Double? + private var lastStart = 0.0 + private var lastReport = -Double.infinity + private var counts: [String: Int] = [:] + private var total = 0 + + public init() {} + + public mutating func start(_ label: String, at now: Double) -> [String] { + prune(now) + var lines = endIfQuiet().map { [$0] } ?? [] + running += 1 + starts.append((now, label)) + if let since { + total += 1 + counts[label, default: 0] += 1 + lastStart = now + guard now - lastReport >= Self.reportEvery else { return lines } + lastReport = now + lines.append("spawn.storm ongoing \(Int(now - since)) s: \(total) child processes started, \(running) running; \(top)") + return lines + } + guard starts.count >= Self.burstStarts || running >= Self.burstRunning else { return lines } + since = now + lastStart = now + lastReport = now + total = starts.count + counts = starts.reduce(into: [:]) { $0[$1.label, default: 0] += 1 } + lines.append("spawn.storm \(starts.count) child processes started in \(Int(Self.window)) s, \(running) running; \(top)") + return lines + } + + public mutating func finish(at now: Double) -> [String] { + running = max(0, running - 1) + prune(now) + return endIfQuiet().map { [$0] } ?? [] + } + + private mutating func endIfQuiet() -> String? { + guard let since, running < Self.burstRunning / 2, starts.count < Self.burstStarts / 2 else { return nil } + self.since = nil + defer { counts = [:]; total = 0 } + return "spawn.storm over after \(Int(lastStart - since)) s: \(total) child processes started; \(top)" + } + + private mutating func prune(_ now: Double) { + starts.removeAll { now - $0.at > Self.window } + } + + /// The commonest children first: `GenesisTools agents blame ×51, git status ×3`. + private var top: String { + counts.sorted { $0.value == $1.value ? $0.key < $1.key : $0.value > $1.value }.prefix(3) + .map { "\($0.key) ×\($0.value)" }.joined(separator: ", ") + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift new file mode 100644 index 0000000000..9ca3f5261b --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift @@ -0,0 +1,231 @@ +import AppKit +import UserNotifications + +/// How a waiting approval request (the vault key, an AI account) gets the user's attention. The panel never takes the +/// keyboard by itself, so a stray Return cannot answer it; without these cues a request on another screen or Space +/// waited out its deadline unseen. Both cues are settings of the host app (GenesisTools Settings, "Approval +/// requests"); the values here are the defaults. +public enum ApprovalAttention { + /// The sound a request plays when it appears: a name from /System/Library/Sounds, or "none". + public static let soundKey = "approvals.sound" + /// Its own sound, so it is not taken for the widget's inbox chime (Glass); Purr is soft and under 1 s. + public static let defaultSound = "Purr" + public static let sounds = [ + "Purr", "Bottle", "Tink", "Frog", "Morse", "Basso", "Glass", "Ping", "Pop", "Submarine", "Hero", "Funk", "Blow", + "Sosumi", + ] + + /// Seconds a request waits before a notification follows it; 0 posts it at once, -1 never. + public static let notifyAfterKey = "approvals.notifyAfterSeconds" + public static let defaultNotifyAfter = 15 + public static let notifyAfterChoices = [ + ApprovalNotifyChoice(seconds: 0, title: "At once"), + ApprovalNotifyChoice(seconds: 15, title: "After 15 seconds"), + ApprovalNotifyChoice(seconds: 30, title: "After 30 seconds"), + ApprovalNotifyChoice(seconds: 60, title: "After 1 minute"), + ApprovalNotifyChoice(seconds: -1, title: "Never"), + ] + + public static var sound: String { + UserDefaults.standard.string(forKey: soundKey) ?? defaultSound + } + + public static var notifyAfter: Int { + UserDefaults.standard.object(forKey: notifyAfterKey) as? Int ?? defaultNotifyAfter + } + + /// When the reminder for a request that appears now would be posted; nil when it would not be posted at all: the + /// setting says never, or the request denies itself within 5 s of it, when the card could only mislead. + public static func reminderDelay(notifyAfter: Int, deadline: Date?, now: Date = Date()) -> TimeInterval? { + guard notifyAfter >= 0 else { return nil } + let delay = TimeInterval(notifyAfter) + if let deadline, delay > deadline.timeIntervalSince(now) - 5 { return nil } + return delay + } + + /// Plays `name` once; "none" or an unknown name is silent. `NSSound.play` waits for the output device (about + /// 0.8 s on the main thread, measured for the widget's chime), so it plays from a background queue. + public static func play(_ name: String) { + guard name != "none" else { return } + queue.async { + guard let player = NSSound(named: NSSound.Name(name)) else { + KitHost.log("approval unknown sound \(name)") + return + } + player.play() + playing.append(player) + if playing.count > 2 { playing.removeFirst() } + } + } + + private static let queue = DispatchQueue(label: "genesiskit.approval.sound", qos: .userInitiated) + /// Keeps the last players alive until they finish; a released NSSound stops at once. + private nonisolated(unsafe) static var playing: [NSSound] = [] +} + +public struct ApprovalNotifyChoice: Identifiable, Hashable, Sendable { + public let seconds: Int + public let title: String + + public var id: Int { seconds } +} + +/// One request's cues: the sound and the VoiceOver line when it appears, the notification while it still waits, and +/// the cleanup when it is answered or its deadline passes. +@MainActor +final class ApprovalReminder { + private let model: ApprovalDialogModel + private let onClick: () -> Void + private let token = UUID().uuidString + private var pending: DispatchWorkItem? + private var expiry: DispatchWorkItem? + private var postedID: String? + private var stopped = false + + init(model: ApprovalDialogModel, onClick: @escaping () -> Void) { + self.model = model + self.onClick = onClick + } + + func start() { + let sound = ApprovalAttention.sound + ApprovalAttention.play(sound) + GenAnnounce.post("Approval needed. \(model.title)", priority: .high) + let delay = ApprovalAttention.reminderDelay(notifyAfter: ApprovalAttention.notifyAfter, deadline: model.deadline) + KitHost.log("approval \(model.identifier) shown sound=\(sound) reminder=\(delay.map { "\(Int($0))s" } ?? "off")") + guard let delay else { return } + + let work = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { self?.post() } + } + pending = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + + // The card never outlives the request: the caller's own deadline ends its process right after this. + if let deadline = model.deadline { + let end = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { self?.stop() } + } + expiry = end + DispatchQueue.main.asyncAfter(deadline: .now() + max(0, deadline.timeIntervalSinceNow - 0.5), execute: end) + } + } + + /// nil where Notification Center must not be touched: under `swift test` the bundle is not an app, and + /// `UNUserNotificationCenter.current()` raises there and ends the process. + private static var center: UNUserNotificationCenter? { + guard NSClassFromString("XCTestCase") == nil, Bundle.main.bundlePath.hasSuffix(".app") else { return nil } + return UNUserNotificationCenter.current() + } + + private func post() { + pending = nil + guard !model.answered, let center = Self.center else { return } + // A click on the card must reach this process's panel, and must not reach the host's own click handler, + // which ends a process it finds without a route. + ApprovalNotificationRelay.register(token: token, center: center, onClick: onClick) + + let content = UNMutableNotificationContent() + content.title = model.title + content.subtitle = "Waiting for your answer" + content.body = [model.purpose, model.command].compactMap { $0 }.joined(separator: " ") + content.sound = ApprovalAttention.sound == "none" ? nil : UNNotificationSound.default + content.threadIdentifier = "approval.\(model.identifier)" + content.userInfo = [ApprovalNotificationRelay.tokenKey: token] + let id = "approval.\(model.identifier).\(token)" + // No authorization prompt here: a prompt in the middle of an approval is the wrong moment, and the first grant + // is `tools notify authorize` (macos.md). Without it the add fails, the log says so, and the panel still waits. + let identifier = model.identifier + center.add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { [weak self] error in + if let error { + KitHost.log("approval reminder failed id=\(id): \(error.localizedDescription)") + return + } + DispatchQueue.main.async { + MainActor.assumeIsolated { + // The add is asynchronous: a request answered while it ran must not leave its card behind. + guard let self, !self.stopped else { + center.removeDeliveredNotifications(withIdentifiers: [id]) + return + } + self.postedID = id + KitHost.log("approval \(identifier) reminder posted id=\(id)") + } + } + } + } + + /// Cancels a reminder not posted yet, takes a posted one out of Notification Center, and gives the click handling + /// back to the host. Safe to call twice. + func stop() { + stopped = true + pending?.cancel() + pending = nil + expiry?.cancel() + expiry = nil + guard let center = Self.center else { return } + if let id = postedID { + center.removePendingNotificationRequests(withIdentifiers: [id]) + center.removeDeliveredNotifications(withIdentifiers: [id]) + postedID = nil + } + ApprovalNotificationRelay.unregister(token: token, center: center) + } +} + +/// Takes a click on an approval reminder and brings that request's panel forward; every other notification goes on +/// to the delegate that was in place before (the host's), so its routes and replies still run. One relay serves every +/// reminder of the process, and the host's delegate comes back when the last reminder stops. +final class ApprovalNotificationRelay: NSObject, UNUserNotificationCenterDelegate { + static let tokenKey = "genesis.approval" + + private static var shared: ApprovalNotificationRelay? + /// The click handler of each reminder still waiting, by its token. Read and written on the main thread only. + private static var handlers: [String: () -> Void] = [:] + + /// Strong: the notification center holds its delegate weakly, and the host's must still be there on restore. + private let previous: UNUserNotificationCenterDelegate? + + private init(previous: UNUserNotificationCenterDelegate?) { + self.previous = previous + } + + static func register(token: String, center: UNUserNotificationCenter, onClick: @escaping () -> Void) { + handlers[token] = onClick + guard shared == nil else { return } + let relay = ApprovalNotificationRelay(previous: center.delegate) + shared = relay + center.delegate = relay + } + + static func unregister(token: String, center: UNUserNotificationCenter) { + handlers[token] = nil + guard handlers.isEmpty, let relay = shared else { return } + if center.delegate === relay { center.delegate = relay.previous } + shared = nil + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + if previous?.userNotificationCenter?(center, willPresent: notification, withCompletionHandler: completionHandler) == nil { + completionHandler([.banner, .sound]) + } + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + guard let token = response.notification.request.content.userInfo[Self.tokenKey] as? String else { + if previous?.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler) == nil { + completionHandler() + } + return + } + + KitHost.log("approval reminder clicked id=\(response.notification.request.identifier)") + DispatchQueue.main.async { + // A reminder that already stopped has no handler: its panel is gone, so the click does nothing. + Self.handlers[token]?() + completionHandler() + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalCommandText.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalCommandText.swift new file mode 100644 index 0000000000..f1538101d3 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalCommandText.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// A command line in an approval window, set like a shell code block: monospaced, wrapped, selectable, with the program +/// and its subcommands in the text color, flags in blue, their values in orange and other arguments dimmed (Martin, +/// 2026-10-23: "make the "tools ...." show in some nice "format" as you would with the markdown view"). +public struct ApprovalCommandText: View { + public let command: String + + public init(command: String) { + self.command = command + } + + public var body: some View { + Text(Self.styled(command)) + .font(.system(size: 11.5, design: .monospaced)) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .padding(.horizontal, 9).padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.07), in: RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(Color.primary.opacity(0.09))) + .accessibilityLabel("Command: \(command)") + } + + public enum Role: Equatable { + case program, flag, value, argument + } + + /// The words of a command line with their roles. A quoted word keeps its quotes and stays one word. + public static func words(_ command: String) -> [(text: String, role: Role)] { + var words: [String] = [] + var current = "" + var quoted = false + for character in command { + if character == "\"" { quoted.toggle() } + if character == " ", !quoted { + if !current.isEmpty { words.append(current) } + current = "" + } else { + current.append(character) + } + } + if !current.isEmpty { words.append(current) } + + var result: [(String, Role)] = [] + var inProgram = true + var afterFlag = false + for word in words { + let role: Role + if word.hasPrefix("-") { + role = .flag + inProgram = false + } else if afterFlag { + role = .value + } else if inProgram, word.range(of: "^[a-z][a-z0-9-]*$", options: .regularExpression) != nil { + role = .program + } else { + role = .argument + inProgram = false + } + afterFlag = role == .flag && !word.contains("=") + result.append((word, role)) + } + return result + } + + static func styled(_ command: String) -> AttributedString { + var text = AttributedString() + var cursor = command.startIndex + for word in words(command) { + // Every word is a substring of the command and only spaces lie between them: keep those spaces as typed. + guard let range = command.range(of: word.text, range: cursor..<command.endIndex) else { continue } + text += AttributedString(String(command[cursor..<range.lowerBound])) + cursor = range.upperBound + var part = AttributedString(word.text) + switch word.role { + case .program: + part.foregroundColor = .primary + part.font = .system(size: 11.5, weight: .semibold, design: .monospaced) + case .flag: part.foregroundColor = Color(nsColor: .systemBlue) + case .value: part.foregroundColor = Color(nsColor: .systemOrange) + case .argument: part.foregroundColor = .secondary + } + text += part + } + text += AttributedString(String(command[cursor...])) + return text + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift index 7476898406..11c04863c7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift @@ -37,6 +37,8 @@ public struct ApprovalRemember: Identifiable, Hashable { public final class ApprovalDialogModel: ObservableObject { public let title: String public let purpose: String + /// The command line the request runs, shown as a code block under the purpose. + public let command: String? public let symbol: String public let facts: [ApprovalFact] public let details: [ApprovalFact] @@ -55,11 +57,12 @@ public final class ApprovalDialogModel: ObservableObject { public var onAllow: @MainActor (ApprovalRemember?) -> Void = { _ in } public var onDeny: @MainActor () -> Void = {} - public init(title: String, purpose: String, symbol: String, facts: [ApprovalFact], details: [ApprovalFact], + public init(title: String, purpose: String, command: String? = nil, symbol: String, facts: [ApprovalFact], details: [ApprovalFact], warning: String?, footnote: String, rememberChoices: [ApprovalRemember], rememberNote: String?, allowTitle: String, deadline: Date?, identifier: String) { self.title = title self.purpose = purpose + self.command = command self.symbol = symbol self.facts = facts self.details = details @@ -106,6 +109,9 @@ public struct ApprovalDialogView: View { .font(.system(size: 12)) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) + if let command = model.command { + ApprovalCommandText(command: command).padding(.top, 3) + } } .frame(maxWidth: .infinity, alignment: .leading) } @@ -248,11 +254,14 @@ public struct ApprovalDialogView: View { /// One floating panel for one request. It never runs a modal session (a modal loop on the main queue stops every /// timer queued there, so a deadline never fired), never activates its app, and takes the keyboard only after the -/// user clicks into it: a request that pops up while someone types must not catch a stray Return. +/// user clicks into it: a request that pops up while someone types must not catch a stray Return. So that it is not +/// missed, it plays a sound and speaks to VoiceOver when it appears, and a notification follows while it waits +/// (`ApprovalAttention`, both settings). @MainActor public final class ApprovalPanelPresenter: NSObject, NSWindowDelegate { private var panel: NSPanel? private var model: ApprovalDialogModel? + private var reminder: ApprovalReminder? public override init() {} @@ -286,14 +295,27 @@ public final class ApprovalPanelPresenter: NSObject, NSWindowDelegate { self.panel = panel self.model = model panel.orderFrontRegardless() + let reminder = ApprovalReminder(model: model) { [weak self] in self?.bringForward() } + self.reminder = reminder + reminder.start() } public func close() { + reminder?.stop() + reminder = nil panel?.delegate = nil panel?.orderOut(nil) panel = nil } + /// A click on the reminder: the panel moves to the screen under the pointer and comes to the front, still without + /// taking the keyboard. + private func bringForward() { + guard let panel, model?.answered == false else { return } + panel.setFrameOrigin(Self.origin(for: panel.frame.size)) + panel.orderFrontRegardless() + } + /// ⌘W answers the way Deny does. public func windowShouldClose(_ sender: NSWindow) -> Bool { model?.deny() diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift index 5026d23825..795bc73a52 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift @@ -143,9 +143,10 @@ public final class PermissionCenter { private var activationObserver: ObservationToken? private var lastGrantRecheck: Date? - /// At most every 10 s: activating the app is frequent, and an Automation probe runs AppleScript. + /// At most every `PermissionAccess.activationRecheckInterval` (10 s): activating the app is frequent, and an + /// Automation probe runs AppleScript. private func recheckGrantedProbesOnActivation() { - if let lastGrantRecheck, Date().timeIntervalSince(lastGrantRecheck) < 10 { return } + guard PermissionAccess.activationRecheckDue(last: lastGrantRecheck) else { return } lastGrantRecheck = Date() Task { await recheckGrantedProbes() } } @@ -425,9 +426,7 @@ public final class PermissionDialogModel: ObservableObject, Identifiable { needs = [need] self.status = status self.center = center - appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? ProcessInfo.processInfo.processName + appName = PermissionKind.appName if status == .needsRelaunch, !worksInNewProcess { phase = .relaunch } } @@ -603,6 +602,7 @@ public final class PermissionDialogModel: ObservableObject, Identifiable { package func didClose() { closed = true stopWatching() + PermissionSettingsGuide.shared.dismiss(kind) } // MARK: - Watching for the grant @@ -728,6 +728,7 @@ public final class PermissionDialogModel: ObservableObject, Identifiable { status = .granted note = nil stopWatching() + PermissionSettingsGuide.shared.dismiss(kind) center?.dialogGranted(kind) let callbacks = needs.compactMap(\.onGranted) needs.removeAll() diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift index 79b37d61ba..31d4888889 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift @@ -41,6 +41,14 @@ public enum PermissionKind: String, CaseIterable, Identifiable, Sendable, Codabl } } + /// The name this app has in System Settings' lists: the bundle's display name, else its name, else the process. + /// Every sentence that tells the user which switch to turn on names the app with it, never "this app". + public static var appName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? ProcessInfo.processInfo.processName + } + /// The name System Settings shows for the grant. public var title: String { switch self { diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionSettingsGuide.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionSettingsGuide.swift new file mode 100644 index 0000000000..b4aa8f6811 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionSettingsGuide.swift @@ -0,0 +1,262 @@ +import AppKit +import SwiftUI + +/// A small floating helper over System Settings while a grant is turned on there: this app's icon and one line, +/// "Drag GenesisTools into the list" for the lists that take a dragged app (Accessibility, Input Monitoring, Screen +/// Recording, Full Disk Access), "Turn on GenesisTools in the list" for the others. It sits at the bottom of the +/// Privacy pane, shows only while System Settings is the active app, and goes away when the grant arrives, the dialog +/// closes, System Settings quits, or the person closes it. The `permissions.settingsGuide` default turns it off. +@MainActor +public final class PermissionSettingsGuide { + public static let shared = PermissionSettingsGuide() + + /// The preference: true (the default) shows the helper whenever the app opens a Privacy pane. + public static let enabledKey = "permissions.settingsGuide" + static let settingsBundleID = "com.apple.systempreferences" + + public static var isEnabled: Bool { + UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true + } + + private var panel: NSPanel? + private(set) var kind: PermissionKind? + private var tick: Timer? + private var shownAt = Date.distantPast + /// The helper gives up on its own after this long, as the permission dialog stops waiting. + private let lifetime: TimeInterval = 600 + + init() {} + + /// Shows the helper for `kind` (when the preference is on and the grant has a System Settings list). + public func show(for kind: PermissionKind, appURL: URL = Bundle.main.bundleURL) { + close() + guard Self.isEnabled else { return } + self.kind = kind + shownAt = Date() + let panel = makePanel(kind: kind, appURL: appURL) + self.panel = panel + // Twice a second: System Settings opens with an animation and can be moved; the helper follows it and hides + // while another app is in front. It runs only while the helper exists. + tick = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.follow() } + } + follow() + KitHost.log("permission \(kind.rawValue) settings guide shown") + } + + /// Closes the helper when it is showing for `kind` (nil: whatever it shows). + public func dismiss(_ kind: PermissionKind? = nil) { + guard panel != nil, kind == nil || kind == self.kind else { return } + close() + } + + var isVisible: Bool { panel?.isVisible ?? false } + + private func close() { + tick?.invalidate() + tick = nil + panel?.orderOut(nil) + panel?.close() + panel = nil + kind = nil + } + + private func follow() { + guard let panel else { return } + guard Date().timeIntervalSince(shownAt) < lifetime else { return close() } + let settings = NSRunningApplication.runningApplications(withBundleIdentifier: Self.settingsBundleID).first + guard let settings else { + // System Settings quit (or never opened): nothing left to point at. + if Date().timeIntervalSince(shownAt) > 5 { close() } + return + } + guard NSWorkspace.shared.frontmostApplication?.processIdentifier == settings.processIdentifier, + let window = Self.settingsWindow(pid: settings.processIdentifier) + else { + panel.orderOut(nil) + return + } + let size = panel.frame.size + let origin = Self.origin(size: size, settingsWindow: window) + if panel.frame.origin != origin { panel.setFrameOrigin(origin) } + if !panel.isVisible { panel.orderFrontRegardless() } + } + + /// Bottom of the Privacy pane's list: the right part of the window, past the sidebar, a little above the edge. + static func origin(size: CGSize, settingsWindow window: CGRect) -> CGPoint { + let sidebar = min(260, window.width * 0.3) + let paneMid = window.minX + sidebar + (window.width - sidebar) / 2 + return CGPoint(x: (paneMid - size.width / 2).rounded(), y: (window.minY + 28).rounded()) + } + + /// System Settings' largest normal window, in Cocoa screen points. + static func settingsWindow(pid: pid_t) -> CGRect? { + guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]] else { return nil } + let primaryHeight = NSScreen.screens.first?.frame.height ?? 0 + let frames: [CGRect] = list.compactMap { info in + guard (info[kCGWindowOwnerPID as String] as? Int).map(pid_t.init) == pid, + info[kCGWindowLayer as String] as? Int == 0, + let bounds = info[kCGWindowBounds as String] as? [String: CGFloat], + let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary), + rect.width > 200, rect.height > 200 + else { return nil } + return CGRect(x: rect.minX, y: primaryHeight - rect.maxY, width: rect.width, height: rect.height) + } + return frames.max { $0.width * $0.height < $1.width * $1.height } + } + + private func makePanel(kind: PermissionKind, appURL: URL) -> NSPanel { + let appName = FileManager.default.displayName(atPath: appURL.path) + .replacingOccurrences(of: ".app", with: "") + let view = PermissionGuideView(kind: kind, appName: appName, appURL: appURL, + onClose: { [weak self] in self?.close() }) + let host = GuideHostingView(rootView: view) + let size = host.fittingSize + let panel = PermissionGuidePanel(contentRect: CGRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + panel.title = "Drag \(appName) into the list" + panel.isFloatingPanel = true + panel.level = .floating + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + // The helper shows only while System Settings is the active app, so without this its close button's + // tooltip never appeared at all. + panel.allowsToolTipsWhenApplicationIsInactive = true + panel.contentView = host + panel.setAccessibilityIdentifier("permission-settings-guide") + return panel + } +} + +/// Takes clicks and drags without activating this app, so System Settings stays the active app under the drop. +private final class PermissionGuidePanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +/// The panel never becomes key, so its first click must act (the close button) instead of only focusing it. +private final class GuideHostingView<Content: View>: NSHostingView<Content> { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} + +public extension PermissionKind { + /// System Settings' list for this grant has a "+" and takes an app dropped on it. + var addsByDrag: Bool { + switch self { + case .accessibility, .inputMonitoring, .screenRecording, .fullDiskAccess: return true + default: return false + } + } +} + +struct PermissionGuideView: View { + let kind: PermissionKind + let appName: String + let appURL: URL + let onClose: () -> Void + @State private var hovering = false + + var body: some View { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(RadialGradient(colors: [Color.accentColor.opacity(0.55), .clear], center: .center, + startRadius: 4, endRadius: 62)) + .frame(width: 124, height: 124) + .accessibilityHidden(true) + AppDragSource(appURL: appURL, draggable: kind.addsByDrag) + .frame(width: 72, height: 72) + .accessibilityLabel(kind.addsByDrag ? "\(appName), drag into the list" : appName) + } + .frame(width: 124, height: 96) + Text(kind.addsByDrag ? "Drag \(appName)\ninto the list" : "Turn on \(appName)\nin the list") + .font(.system(size: 13, weight: .semibold)) + .multilineTextAlignment(.center) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.6), radius: 3) + .fixedSize() + Text(kind.title).font(.system(size: 10.5)).foregroundStyle(.white.opacity(0.7)) + } + .padding(.horizontal, 18) + .padding(.top, 4) + .padding(.bottom, 12) + .background(.black.opacity(0.35), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(alignment: .topTrailing) { + // The 18 pt circle is drawn inside a 24 pt target: the padding sits inside the button, so a click + // just beside the circle still closes the helper. + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white.opacity(hovering ? 0.95 : 0.6)) + .frame(width: 18, height: 18) + .background(.white.opacity(hovering ? 0.22 : 0.1), in: Circle()) + .padding(3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pointerCursor() + .onHover { hovering = $0 } + .help("Close this helper") + .accessibilityLabel("Close the helper") + .padding(3) + } + .padding(4) + .environment(\.colorScheme, .dark) + } +} + +/// The app's icon. Where the list takes a dropped app, a drag from it carries the app bundle's file URL, which is +/// what dragging the app from Finder carries. +private struct AppDragSource: NSViewRepresentable { + let appURL: URL + let draggable: Bool + + func makeNSView(context: Context) -> AppDragSourceView { + AppDragSourceView(appURL: appURL, draggable: draggable) + } + + func updateNSView(_ view: AppDragSourceView, context: Context) {} +} + +final class AppDragSourceView: NSView, NSDraggingSource { + let appURL: URL + let draggable: Bool + private let icon: NSImage + + init(appURL: URL, draggable: Bool) { + self.appURL = appURL + self.draggable = draggable + icon = NSWorkspace.shared.icon(forFile: appURL.path) + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func resetCursorRects() { + if draggable { addCursorRect(bounds, cursor: .openHand) } + } + + override func draw(_ dirtyRect: NSRect) { + icon.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1) + } + + override func mouseDragged(with event: NSEvent) { + guard draggable else { return } + let item = NSDraggingItem(pasteboardWriter: appURL as NSURL) + item.setDraggingFrame(bounds, contents: icon) + beginDraggingSession(with: [item], event: event, source: self) + } + + func draggingSession(_ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + context == .outsideApplication ? [.copy, .generic, .link] : [] + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift index ac6fcdc4a9..eea39876d7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift @@ -154,6 +154,7 @@ public struct SystemPermissions: PermissionSystem { public func openSettings(_ kind: PermissionKind) -> Bool { let opened = NSWorkspace.shared.open(kind.settingsURL) KitHost.log("permission \(kind.rawValue) open settings \(kind.settingsAnchor) opened=\(opened)") + if opened { PermissionSettingsGuide.shared.show(for: kind) } return opened } @@ -314,6 +315,16 @@ public struct PermissionAccess: Sendable { /// The real system and the simulation in this app's defaults. public static let live = PermissionAccess(system: SystemPermissions(), simulation: { PermissionSimulation.current() }) + /// The shortest time between two rechecks of the answered probes that an app activation starts. Activating the app + /// is frequent, and an Automation probe runs AppleScript. `PermissionCenter` and the settings window both use it. + public static let activationRecheckInterval: TimeInterval = 10 + + /// True when an activation may recheck the answered probes: never yet, or `activationRecheckInterval` since the last. + public static func activationRecheckDue(last: Date?, now: Date = Date()) -> Bool { + guard let last else { return true } + return now.timeIntervalSince(last) >= activationRecheckInterval + } + public let system: any PermissionSystem private let simulation: @Sendable () -> PermissionSimulation private let lifted = LiftedKinds() diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Style/GenAnnounce.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Style/GenAnnounce.swift new file mode 100644 index 0000000000..553f9735de --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Style/GenAnnounce.swift @@ -0,0 +1,32 @@ +import AppKit + +/// Speaks a short message through VoiceOver for something that changed without the user's action: a new decision, a +/// send that failed, a recording that started. Each post also writes `a11y.announce <text>` to the kit log, so a test +/// or a live check can prove it ran without VoiceOver on. +public enum GenAnnounce { + public enum Priority { + case low, medium, high + + var level: NSAccessibilityPriorityLevel { + switch self { + case .low: return .low + case .medium: return .medium + case .high: return .high + } + } + } + + @MainActor + public static func post(_ text: String, priority: Priority = .medium) { + let message = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + KitHost.log("a11y.announce \(message)") + // NSApp is nil in a process that never created the application (a test run): log only. + guard let app = NSApp else { return } + let element: Any = app.keyWindow ?? app.mainWindow ?? app + NSAccessibility.post(element: element, notification: .announcementRequested, userInfo: [ + .announcement: message, + .priority: priority.level.rawValue, + ]) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ProcessCapture.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ProcessCapture.swift index f7c9bc5f16..38f6cb10fa 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ProcessCapture.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ProcessCapture.swift @@ -48,6 +48,8 @@ extension Process { let exited = DispatchSemaphore(value: 0) terminationHandler = { _ in exited.signal() } try run() + let ended = SpawnMeter.started(executable: executableURL, arguments: arguments ?? []) + defer { ended() } let outReader = PipeReader(out, stream: "stdout") let errReader = err.map { PipeReader($0, stream: "stderr") } afterStart?() diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ToolsBridge.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ToolsBridge.swift index 2468d4ed01..ee6743e1d0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ToolsBridge.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Tools/ToolsBridge.swift @@ -305,10 +305,15 @@ public struct ToolsBridge: Sendable { let stdout = try ToolsPipeCollector(outPipe) let stderr = try ToolsPipeCollector(errPipe) let exit = ToolsProcessExit() - process.terminationHandler = { _ in exit.finish() } + let ended = SpawnMeter.started(executable: process.executableURL, arguments: process.arguments ?? []) + process.terminationHandler = { _ in + ended() + exit.finish() + } do { try process.run() } catch { + ended() stdout.stop(error) stderr.stop(error) throw error diff --git a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift index 319c63de60..6af5b3a3f7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexModels.swift @@ -374,7 +374,10 @@ public struct DuIndexDiffRow: Codable, Equatable, Identifiable, Sendable { public var path: String /// nil when the old side is not known exactly (`approx`). public var before: Int64? - public var after: Int64 + /// nil when the folder fell below the storage floor or out of the index (`approx`): the CLI keeps + /// that side unknown rather than inventing zero bytes. Required before 2026-10-23, so one such row + /// failed the whole Radar "Fast-growing folders" card ("no value for Int64 at rows.Index 21.after"). + public var after: Int64? public var delta: Int64 /// `grown`, `shrunk`, `new` or `gone`. public var status: String @@ -383,7 +386,7 @@ public struct DuIndexDiffRow: Codable, Equatable, Identifiable, Sendable { public var id: String { path } - public init(path: String, before: Int64?, after: Int64, delta: Int64, status: String, filesDelta: Int64? = nil, + public init(path: String, before: Int64?, after: Int64?, delta: Int64, status: String, filesDelta: Int64? = nil, approx: Bool? = nil) { self.path = path self.before = before diff --git a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift index 696a35feda..737d9685ae 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitDisk/Disk/DuIndexReader.swift @@ -334,13 +334,18 @@ public final class DuIndexReader: @unchecked Sendable { } /// The most recent scans, newest first. - public func scans(limit: Int = 10) throws -> [DuIndexScan] { + /// The newest scans first. `status` and `kind` filter in SQL, so "the last finished build" is found + /// however many failed updates came after it (a volume-id mismatch failed 176 in a row by 2026-10-23, + /// and a 20-row window then showed "no index" over a good one). + public func scans(limit: Int = 10, status: String? = nil, kind: String? = nil) throws -> [DuIndexScan] { try locked { var result: [DuIndexScan] = [] try query(""" SELECT id, kind, status, started_at_ms, finished_at_ms, files, dirs, alloc_bytes, walk_ms, threads - FROM scans ORDER BY id DESC LIMIT ?1 - """, [.int(Int64(max(0, limit)))]) { row in + FROM scans WHERE (?2 IS NULL OR status = ?2) AND (?3 IS NULL OR kind = ?3) + ORDER BY id DESC LIMIT ?1 + """, [.int(Int64(max(0, limit))), status.map { .text($0) } ?? .null, + kind.map { .text($0) } ?? .null]) { row in result.append(DuIndexScan(id: row.int(0), kind: row.text(1), status: row.text(2), startedAtMs: row.int(3), finishedAtMs: row.optionalInt(4), files: row.optionalInt(5), dirs: row.optionalInt(6), allocBytes: row.optionalInt(7), @@ -360,6 +365,7 @@ public final class DuIndexReader: @unchecked Sendable { enum Value { case int(Int64) case text(String) + case null } struct Row { @@ -440,6 +446,7 @@ public final class DuIndexReader: @unchecked Sendable { switch value { case .int(let number): sqlite3_bind_int64(statement, position, number) case .text(let text): sqlite3_bind_text(statement, position, text, -1, Self.transient) + case .null: sqlite3_bind_null(statement, position) } } while true { diff --git a/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json b/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json index 2172e6c0f2..46ae389b4a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json +++ b/src/macos/GenesisKit/Sources/GenesisKitFixtures/Disk/contract-du-index-diff.json @@ -63,6 +63,15 @@ "status": "shrunk", "files_delta": -300, "approx": false + }, + { + "path": "/Users/example/Library/Caches/Below-Floor", + "before": 268435456, + "after": null, + "delta": -251658240, + "status": "shrunk", + "files_delta": -41, + "approx": true } ], "truncated": false diff --git a/src/macos/GenesisKit/Sources/GenesisKitFixtures/Git/contract-worktree-renames.json b/src/macos/GenesisKit/Sources/GenesisKitFixtures/Git/contract-worktree-renames.json new file mode 100644 index 0000000000..1bcc04f3a4 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitFixtures/Git/contract-worktree-renames.json @@ -0,0 +1,66 @@ +{ + "about": "Moves git does not report: a deleted file paired with an untracked file of the same blob. GenesisKit GitWorktreeRenames.pair and src/utils/git/worktree-renames.ts pairWorktreeRenames must both turn every case's deleted and untracked lists into its pairs, in this order.", + "cases": [ + { + "name": "a moved folder pairs each file with its new place", + "deleted": [ + { "path": "Poznámky/úkol-1234/data/perf-deep/agent-rerender/compiler.ts", "oid": "1111111111111111111111111111111111111111" }, + { "path": "Poznámky/úkol-1234/data/perf-deep/agent-rerender/out/compiler/web__src__App.tsx.txt", "oid": "2222222222222222222222222222222222222222" } + ], + "untracked": [ + { "path": "Poznámky/WebPerformance/data/perf-deep/agent-rerender/out/compiler/web__src__App.tsx.txt", "oid": "2222222222222222222222222222222222222222" }, + { "path": "Poznámky/WebPerformance/data/perf-deep/agent-rerender/compiler.ts", "oid": "1111111111111111111111111111111111111111" } + ], + "pairs": [ + ["Poznámky/úkol-1234/data/perf-deep/agent-rerender/compiler.ts", "Poznámky/WebPerformance/data/perf-deep/agent-rerender/compiler.ts"], + ["Poznámky/úkol-1234/data/perf-deep/agent-rerender/out/compiler/web__src__App.tsx.txt", "Poznámky/WebPerformance/data/perf-deep/agent-rerender/out/compiler/web__src__App.tsx.txt"] + ] + }, + { + "name": "among copies of the same content the same file name wins, then the most equal trailing folders", + "deleted": [{ "path": "old/run1/run.log", "oid": "3333333333333333333333333333333333333333" }], + "untracked": [ + { "path": "a/other.log", "oid": "3333333333333333333333333333333333333333" }, + { "path": "b/run2/run.log", "oid": "3333333333333333333333333333333333333333" }, + { "path": "c/run1/run.log", "oid": "3333333333333333333333333333333333333333" } + ], + "pairs": [["old/run1/run.log", "c/run1/run.log"]] + }, + { + "name": "an untracked file pairs once; the deleted file that sorts first by bytes takes it", + "deleted": [ + { "path": "z/notes.md", "oid": "4444444444444444444444444444444444444444" }, + { "path": "a/notes.md", "oid": "4444444444444444444444444444444444444444" } + ], + "untracked": [{ "path": "moved/notes.md", "oid": "4444444444444444444444444444444444444444" }], + "pairs": [["a/notes.md", "moved/notes.md"]] + }, + { + "name": "equal scores fall back to the untracked path's byte order", + "deleted": [{ "path": "x/a.txt", "oid": "5555555555555555555555555555555555555555" }], + "untracked": [ + { "path": "q/a.txt", "oid": "5555555555555555555555555555555555555555" }, + { "path": "p/a.txt", "oid": "5555555555555555555555555555555555555555" } + ], + "pairs": [["x/a.txt", "p/a.txt"]] + }, + { + "name": "empty files prove no move, in SHA-1 and SHA-256 repositories", + "deleted": [ + { "path": "runs/load-resolved.err", "oid": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" }, + { "path": "runs/other.err", "oid": "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813" } + ], + "untracked": [ + { "path": "Monitoring/state/run.lock", "oid": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" }, + { "path": "Monitoring/state/other.err", "oid": "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813" } + ], + "pairs": [] + }, + { + "name": "a deleted file with no untracked twin stays deleted", + "deleted": [{ "path": "gone.md", "oid": "6666666666666666666666666666666666666666" }], + "untracked": [{ "path": "new.md", "oid": "7777777777777777777777777777777777777777" }], + "pairs": [] + } + ] +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitFixtures/KitFixture.swift b/src/macos/GenesisKit/Sources/GenesisKitFixtures/KitFixture.swift index 0d1d47a681..344b073e2e 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitFixtures/KitFixture.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitFixtures/KitFixture.swift @@ -1,7 +1,8 @@ import Foundation -/// The one copy of the `tools` disk fixtures: the frozen CLI contracts (`contract-*`) and real captured outputs -/// (`doctor.json`, `du-*.json`, `clones-*.json`, `hub-worktrees-*.json`, `macos-swap.json`) in `Disk/`. +/// The one copy of the `tools` fixtures: the frozen CLI contracts (`contract-*`) and real captured outputs +/// (`doctor.json`, `du-*.json`, `clones-*.json`, `hub-worktrees-*.json`, `macos-swap.json`) in `Disk/`, and the git +/// rules both languages implement (`contract-worktree-renames.json`) in `Git/`. /// The kit's tests and Genesis's tests read them through this product; the GenesisTools bun tests read the same /// files by path and check every contract against the real output of the command code. Test targets only: an app /// target never links this product. @@ -9,19 +10,20 @@ public enum KitFixture { /// A file name that is not in `Disk/`. public struct Missing: Error, CustomStringConvertible { public let name: String - public var description: String { "no fixture named \(name) in GenesisKitFixtures/Disk" } + public var folder = "Disk" + public var description: String { "no fixture named \(name) in GenesisKitFixtures/\(folder)" } } - /// The fixture file `name`, with its extension (`contract-du-index-schema.sql`, `doctor.json`). - public static func url(_ name: String) throws -> URL { - guard let url = Bundle.module.url(forResource: name, withExtension: nil, subdirectory: "Disk") else { - throw Missing(name: name) + /// The fixture file `name`, with its extension (`contract-du-index-schema.sql`, `doctor.json`), in `folder`. + public static func url(_ name: String, in folder: String = "Disk") throws -> URL { + guard let url = Bundle.module.url(forResource: name, withExtension: nil, subdirectory: folder) else { + throw Missing(name: name, folder: folder) } return url } - public static func data(_ name: String) throws -> Data { - try Data(contentsOf: url(name)) + public static func data(_ name: String, in folder: String = "Disk") throws -> Data { + try Data(contentsOf: url(name, in: folder)) } public static func string(_ name: String) throws -> String { diff --git a/src/macos/GenesisKit/Sources/GenesisKitTestHooks/GenesisKitTestHooks.m b/src/macos/GenesisKit/Sources/GenesisKitTestHooks/GenesisKitTestHooks.m new file mode 100644 index 0000000000..ed48413027 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitTestHooks/GenesisKitTestHooks.m @@ -0,0 +1,50 @@ +// +// GenesisKitTestHooks.m +// GenesisKit +// +// Test runs must never take the user's keyboard focus. A test that expands an edge panel or opens the recording +// picker made the test process the key window, about ten times per suite run (2026-10-25, WindowServer logged +// "key thief ... (xctest)"). From the moment the test bundle loads, making a window key only orders it front, and +// the app never activates, so a new test cannot bring it back. +// + +#import <AppKit/AppKit.h> +#import <objc/runtime.h> +#import "GenesisKitTestHooks.h" + +static NSInteger refused = 0; + +NSInteger GenesisKitTestHooksRefusedFocus(void) { + return refused; +} + +static void replace(Class cls, SEL selector, IMP replacement) { + Method method = class_getInstanceMethod(cls, selector); + if (method) { + method_setImplementation(method, replacement); + } +} + +static void orderFrontInstead(NSWindow *self, SEL _cmd, id sender) { + refused += 1; + [self orderFront:sender]; +} + +static void noKey(NSWindow *self, SEL _cmd) { + refused += 1; +} + +static void noActivation(NSApplication *self, SEL _cmd) { + refused += 1; +} + +static void noActivationIgnoring(NSApplication *self, SEL _cmd, BOOL flag) { + refused += 1; +} + +__attribute__((constructor)) static void GenesisKitTestHooksInstall(void) { + replace([NSWindow class], @selector(makeKeyAndOrderFront:), (IMP)orderFrontInstead); + replace([NSWindow class], @selector(makeKeyWindow), (IMP)noKey); + replace([NSApplication class], @selector(activateIgnoringOtherApps:), (IMP)noActivationIgnoring); + replace([NSApplication class], @selector(activate), (IMP)noActivation); +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitTestHooks/include/GenesisKitTestHooks.h b/src/macos/GenesisKit/Sources/GenesisKitTestHooks/include/GenesisKitTestHooks.h new file mode 100644 index 0000000000..a5839f2c60 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitTestHooks/include/GenesisKitTestHooks.h @@ -0,0 +1,5 @@ +#import <Foundation/Foundation.h> + +/// The number of times a test asked for a key window or app activation that the hooks turned down. A test file +/// references it, which also keeps this object file (and its load-time hook) in the test bundle. +NSInteger GenesisKitTestHooksRefusedFocus(void); diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyOutputTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyOutputTests.swift new file mode 100644 index 0000000000..d54d7a8122 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyOutputTests.swift @@ -0,0 +1,125 @@ +import AppKit +import CoreAudio +import SwiftUI +import XCTest + +@testable import GenesisKit + +/// Where Clicky plays: a picked device, a rule for the system output, or the system output; and which output the +/// late-output prompt offers. +@MainActor +final class ClickyOutputTests: XCTestCase { + private let speakers = AudioOutputDevice(id: 1, uid: "BuiltInSpeakerDevice", name: "MacBook Pro Speakers", + transport: kAudioDeviceTransportTypeBuiltIn, delay: 0.027) + private let homePod = AudioOutputDevice(id: 2, uid: "airplay-work", name: "AirPlay", + transport: kAudioDeviceTransportTypeAirPlay, delay: 2.012) + private let monitor = AudioOutputDevice(id: 3, uid: "hdmi-monitor", name: "Monitor", + transport: kAudioDeviceTransportTypeHDMI, delay: 0.019) + private let loopback = AudioOutputDevice(id: 4, uid: "virtual", name: "Loopback", + transport: kAudioDeviceTransportTypeVirtual, delay: 0.011) + private var all: [AudioOutputDevice] { [speakers, homePod, monitor, loopback] } + + func testWithNoChoiceClickyFollowsTheSystemOutput() { + let resolved = ClickyOutputRouting.resolve(preferences: ClickyPreferences(), outputs: all, systemID: homePod.id) + XCTAssertNil(resolved.device) + XCTAssertEqual(resolved.effective, homePod) + } + + func testARuleForTheSystemOutputPlaysOnItsTarget() { + var preferences = ClickyPreferences() + preferences.outputRules = [ClickyOutputRule(whenUID: homePod.uid, whenName: homePod.name, + playOnUID: speakers.uid, playOnName: speakers.name)] + XCTAssertEqual(ClickyOutputRouting.resolve(preferences: preferences, outputs: all, systemID: homePod.id).effective, + speakers) + XCTAssertEqual(ClickyOutputRouting.resolve(preferences: preferences, outputs: all, systemID: monitor.id).effective, + monitor, "a rule applies only while the system plays on its output") + } + + func testAPickedDeviceWinsOverRulesAndFallsBackWhenGone() { + var preferences = ClickyPreferences() + preferences.outputDeviceUID = monitor.uid + preferences.outputDeviceName = monitor.name + preferences.outputRules = [ClickyOutputRule(whenUID: homePod.uid, whenName: homePod.name, + playOnUID: speakers.uid, playOnName: speakers.name)] + XCTAssertEqual(ClickyOutputRouting.resolve(preferences: preferences, outputs: all, systemID: homePod.id).effective, + monitor) + let gone = ClickyOutputRouting.resolve(preferences: preferences, outputs: [speakers, homePod], systemID: homePod.id) + XCTAssertEqual(gone.effective, homePod) + XCTAssertTrue(gone.reason.contains("not connected"), gone.reason) + } + + func testTheSuggestionPrefersBuiltInSpeakersAndNeverAVirtualDevice() { + XCTAssertEqual(AudioOutputs.suggestion(instead: homePod, outputs: all, limit: 0.15), speakers) + XCTAssertEqual(AudioOutputs.suggestion(instead: homePod, outputs: [homePod, monitor, loopback], limit: 0.15), + monitor) + XCTAssertNil(AudioOutputs.suggestion(instead: homePod, outputs: [homePod, loopback], limit: 0.15)) + } + + func testPreferencesSavedBeforeOutputsKeepTheirValues() throws { + let old = #"{"selectedSwitch":"ember","volume":0.8,"releaseSounds":false,"randomizedPitch":true,"spatialAudio":true,"repeatSounds":false,"quietHours":false,"quietStart":1320,"quietEnd":480,"excludedApplications":[],"visualizer":true,"notifications":false,"collectStats":true,"reduceMotion":false,"reduceTransparency":false}"# + let decoded = try JSONDecoder().decode(ClickyPreferences.self, from: Data(old.utf8)) + XCTAssertEqual(decoded.selectedSwitch, .ember) + XCTAssertEqual(decoded.volume, 0.8) + XCTAssertFalse(decoded.releaseSounds) + XCTAssertNil(decoded.outputDeviceUID) + XCTAssertTrue(decoded.delayWarning) + XCTAssertEqual(decoded.delayWarningMilliseconds, 150) + var changed = decoded + changed.outputRules = [ClickyOutputRule(whenUID: "a", whenName: "A", playOnUID: "b", playOnName: "B")] + let again = try JSONDecoder().decode(ClickyPreferences.self, from: JSONEncoder().encode(changed)) + XCTAssertEqual(again, changed) + } + + func testDelayText() { + XCTAssertEqual(homePod.delayText, "2.0 s late") + XCTAssertEqual(speakers.delayText, "27 ms late") + } + + /// Renders the prompt for the report: `CLICKY_PROMPT_SHOT=<png path>`. + @MainActor + func testThePromptRendersItsQuestion() throws { + let view = ClickyDelayPromptView(late: homePod, suggestion: speakers, onSwitch: {}, onKeep: {}, onClose: {}) + .environment(\.colorScheme, .dark) + let host = NSHostingView(rootView: view) + host.frame = CGRect(origin: .zero, size: host.fittingSize) + XCTAssertGreaterThan(host.fittingSize.height, 80) + guard let path = ProcessInfo.processInfo.environment["CLICKY_PROMPT_SHOT"] else { return } + let window = NSWindow(contentRect: CGRect(x: -3000, y: -3000, width: host.frame.width + 40, height: host.frame.height + 40), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.backgroundColor = NSColor(white: 0.18, alpha: 1) + let container = NSView(frame: CGRect(origin: .zero, size: window.frame.size)) + host.frame.origin = CGPoint(x: 20, y: 20) + container.addSubview(host) + window.contentView = container + window.orderFront(nil) + defer { window.close() } + container.layoutSubtreeIfNeeded() + let rep = try XCTUnwrap(container.bitmapImageRepForCachingDisplay(in: container.bounds)) + container.cacheDisplay(in: container.bounds, to: rep) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path)) + } + + /// Renders the settings card with this Mac's real outputs: `CLICKY_OUTPUT_CARD_SHOT=<png path>`. + @MainActor + func testTheOutputCardRendersTheDevicesAndARule() throws { + guard let path = ProcessInfo.processInfo.environment["CLICKY_OUTPUT_CARD_SHOT"] else { return } + let suite = "dev.genesis.clicky.output.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let appearance = NativeSettingsAppearance(defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) + let model = ClickyModel(defaults: defaults, appearance: appearance, observeSystemEvents: false) + model.refreshOutput() + if let system = model.output?.system, + let faster = AudioOutputs.suggestion(instead: system, outputs: model.outputs, limit: .infinity) { + model.setRule(when: system, playOn: faster) + } + let view = ClickyOutputCard(model: model).padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, .graphite) + .environment(\.colorScheme, .dark) + let rep = try KitShots.render(view, size: CGSize(width: 680, height: 560), name: "clicky-output-card", settle: 0.6) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path)) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyStatsPagesTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyStatsPagesTests.swift new file mode 100644 index 0000000000..80a267afbc --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyStatsPagesTests.swift @@ -0,0 +1,169 @@ +import AppKit +import SwiftUI +import XCTest + +@testable import GenesisKitBase +@testable import GenesisKit + +/// The Stats and Performance pages: ranges, comparisons, the dictation estimate, calendar bins and pictures of both +/// pages with the sample data. +@MainActor +final class ClickyStatsPagesTests: XCTestCase { + private func calendar(firstWeekday: Int = 2) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Europe/Prague")! + calendar.locale = Locale(identifier: "en_US") + calendar.firstWeekday = firstWeekday + return calendar + } + + private func date(_ text: String) -> Date { ISO8601DateFormatter().date(from: text)! } + + func testDictationEstimateAppliesTheDictatedShareToBothSidesAndAddsReview() { + var totals = ClickyPerformanceBucket() + totals.characters = 750; totals.corrections = 50; totals.activeSeconds = 180; totals.sessions = 2 + let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerSession: 3, + dictatedShare: 0.7, reviewShare: 0.15) + XCTAssertEqual(estimate.retainedWords, 98, accuracy: 0.0001) + XCTAssertEqual(estimate.typingSeconds, 126, accuracy: 0.0001) + XCTAssertEqual(estimate.speakingSeconds, 42, accuracy: 0.0001) + XCTAssertEqual(estimate.setupSeconds, 4.2, accuracy: 0.0001) + XCTAssertEqual(estimate.reviewSeconds, 6.3, accuracy: 0.0001) + XCTAssertEqual(estimate.differenceSeconds, 126 - 52.5, accuracy: 0.0001) + let none = ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerSession: 3, dictatedShare: 0) + XCTAssertEqual(none.differenceSeconds, 0, "0% dictated compares nothing") + } + + func testRangesPreviousPeriodsAndCoverage() { + let calendar = calendar() + let now = date("2026-10-25T14:30:00Z") + let week = ClickyRanges.interval(ClickyRanges.week, now: now, earliest: nil, calendar: calendar) + XCTAssertEqual(ClickyRanges.dayCount(week, calendar: calendar), 7) + let previous = ClickyRanges.previous(week, range: ClickyRanges.week, calendar: calendar)! + XCTAssertEqual(ClickyRanges.dayCount(previous, calendar: calendar), 7) + XCTAssertEqual(previous.end, calendar.date(byAdding: .day, value: -7, to: now)) + let today = ClickyRanges.interval(ClickyRanges.today, now: now, earliest: nil, calendar: calendar) + let yesterday = ClickyRanges.previous(today, range: ClickyRanges.today, calendar: calendar)! + XCTAssertEqual(yesterday.duration, today.duration, accuracy: 3600, "yesterday until the same time") + XCTAssertNil(ClickyRanges.previous(today, range: ClickyRanges.all, calendar: calendar)) + let started = calendar.date(byAdding: .day, value: -2, to: now)! + let year = ClickyRanges.interval(ClickyRanges.year, now: now, earliest: started, calendar: calendar) + XCTAssertEqual(ClickyRanges.coverageText(year, earliest: started, calendar: calendar), + "Recorded data covers 3 of the 366 days in this range.") + XCTAssertNil(ClickyRanges.coverageText(today, earliest: started, calendar: calendar)) + XCTAssertEqual(ClickyRanges.groupings(for: year, now: now).map(\.rawValue), [3600, 86400, 604800], + "no minute grouping past the 30 days of minute detail") + XCTAssertEqual(ClickyRanges.grouping(for: ClickyRanges.custom, span: 86400), .fifteenMinutes) + XCTAssertEqual(ClickyRanges.grouping(for: ClickyRanges.custom, span: 120 * 86400), .day) + } + + func testWeekBinsStartOnTheCalendarsFirstWeekday() { + let monday = calendar(firstWeekday: 2) + let points = [ + NativeTimePoint(date: date("2026-10-21T10:00:00Z"), value: 2), // Wednesday + NativeTimePoint(date: date("2026-10-25T10:00:00Z"), value: 3), // Sunday + ] + let start = date("2026-10-01T00:00:00Z") + let end = date("2026-10-25T20:00:00Z") + let mondayBins = NativeChartSampling.bins(points: points, start: start, end: end, step: 604800, calendar: monday) + XCTAssertEqual(mondayBins.map(\.value), [5]) + XCTAssertEqual(monday.component(.weekday, from: mondayBins[0].date), 2, "the week bar is dated on Monday") + let sunday = calendar(firstWeekday: 1) + let sundayBins = NativeChartSampling.bins(points: points, start: start, end: end, step: 604800, calendar: sunday) + XCTAssertEqual(sundayBins.map(\.value), [2, 3], "a Sunday-first calendar starts a new week on Sunday") + let span = NativeChartSampling.spanText(mondayBins[0].date, step: 604800, calendar: monday) + let sundayText = monday.date(byAdding: .day, value: 6, to: mondayBins[0].date)! + .formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day()) + XCTAssertTrue(span.hasSuffix(" to \(sundayText)"), "a week bar names its whole span: \(span)") + } + + func testAverageBinsLeaveEmptySlotsOutAndDoNotAddRates() { + let points = [ + NativeTimePoint(date: Date(timeIntervalSince1970: 0), value: 40), + NativeTimePoint(date: Date(timeIntervalSince1970: 60), value: 60), + NativeTimePoint(date: Date(timeIntervalSince1970: 600), value: 50), + ] + let bins = NativeChartSampling.bins(points: points, start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 1200), step: 300, combine: .average) + XCTAssertEqual(bins.map(\.value), [50, 50]) + XCTAssertEqual(bins.map(\.date.timeIntervalSince1970), [0, 600]) + } + + func testRecordsFindTheBusiestDayAndStreaks() { + let calendar = calendar() + let today = calendar.startOfDay(for: date("2026-10-25T10:00:00Z")) + func day(_ offset: Int) -> Date { calendar.date(byAdding: .day, value: offset, to: today)! } + let records = ClickyRecords(days: [day(-9): 5, day(-8): 50, day(-7): 5, day(-6): 5, day(-3): 0, day(-1): 1, day(0): 2], + today: today, calendar: calendar) + XCTAssertEqual(records.busiestDay, day(-8)) + XCTAssertEqual(records.longestStreak, 4) + XCTAssertEqual(records.currentStreak, 2) + } + + func testStatsReportTotalsComparesAndOrdersWeekdays() { + let calendar = calendar(firstWeekday: 1) + var stats = ClickyStatistics() + let now = date("2026-10-25T14:30:00Z") + for offset in (0..<10).reversed() { + let at = calendar.date(byAdding: .day, value: -offset, to: now)!.addingTimeInterval(-3600) + for _ in 0..<(offset + 1) { stats.record(keyCode: 0, release: false, at: at, calendar: calendar) } + } + let week = ClickyRanges.interval(ClickyRanges.week, now: now, earliest: stats.historyStartedAt, calendar: calendar) + let report = ClickyStatsReport(statistics: stats, interval: week, + previous: ClickyRanges.previous(week, range: ClickyRanges.week, calendar: calendar), + resolution: .hour, releases: false, now: now, calendar: calendar) + XCTAssertEqual(report.total, (1...7).reduce(0, +)) + XCTAssertEqual(report.previousTotal, 8 + 9 + 10) + XCTAssertEqual(report.activeDays, 7) + XCTAssertEqual(report.records.currentStreak, 7) + XCTAssertEqual(report.heatmap.count, 7 * 24) + XCTAssertEqual(ClickyWeekdays.order(calendar).first, 6, "a Sunday-first calendar lists Sunday first") + let sundayRow = report.heatmap.filter { $0.row == 0 }.reduce(0) { $0 + $1.value } + XCTAssertEqual(sundayRow, 1, "today is a Sunday with one press") + } + + func testRecordingStatusNamesTheReasonAndTheFix() { + XCTAssertNil(ClickyRecordingStatus.recording.message) + XCTAssertEqual(ClickyRecordingStatus.statisticsOff.actionTitle, "Turn On Statistics") + XCTAssertTrue(ClickyRecordingStatus.inputMonitoringMissing.message?.contains("Input Monitoring") == true) + } + + // MARK: Pictures + + private func page(_ content: some View) -> some View { + ScrollView { content.padding(24) } + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, .graphite) + .environment(\.colorScheme, .dark) + } + + private func defaults(_ values: [String: Any]) -> UserDefaults { + let name = "clicky.stats.shots.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + for (key, value) in values { defaults.set(value, forKey: key) } + return defaults + } + + func testPerformancePageRendersTheSampleData() throws { + let store = ClickyAnalyticsStore() + store.flush(ClickyStatistics.example(now: Date())) + for (range, name) in [("7 days", "clicky-performance-7days"), ("1 year", "clicky-performance-1year")] { + let view = page(ClickyPerformanceView(store: store, defaults: defaults(["clicky.performance.range": range]), + status: .clickyOff, fixRecording: {})) + let rep = try KitShots.render(view, size: CGSize(width: 820, height: 3300), name: name, settle: 3) + XCTAssertGreaterThan(KitShots.brightness(rep, in: CGRect(x: 0, y: 0, width: 820, height: 600), viewWidth: 820), 0.02) + } + } + + func testStatsPageRendersTheSampleData() throws { + let store = ClickyAnalyticsStore() + store.flush(ClickyStatistics.example(now: Date())) + for (range, tab, name) in [("30 days", "Activity", "clicky-stats-30days"), ("1 year", "Rhythm", "clicky-stats-1year-rhythm")] { + let view = page(ClickyAnalyticsView(store: store, + defaults: defaults(["clicky.stats.range": range, "clicky.stats.tab": tab]))) + let rep = try KitShots.render(view, size: CGSize(width: 820, height: 1500), name: name, settle: 3) + XCTAssertGreaterThan(KitShots.brightness(rep, in: CGRect(x: 0, y: 0, width: 820, height: 600), viewWidth: 820), 0.02) + } + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift index e5fd375b13..85f3e4f874 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift @@ -245,9 +245,11 @@ final class ClickyTests: XCTestCase { func testExampleStatisticsHaveConsistentTotalsAndRecentDataWithoutStorage() { let example = ClickyStatistics.example(now: date(23, 30), calendar: calendar) XCTAssertEqual(example.keys.values.reduce(0, +), example.presses) - XCTAssertEqual(example.minutes.values.reduce(0) { $0 + $1.presses }, example.presses) - XCTAssertEqual(example.hours.values.reduce(0) { $0 + $1.presses }, example.presses) XCTAssertEqual(example.days.values.reduce(0) { $0 + $1.presses }, example.presses) + let recentHours = example.hours.filter { $0.key >= example.minutes.keys.min()! / 60 } + XCTAssertEqual(example.minutes.values.reduce(0) { $0 + $1.presses }, recentHours.values.reduce(0) { $0 + $1.presses }) + XCTAssertGreaterThan(example.days.count, 700, "the sample fills the long ranges") + XCTAssertGreaterThan(example.performanceHours.count, 5000) let recent = Int(date(23).timeIntervalSince1970 / 60) XCTAssertGreaterThan(example.minutes.filter { $0.key >= recent }.values.reduce(0) { $0 + $1.presses }, 0) } @@ -2376,15 +2378,59 @@ extension ClickyTests { func testDictationEstimateUsesRetainedWordConventionAndCanFavorTyping() { var totals = ClickyPerformanceBucket() - totals.characters = 750; totals.corrections = 50; totals.activeSeconds = 180; totals.bursts = 2 - let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerBurst: 3) + totals.characters = 750; totals.corrections = 50; totals.activeSeconds = 180; totals.bursts = 9; totals.sessions = 2 + let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerSession: 3) XCTAssertEqual(totals.estimatedWords, 150) XCTAssertEqual(totals.estimatedRetainedWords, 140) XCTAssertEqual(estimate.speakingSeconds, 60) XCTAssertEqual(estimate.setupSeconds, 6) XCTAssertEqual(estimate.differenceSeconds, 114) totals.activeSeconds = 20 - XCTAssertEqual(ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerBurst: 3).differenceSeconds, -46) + XCTAssertEqual(ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerSession: 3).differenceSeconds, -46) + } + + /// A session starts after a minute without text; minutes past 30 days roll into hours that the long ranges still count. + func testOldMinutesRollIntoHoursAndLongRangesCountThemOnce() { + var stats = ClickyStatistics() + func put(_ minute: Int, characters: Int, seconds: Double) { + var bucket = ClickyPerformanceBucket() + bucket.presses = characters; bucket.characters = characters; bucket.activeSeconds = seconds; bucket.bursts = 1 + stats.performanceMinutes[minute] = bucket + } + let base = 29_000_000 // a whole hour: 483,333 h 20 min, so use an hour-aligned start below + let hourStart = (base / 60) * 60 + put(hourStart, characters: 50, seconds: 20) // starts a session + put(hourStart + 1, characters: 40, seconds: 15) // same session + put(hourStart + 5, characters: 30, seconds: 10) // a new session after 3 empty minutes + let recent = hourStart + 40 * 24 * 60 + put(recent, characters: 25, seconds: 9) + stats.rollUpPerformance(olderThan: recent - 30 * 24 * 60) + XCTAssertEqual(stats.performanceMinutes.keys.sorted(), [recent], "only minutes inside 30 days stay") + let hour = stats.performanceHours[hourStart / 60] + XCTAssertEqual(hour?.characters, 120) + XCTAssertEqual(hour?.sessions, 2) + let all = ClickyPerformanceFilter(start: Date(timeIntervalSince1970: 0), end: Date(timeIntervalSince1970: Double(recent + 60) * 60)) + let report = ClickyPerformanceReport(statistics: stats, filter: all, grouping: .week, calendar: calendar) + XCTAssertEqual(report.total.characters, 145, "hours and minutes are disjoint") + XCTAssertEqual(report.total.sessions, 3, "two rolled-up sessions and the recent minute's own") + XCTAssertEqual(report.total.activeSeconds, 54, accuracy: 0.001) + } + + func testEachRangeOpensWithAGroupingThatKeepsTheTimelineReadable() { + XCTAssertEqual(ClickyPerformanceView.grouping(for: "Today"), .fifteenMinutes) + XCTAssertEqual(ClickyPerformanceView.grouping(for: "7 days"), .hour) + XCTAssertEqual(ClickyPerformanceView.grouping(for: "90 days"), .day) + XCTAssertEqual(ClickyPerformanceView.grouping(for: "All kept data"), .week) + XCTAssertEqual(ClickyRanges.known("All time", allowed: ClickyPerformanceView.ranges), "All kept data", + "a range saved under the old name still opens") + XCTAssertTrue(ClickyPerformanceView.ranges.contains("1 year")) + } + + func testABucketSavedBeforeSessionsLoads() throws { + let old = #"{"presses":3,"characters":2,"corrections":0,"activeSeconds":1.5,"bursts":1,"carriedBurst":false}"# + let bucket = try JSONDecoder().decode(ClickyPerformanceBucket.self, from: Data(old.utf8)) + XCTAssertEqual(bucket.characters, 2) + XCTAssertEqual(bucket.sessions, 0) } func testPreparedPerformanceReportPreservesTotalsAndRejectsCancellation() async { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/AgentBlameModelTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/AgentBlameModelTests.swift index c669ba4fd8..4afb898635 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/AgentBlameModelTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/AgentBlameModelTests.swift @@ -21,6 +21,50 @@ final class AgentBlameModelTests: XCTestCase { } } + /// A repository change used to start one blame per open note (51 to 66 GenesisTools launches at once in Genesis + /// Markdown, 2026-10-24); the notes of one repository now share one run, and a second wave waits for the first. + func testTheOpenNotesOfARepositoryShareOneBlameRun() async throws { + final class Counting: GenesisKitGTCLIProvider, @unchecked Sendable { + let lock = NSLock() + var runs: [[String]] = [] + var starts: [Date] = [] + override func agentBlame(repo: String, paths: [String]) throws -> AgentBlameResult { + lock.withLock { runs.append(paths); starts.append(Date()) } + Thread.sleep(forTimeInterval: 0.2) + return AgentBlameResult( + sources: [AgentBlameSource(provider: "claude", session: "aaaaaaaa-1111", turn: "t1", + ts: "2026-10-24T10:00:00Z", prompt: "Write it")], + files: paths.map { AgentBlameFile(path: $0, ranges: [[1, 2, 0]]) }, elapsedMs: 1) + } + override func repoStatus(_ folder: URL, commits: Int) throws -> GitRepoStatus? { nil } + } + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("blame-queue-\(UUID())") + .resolvingSymlinksInPath() + let provider = Counting() + let previous = GenesisKitGT.provider + GenesisKitGT.provider = provider + defer { GenesisKitGT.provider = previous } + AgentBlameQueue.shared.remember(root: folder, for: folder) + let interval = AgentBlameQueue.shared.minInterval + AgentBlameQueue.shared.minInterval = .milliseconds(700) + defer { AgentBlameQueue.shared.minInterval = interval } + let models = (0..<3).map { _ in AgentBlameModel() } + for (index, model) in models.enumerated() { model.follow(folder.appendingPathComponent("note\(index).md")) } + try await Task.sleep(for: .milliseconds(450)) + // A second wave while the first runs: one more run for all of it, after the first. + for model in models { model.reload() } + try await Task.sleep(for: .milliseconds(1400)) + + let (runs, starts) = provider.lock.withLock { (provider.runs, provider.starts) } + XCTAssertEqual(runs, [["note0.md", "note1.md", "note2.md"], ["note0.md", "note1.md", "note2.md"]], + "one run per wave for the three notes, never one per note") + if starts.count == 2 { + XCTAssertGreaterThanOrEqual(starts[1].timeIntervalSince(starts[0]), 0.65, "a repository's next run waits out minInterval") + } + XCTAssertEqual(models.map(\.path), ["note0.md", "note1.md", "note2.md"]) + XCTAssertEqual(models[1].source(forLines: 0...1)?.session, "aaaaaaaa-1111", "each note gets its own part") + } + func testDisablingAttributionDiscardsAnInFlightReply() async throws { final class Delayed: GenesisKitGTCLIProvider { let gate = DispatchSemaphore(value: 0) @@ -96,3 +140,58 @@ final class AgentBlameModelTests: XCTestCase { XCTAssertNil(model.source(forLines: 0 ... 2)) } } + +/// The spawn meter's burst rule and labels (`SpawnMeter`), which put a burst of child processes into the perf log. +final class SpawnStormTests: XCTestCase { + func testABurstIsReportedWhenItStartsWhileItLastsAndWhenItEnds() { + var storm = SpawnStorm() + let burst = SpawnStorm.burstStarts + for index in 0..<(burst - 1) { + XCTAssertEqual(storm.start("GenesisTools agents blame", at: Double(index) * 0.1), [], "under the burst size: quiet") + XCTAssertEqual(storm.finish(at: Double(index) * 0.1 + 0.05), []) + } + let first = storm.start("git status", at: Double(burst - 1) * 0.1) + XCTAssertEqual(first.count, 1) + XCTAssertTrue(first.first?.hasPrefix("spawn.storm \(burst) child processes started in 10 s") == true, first.first ?? "none") + XCTAssertTrue(first.first?.contains("GenesisTools agents blame ×\(burst - 1), git status ×1") == true, first.first ?? "none") + _ = storm.finish(at: Double(burst - 1) * 0.1 + 0.05) + + // It goes on at 4 starts a second: one "ongoing" line after 30 s, none before. + var ongoing: [String] = [] + var now = 4.0 + while now < 35 { + ongoing += storm.start("git status", at: now) + ongoing += storm.finish(at: now + 0.01) + now += 0.25 + } + XCTAssertEqual(ongoing.count, 1, ongoing.joined(separator: "\n")) + XCTAssertTrue(ongoing.first?.hasPrefix("spawn.storm ongoing 30 s") == true, ongoing.first ?? "none") + + // Minutes of quiet, then one child: it ends the burst, timed to the burst's last start (20:31 to 20:35 read + // "over after 209 s" for a 10 s burst before). + let over = storm.start("git status", at: 300) + XCTAssertEqual(over.count, 1) + XCTAssertTrue(over.first?.hasPrefix("spawn.storm over after 30 s") == true, over.first ?? "none") + XCTAssertEqual(storm.finish(at: 300.1), [], "the quiet child after it starts no new burst") + } + + func testManyChildrenRunningAtOnceAreABurstToo() { + var storm = SpawnStorm() + var lines: [String] = [] + for index in 0..<SpawnStorm.burstRunning { lines += storm.start("rg", at: Double(index)) } + XCTAssertTrue(lines.last?.contains("\(SpawnStorm.burstRunning) running") == true, lines.last ?? "nil") + } + + func testLabelsNameTheCommandNotItsPaths() { + XCTAssertEqual(SpawnMeter.label(executable: URL(fileURLWithPath: "/usr/bin/git"), + arguments: ["-C", "/Users/x/vault", "-c", "core.quotepath=off", "status", "-z"]), + "git status") + XCTAssertEqual(SpawnMeter.label(executable: URL(fileURLWithPath: "/Applications/GenesisTools.app/Contents/MacOS/GenesisTools"), + arguments: ["/Users/x/.genesis-tools/bin/gt-agents", "/repo/src/agents/index.ts", "blame", + "--repo", "/vault", "--files", "a/b.md", "--json"]), + "GenesisTools agents blame") + XCTAssertEqual(SpawnMeter.label(executable: URL(fileURLWithPath: "/usr/bin/git"), + arguments: ["-c", "core.quotepath=off", "cat-file", "-t", "96c986d3c53f34fde9beb70d8f3e3c987edcfe3f^{commit}"]), + "git cat-file", "a commit id would split one command's count") + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFailureTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFailureTests.swift new file mode 100644 index 0000000000..b8bd85281e --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFailureTests.swift @@ -0,0 +1,138 @@ +import XCTest +@testable import GenesisKit + +/// A failed git read reaches the user as one sentence with the raw text behind Details, keeps what was shown before, +/// and is never taken for an empty answer. +@MainActor +final class GitFailureTests: XCTestCase { + private func git(_ args: [String], in repo: URL) throws { + let result = try GenesisKitGTCLIProvider().git(args, in: repo, input: nil, gitDirectory: nil, environment: [ + "GIT_AUTHOR_NAME": "Alice", "GIT_AUTHOR_EMAIL": "alice@example.com", + "GIT_COMMITTER_NAME": "Alice", "GIT_COMMITTER_EMAIL": "alice@example.com", + ]) + XCTAssertEqual(result.status, 0, String(decoding: result.stderr, as: UTF8.self)) + } + + private func repoWithOneCommit() throws -> (repo: URL, note: URL) { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gitfailure-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + let note = repo.appendingPathComponent("plan.md") + try git(["init", "-q", "-b", "main"], in: repo) + try "one\n".write(to: note, atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-q", "-m", "First"], in: repo) + return (repo, note) + } + + /// A provider whose history or status reads fail while `failing` is set. + private final class FailingProvider: GenesisKitGTCLIProvider, @unchecked Sendable { + private let lock = NSLock() + private var _failing = true + var failing: Bool { + get { lock.withLock { _failing } } + set { lock.withLock { _failing = newValue } } + } + + override func history(of file: URL, limit: Int) throws -> [GitCommit] { + if failing { throw ReviewError.git("git log --follow: fatal: unable to read index") } + return try super.history(of: file, limit: limit) + } + + override func repoStatus(_ folder: URL, commits: Int) throws -> GitRepoStatus? { + if failing { throw ReviewError.git("git status: fatal: Unable to create '/x/.git/index.lock': File exists.") } + return try super.repoStatus(folder, commits: commits) + } + } + + func testKnownCausesReadAsOneSentenceWithTheRawTextBehindDetails() { + let base = GitFailure(ReviewError.git("no merge base with main"), fallback: "fallback") + XCTAssertEqual(base.message, "This branch shares no history with its base branch, so there is nothing to compare.") + XCTAssertEqual(base.detail, "no merge base with main") + + let missing = GitFailure(ReviewError.git("git show HEAD:a.md: fatal: path 'a.md' exists on disk, but not in 'HEAD'"), fallback: "fallback") + XCTAssertEqual(missing.message, "That version of the file is not in git.") + + let locked = GitFailure(ReviewError.git("fatal: Unable to create '/x/.git/index.lock': File exists."), fallback: "fallback") + XCTAssertEqual(locked.message, "Another git command is using this repository. Try again in a moment.") + + let unknown = GitFailure(ReviewError.git("fatal: something new"), fallback: "Git could not read this file.") + XCTAssertEqual(unknown.message, "Git could not read this file.") + XCTAssertEqual(unknown.detail, "fatal: something new") + + let outside = GitFailure(ReviewError.notRepository("/tmp/x"), fallback: "fallback") + XCTAssertTrue(outside.message.hasSuffix("is not a git repository, so it has no changes to show."), outside.message) + XCTAssertNil(outside.detail, "the sentence is the whole story") + } + + func testAFailedHistoryReadIsNotNoCommitsYet() throws { + let (repo, note) = try repoWithOneCommit() + defer { try? FileManager.default.removeItem(at: repo) } + let provider = FailingProvider() + XCTAssertThrowsError(try GitFileDiffModel.sides(note, text: "one\n", scope: .lastCommit, pick: nil, provider: provider)) { error in + let failure = (error as? GitFailure.Thrown)?.failure + XCTAssertEqual(failure?.message, "Could not read this file's history. Try again.") + XCTAssertEqual(failure?.detail?.contains("unable to read index"), true) + } + // A scope that does not need the history still shows its diff. + XCTAssertEqual(try GitFileDiffModel.sides(note, text: "two\n", scope: .uncommitted, pick: nil, provider: provider).old, "one\n") + // Read, the history gives the commit. + provider.failing = false + XCTAssertEqual(try GitFileDiffModel.sides(note, text: "one\n", scope: .lastCommit, pick: nil, provider: provider).commit?.subject, "First") + } + + func testTheModelSaysTheSentenceAndTryAgainReadsAgain() async throws { + let (repo, note) = try repoWithOneCommit() + defer { try? FileManager.default.removeItem(at: repo) } + let provider = FailingProvider() + let previous = GenesisKitGT.provider + GenesisKitGT.provider = provider + defer { GenesisKitGT.provider = previous } + + let model = GitFileDiffModel() + model.load(note, text: "one\n", scope: .lastCommit) + await wait("the failure") { if case .failed = model.phase { true } else { false } } + XCTAssertEqual(model.phase, .failed("Could not read this file's history. Try again.")) + XCTAssertNotNil(model.failure?.detail) + + provider.failing = false + model.retry() + await wait("the empty commit diff") { if case .ready = model.phase { true } else { false } } + XCTAssertNil(model.failure) + XCTAssertEqual(model.commit?.subject, "First") + } + + func testAFailedStatusKeepsTheLastListAndSaysSo() async throws { + let (repo, note) = try repoWithOneCommit() + defer { try? FileManager.default.removeItem(at: repo) } + try "one\ntwo\n".write(to: note, atomically: true, encoding: .utf8) + let provider = FailingProvider() + provider.failing = false + let previous = GenesisKitGT.provider + GenesisKitGT.provider = provider + defer { GenesisKitGT.provider = previous } + + let model = GitRepoModel(root: repo) + await wait("the status") { model.status?.entry(for: note) != nil } + let shown = model.status + let generation = model.generation + + provider.failing = true + model.retry() + await wait("the failed read") { model.generation > generation } + XCTAssertEqual(model.status, shown, "the list stays") + XCTAssertEqual(model.failure?.message, "Another git command is using this repository. Try again in a moment.") + + provider.failing = false + model.retry() + await wait("the read after Try again") { model.failure == nil } + XCTAssertEqual(model.status, shown) + } + + private func wait(_ what: String, timeout: TimeInterval = 5, until done: () -> Bool) async { + let end = Date().addingTimeInterval(timeout) + while !done(), Date() < end { + try? await Task.sleep(for: .milliseconds(50)) + } + XCTAssertTrue(done(), "timed out waiting for \(what)") + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift index 740477f2d6..2567e267b7 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitFileDiffTests.swift @@ -189,14 +189,14 @@ final class GitFileDiffTests: XCTestCase { } } - func testPanelRetargetsEqualGenerationsAndRejectsOldDates() async throws { + func testPanelRetargetsANewRootAndRejectsOldDates() async throws { let first = URL(fileURLWithPath: "/fixture/first"), second = URL(fileURLWithPath: "/fixture/second") - let a = [GitStatusEntry(path: "a.md", unstaged: .modified)] - let b = [GitStatusEntry(path: "b.md", unstaged: .modified)] - let dates = GitChangeDates() + let a = GitRepoStatus(root: first, branch: "main", entries: [GitStatusEntry(path: "a.md", unstaged: .modified)]) + let b = GitRepoStatus(root: second, branch: "main", entries: [GitStatusEntry(path: "b.md", unstaged: .modified)]) + let lists = GitRepoPanelLists() let started = expectation(description: "old dates started"), finished = expectation(description: "old dates finished") let gate = DispatchSemaphore(value: 0) - dates.readDates = { root, paths in + lists.readDates = { root, paths in if root == first { started.fulfill() _ = gate.wait(timeout: .now() + 5) @@ -204,31 +204,45 @@ final class GitFileDiffTests: XCTestCase { } return [paths[0]: .distantPast] } - dates.load(root: first, entries: a, generation: 1) + lists.update(.init(root: first, order: .name, filter: "", untracked: true), status: a, reads: 1) await fulfillment(of: [started], timeout: 3) - dates.load(root: second, entries: b, generation: 1) + lists.update(.init(root: second, order: .name, filter: "", untracked: true), status: b, reads: 1) + XCTAssertTrue(lists.lists(for: first).changed.isEmpty, "a new root never offers old paths") let deadline = Date().addingTimeInterval(3) - while dates.dates["b.md"] == nil, Date() < deadline { try await Task.sleep(for: .milliseconds(20)) } - XCTAssertEqual(Set(dates.dates.keys), ["b.md"]) + while lists.lists(for: second).changed.isEmpty, Date() < deadline { try await Task.sleep(for: .milliseconds(20)) } + XCTAssertEqual(lists.lists(for: second).changed.map(\.path), ["b.md"]) gate.signal() await fulfillment(of: [finished], timeout: 3) try await Task.sleep(for: .milliseconds(100)) - XCTAssertEqual(Set(dates.dates.keys), ["b.md"]) - XCTAssertNotEqual(GitChangeDates.Key(root: first, generation: 1), GitChangeDates.Key(root: second, generation: 1)) + XCTAssertEqual(Set(lists.lists.dates.keys), ["b.md"], "the old root's late dates are dropped") + } + /// A status read that finds the same status and the same dates changes nothing: no new lists, no publish. Before, + /// every read re-read the dates, derived the lists twice and re-ran the panel six times (2026-10-25). + func testAnEqualReadChangesNothing() async throws { + let root = URL(fileURLWithPath: "/fixture/equal") + let status = GitRepoStatus(root: root, branch: "main", entries: [GitStatusEntry(path: "a.md", unstaged: .modified)]) let lists = GitRepoPanelLists() - let inputs = GitRepoPanelLists.Inputs(root: first, generation: 1, datesVersion: 1, order: .name, filter: "", untracked: true) - lists.update(inputs, entries: a, dates: [:]) - let loaded = Date().addingTimeInterval(3) - while lists.lists.changed.isEmpty, Date() < loaded { try await Task.sleep(for: .milliseconds(20)) } - XCTAssertEqual(lists.lists(for: first).changed.map(\.path), ["a.md"]) - XCTAssertTrue(lists.lists(for: second).changed.isEmpty, "a new root must never offer old paths") - var next = inputs - next.root = second - lists.update(next, entries: b, dates: [:]) - let updated = Date().addingTimeInterval(3) - while lists.lists.changed.isEmpty, Date() < updated { try await Task.sleep(for: .milliseconds(20)) } - XCTAssertEqual(lists.lists(for: second).changed.map(\.path), ["b.md"]) + final class Clock: @unchecked Sendable { var date = Date(timeIntervalSince1970: 1_000) } + let clock = Clock() + lists.readDates = { _, paths in [paths[0]: clock.date] } + let inputs = GitRepoPanelLists.Inputs(root: root, order: .lastUpdate, filter: "", untracked: true) + lists.update(inputs, status: status, reads: 1) + let deadline = Date().addingTimeInterval(3) + while lists.settled != inputs, Date() < deadline { try await Task.sleep(for: .milliseconds(20)) } + let version = lists.version + var published = 0 + let watch = lists.objectWillChange.sink { published += 1 } + lists.update(inputs, status: status, reads: 2) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(lists.version, version) + XCTAssertEqual(published, 0) + clock.date = Date(timeIntervalSince1970: 2_000) + lists.update(inputs, status: status, reads: 3) + let moved = Date().addingTimeInterval(3) + while lists.version == version, Date() < moved { try await Task.sleep(for: .milliseconds(20)) } + XCTAssertEqual(lists.lists.dates["a.md"], clock.date, "a newer date on the same status is a change") + watch.cancel() } func testRenderedMarksAreComputedOffMainCachedAndFenced() async throws { @@ -420,6 +434,34 @@ final class GitFileDiffTests: XCTestCase { XCTAssertEqual(model.workingText(for: doc), "recovered draft") } + /// Martin, 2026-10-25: "No unstaged changes in this file." flashed every few seconds. Each status refresh reloaded the + /// diff, and a reload with no diff on screen went through `.loading`, which drew nothing for a moment. + func testReloadingTheSameEmptyDiffKeepsItsMessage() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("diff-flash-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let note = root.appendingPathComponent("note.md") + _ = try git(["init", "-q", "-b", "main"], in: root) + try "saved\n".write(to: note, atomically: true, encoding: .utf8) + _ = try git(["add", "."], in: root) + _ = try git(["commit", "-q", "-m", "Saved"], in: root) + let model = GitFileDiffModel() + model.load(note, text: "saved\n", scope: .unstaged) + let deadline = Date().addingTimeInterval(5) + while model.phase == .loading || model.phase == .idle, Date() < deadline { try await Task.sleep(for: .milliseconds(20)) } + XCTAssertEqual(model.phase, .empty("No unstaged changes in this file.")) + var phases: [GitFileDiffModel.Phase] = [] + let watch = model.$phase.dropFirst().sink { phases.append($0) } + for _ in 0..<3 { + model.load(note, text: "saved\n", scope: .unstaged) + try await Task.sleep(for: .milliseconds(300)) + } + watch.cancel() + XCTAssertFalse(phases.contains(.loading), "a reload of the same question keeps the message: \(phases)") + model.load(note, text: "saved\n", scope: .staged) + XCTAssertEqual(model.phase, .loading, "another question still shows that it is loading") + } + func testNormalAndHistoricalDiffCountsRunOffMain() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("diff-worker-\(UUID())") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift index 972e33d9f6..09346c758a 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift @@ -95,6 +95,26 @@ final class GitPickerTests: XCTestCase { XCTAssertEqual(unpicked.highlighted, choices[0].id, "Down from nothing starts at the top") } + func testShiftExtendsARangeThatKeepsEveryCommitBetweenItsEnds() { + let choices = Self.choices(10) + var state = GitCommitPickerState(choices: choices, selection: choices[2].sha) + XCTAssertNil(state.range, "one commit is no range") + state.extend(1) + state.extend(1) + XCTAssertEqual(state.range?.map(\.subject), ["Change number 2", "Change number 3", "Change number 4"]) + state.extend(-3) + XCTAssertEqual(state.range?.map(\.subject), ["Change number 1", "Change number 2"], "the range grows from its start") + state.move(1) + XCTAssertNil(state.range, "a plain move is back to one commit") + + // A Shift-click across a filter: the hidden commits between the ends still belong to the range. + state.highlighted = choices[1].id + state.extend(to: choices[1].id) + state.query = "number 6" + state.extend(to: choices[6].id) + XCTAssertEqual(state.range?.count, 6, "commits 1 to 6, though the filter shows only one row") + } + func testTheDetailsComeFromOneGitLogWithTheFileAndTheCommitCounts() throws { let repo = FileManager.default.temporaryDirectory.appendingPathComponent("commit-details-\(UUID().uuidString)") try FileManager.default.createDirectory(at: repo.appendingPathComponent("notes"), withIntermediateDirectories: true) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitQueriesTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitQueriesTests.swift index aafce48a2c..2f63abdc69 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitQueriesTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitQueriesTests.swift @@ -82,7 +82,7 @@ final class GitQueriesTests: XCTestCase { XCTAssertEqual(state.entry?.unstaged, .modified) XCTAssertEqual(state.unstagedAdditions, 1) XCTAssertEqual(state.stagedAdditions + state.stagedDeletions, 3) - XCTAssertEqual(state.summary, "M · 1 line unstaged · 3 lines staged") + XCTAssertEqual(state.summary, "Modified · 1 line unstaged · 3 lines staged") XCTAssertEqual(try provider.contents(of: note, at: .head), "one\ntwo\nthree\n") XCTAssertEqual(try provider.contents(of: note, at: .index), "one\n2\nthree\nfour\n") diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitRepoPanelKeyboardTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitRepoPanelKeyboardTests.swift new file mode 100644 index 0000000000..b9ee9bed16 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitRepoPanelKeyboardTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import GenesisKit + +/// The repository panel from the keyboard: the keys walk the rows in drawn order (headings too: they fold), and the branch says its counts in words. +@MainActor +final class GitRepoPanelKeyboardTests: XCTestCase { + func testTheKeysWalkTheRowsInTheOrderThePanelDrawsThem() { + var lists = GitRepoPanelLists.Lists() + lists.staged = [GitStatusEntry(path: "a.md", staged: .modified)] + lists.changed = [GitStatusEntry(path: "b.md", unstaged: .modified)] + lists.groups = [GitUntrackedGroup(folder: "data", files: [ + GitStatusEntry(path: "data/1.json", unstaged: .untracked), GitStatusEntry(path: "data/2.json", unstaged: .untracked), + ])] + lists.loose = [GitStatusEntry(path: "c.md", unstaged: .untracked)] + lists.untrackedTotal = 3 + + func keys(_ order: GitChangeOrder, untracked: Bool, open: Bool) -> [String] { + GitRepoPanel.keyRows(GitRepoPanel.rows(lists, order: order, untracked: untracked, isOpen: { _ in open })) + } + XCTAssertEqual(keys(.name, untracked: true, open: false), ["section:staged", "staged:a.md", "section:changes", "changes:b.md", + "section:untracked", "folder-row:data", "untracked:c.md"]) + XCTAssertEqual(keys(.name, untracked: true, open: true), + ["section:staged", "staged:a.md", "section:changes", "changes:b.md", "section:untracked", "folder-row:data", + "folder:data:data/1.json", "folder:data:data/2.json", "untracked:c.md"], + "an open folder lists every file: the table builds only the rows on screen, so there are no pages") + XCTAssertEqual(keys(.name, untracked: false, open: true), ["section:staged", "staged:a.md", "section:changes", "changes:b.md"]) + } + + /// Martin, 2026-10-25: the changes section topped out at a note 5 h old while untracked files a minute old sat below. + func testLastUpdateIsOneListNewestFirst() { + let now = Date() + let entries = [ + GitStatusEntry(path: "Dev/Index.md", unstaged: .modified), + GitStatusEntry(path: "Health/new.md", unstaged: .untracked), + GitStatusEntry(path: "data/run/1.json", unstaged: .untracked), + GitStatusEntry(path: "data/run/2.json", unstaged: .untracked), + GitStatusEntry(path: "staged.md", staged: .added), + ] + let dates = ["Dev/Index.md": now.addingTimeInterval(-5 * 3600), "Health/new.md": now.addingTimeInterval(-60), + "data/run/1.json": now.addingTimeInterval(-7200), "data/run/2.json": now.addingTimeInterval(-600), + "staged.md": now] + let lists = GitRepoPanelLists.derive(entries, dates: dates, inputs: .init(root: URL(fileURLWithPath: "/r"), order: .lastUpdate, filter: "", untracked: true)) + let rows = GitRepoPanel.rows(lists, order: .lastUpdate, untracked: true, isOpen: { _ in false }) + XCTAssertEqual(rows.map(\.id), ["section:staged", "staged:staged.md", "section:changes", + "untracked:Health/new.md", "folder-row:data", "changes:Dev/Index.md"]) + XCTAssertEqual(lists.places["data/run/2.json"], .init(id: "folder:data:data/run/2.json", folder: "data"), "the reveal button's lookup") + guard case .folder(_, let count, let newest, _) = rows[4] else { return XCTFail("a folder row") } + XCTAssertEqual(count, 2) + XCTAssertEqual(newest, dates["data/run/2.json"], "a folder ranks by its newest file") + } + + func testTheKeyboardWalksEveryRowAndLetsEscapeThroughWhenNothingIsKeyed() { + let rows: [GitPanelRow] = [ + .section(key: "changes", title: "Changes · 2", open: true), + .file(entry: GitStatusEntry(path: "a.md", unstaged: .modified), staged: false, id: "changes:a.md", indent: false, date: nil), + .folder(folder: "data", count: 2, newest: nil, open: false), + .section(key: "untracked", title: "Untracked · 0", open: false), + ] + XCTAssertEqual(GitPanelRow.action(for: .escape, rows: rows, cursor: nil), .none, "Esc reaches the window or a sheet") + XCTAssertEqual(GitPanelRow.action(for: .escape, rows: rows, cursor: 1), .clear) + XCTAssertEqual(GitPanelRow.action(for: .down, rows: rows, cursor: nil), .select(0)) + XCTAssertEqual(GitPanelRow.action(for: .down, rows: rows, cursor: 1), .select(2)) + XCTAssertEqual(GitPanelRow.action(for: .down, rows: rows, cursor: 3), .select(3), "the last row stays") + XCTAssertEqual(GitPanelRow.action(for: .up, rows: rows, cursor: 1), .select(0), "a heading is keyed: it folds") + XCTAssertEqual(GitPanelRow.action(for: .right, rows: rows, cursor: 2), .setOpen(2, true)) + XCTAssertEqual(GitPanelRow.action(for: .left, rows: rows, cursor: 2), .select(0), "Left on a closed folder keys its heading") + XCTAssertEqual(GitPanelRow.action(for: .left, rows: rows, cursor: 0), .setOpen(0, false), "Left folds an open section") + XCTAssertEqual(GitPanelRow.action(for: .left, rows: rows, cursor: 3), .none, "a folded section does not fold again") + XCTAssertEqual(GitPanelRow.action(for: .right, rows: rows, cursor: 3), .setOpen(3, true)) + XCTAssertEqual(GitPanelRow.action(for: .left, rows: rows, cursor: 1), .select(0), "Left on a file keys its heading") + let inFolder: [GitPanelRow] = [ + .section(key: "untracked", title: "Untracked · 2", open: true), + .folder(folder: "data", count: 2, newest: nil, open: true), + .file(entry: GitStatusEntry(path: "data/1.json", unstaged: .untracked), staged: false, id: "folder:data:data/1.json", indent: true, date: nil), + ] + XCTAssertEqual(GitPanelRow.action(for: .left, rows: inFolder, cursor: 2), .select(1), "a folder's file keys its folder") + XCTAssertEqual(GitPanelRow.action(for: .left, rows: inFolder, cursor: 1), .setOpen(1, false), "Left folds an open folder") + XCTAssertEqual(GitPanelRow.action(for: .right, rows: inFolder, cursor: 1), .none, "an open folder stays open") + XCTAssertEqual(GitPanelRow.action(for: .open, rows: rows, cursor: 1), .activate(1)) + XCTAssertEqual(rows[3].spoken, "Untracked · 0, section, collapsed") + } + + /// Martin, 2026-10-25: "i should be able to fold the Changes". A folded section keeps its heading and drops its rows, + /// in both layouts. + func testAFoldedSectionKeepsOnlyItsHeading() { + var lists = GitRepoPanelLists.Lists() + lists.staged = [GitStatusEntry(path: "a.md", staged: .modified)] + lists.changed = [GitStatusEntry(path: "b.md", unstaged: .modified)] + lists.loose = [GitStatusEntry(path: "c.md", unstaged: .untracked)] + lists.untrackedTotal = 1 + lists.recent = [.file(lists.changed[0]), .file(lists.loose[0])] + let byName = GitRepoPanel.rows(lists, order: .name, untracked: true, folded: ["changes"], isOpen: { _ in false }) + XCTAssertEqual(byName.map(\.id), ["section:staged", "staged:a.md", "section:changes", "section:untracked", "untracked:c.md"]) + guard case .section(_, _, let open) = byName[2] else { return XCTFail("the changes heading") } + XCTAssertFalse(open) + let byDate = GitRepoPanel.rows(lists, order: .lastUpdate, untracked: true, folded: ["staged", "changes"], isOpen: { _ in false }) + XCTAssertEqual(byDate.map(\.id), ["section:staged", "section:changes"]) + } + + func testAheadAndBehindAreWords() { + let root = URL(fileURLWithPath: "/tmp/repo") + XCTAssertEqual(GitRepoPanel.trackingText(GitRepoStatus(root: root, branch: "main")), "No upstream branch") + XCTAssertEqual(GitRepoPanel.trackingText(GitRepoStatus(root: root, branch: "main", upstream: "origin/main")), + "Tracks origin/main, up to date") + XCTAssertEqual(GitRepoPanel.trackingText(GitRepoStatus(root: root, branch: "main", upstream: "origin/main", ahead: 2, behind: 1)), + "Tracks origin/main: 2 commits ahead, 1 commit behind") + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift index d7f429adf0..6100bcdc02 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitReposTests.swift @@ -55,18 +55,37 @@ final class GitReposTests: XCTestCase { let file = GitFileStateModel() file.follow(note) await wait("the file state") { file.state?.unstagedAdditions == 1 } - XCTAssertEqual(file.state?.summary, "M · 1 line unstaged") + XCTAssertEqual(file.state?.summary, "Modified · 1 line unstaged") // A stage is a change to the index: git's folder event refreshes both, nobody calls refresh. try git(["add", "plan.md"], in: repo) await wait("the staged state") { file.state?.hasStaged == true && file.state?.hasUnstaged == false } XCTAssertEqual(model.status?.entry(for: note)?.staged, .modified) - XCTAssertEqual(file.state?.summary, "M · 1 line staged") + XCTAssertEqual(file.state?.summary, "Modified · 1 line staged") file.follow(nil) XCTAssertNil(file.state) } + /// Martin, 2026-10-25: the panel's "Last update" showed a note from 5 h ago while agents wrote files every minute; + /// only git's own folder events and the viewer's saves refreshed the status. + func testAFileAnotherProgramWritesRefreshesTheStatus() async throws { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gitworktree-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: repo.appendingPathComponent("notes"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: repo) } + try git(["init", "-q", "-b", "main"], in: repo) + try git(["commit", "--allow-empty", "-q", "-m", "First"], in: repo) + let saved = GitRepoModel.workingTreeInterval + GitRepoModel.workingTreeInterval = 0.3 + defer { GitRepoModel.workingTreeInterval = saved } + let model = GitRepoModel(root: repo) + await wait("the first status") { model.status != nil } + XCTAssertEqual(model.status?.entries, []) + // Written by "another program": nobody calls refresh, git's folder does not change. + try "new\n".write(to: repo.appendingPathComponent("notes/agent.md"), atomically: true, encoding: .utf8) + await wait("the untracked file", timeout: 8) { model.status?.entries.map(\.path) == ["notes/agent.md"] } + } + func testAnotherFileInTheSameFolderLoadsItsOwnStateAndHistory() async throws { // The status bar and the git sidebar outlive a document switch: a sibling note must load with no git event. let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gitsibling-\(UUID().uuidString)") @@ -131,23 +150,16 @@ final class GitReposTests: XCTestCase { XCTAssertFalse(tree.hasRepository) } - func testTheRevealButtonFindsTheOpenFileInItsListFolderAndPage() { - func entry(_ path: String, untracked: Bool = false) -> GitStatusEntry { - GitStatusEntry(path: path, oldPath: nil, staged: nil, unstaged: untracked ? .untracked : .modified) - } - var lists = GitRepoPanelLists.Lists() - lists.changed = [entry("a.md"), entry("notes/b.md")] - lists.groups = [GitUntrackedGroup(folder: "drafts", files: (0..<150).map { entry("drafts/\($0).md", untracked: true) })] - let changed = GitRepoPanel.revealTarget(path: "notes/b.md", in: lists) - XCTAssertEqual(changed?.key, "changes") - XCTAssertNil(changed?.folder) - XCTAssertEqual(changed?.index, 1) - // Folded and past the first page of 100: the folder opens and the page grows. - let folded = GitRepoPanel.revealTarget(path: "drafts/120.md", in: lists) - XCTAssertEqual(folded?.key, "folder:drafts") - XCTAssertEqual(folded?.folder, "drafts") - XCTAssertEqual(folded?.index, 120) - XCTAssertNil(GitRepoPanel.revealTarget(path: "clean.md", in: lists), "a file with no change has no row") + func testTheRevealButtonFindsTheOpenFileInItsListAndFolder() { + let entries = [GitStatusEntry(path: "a.md", unstaged: .modified), GitStatusEntry(path: "notes/b.md", unstaged: .modified)] + + (0..<150).map { GitStatusEntry(path: "drafts/\($0).md", unstaged: .untracked) } + let lists = GitRepoPanelLists.derive(entries, dates: [:], inputs: .init(root: URL(fileURLWithPath: "/r"), order: .name, filter: "", untracked: true)) + XCTAssertEqual(lists.places["notes/b.md"], .init(id: "changes:notes/b.md")) + // Folded: the reveal opens the folder; with no pages, the row is in the table once the folder is open. + XCTAssertEqual(lists.places["drafts/120.md"], .init(id: "folder:drafts:drafts/120.md", folder: "drafts")) + let open = GitRepoPanel.rows(lists, order: .name, untracked: true, isOpen: { $0 == "drafts" }) + XCTAssertTrue(open.contains { $0.id == "folder:drafts:drafts/120.md" }) + XCTAssertNil(lists.places["clean.md"], "a file with no change has no row") } func testTheTreeCountsSumEachFilesLinesIntoItsFolders() { @@ -194,6 +206,31 @@ final class GitReposTests: XCTestCase { XCTAssertNil(marks.count(for: tree), "the tree's root sums nothing") } + /// Martin, 2026-10-25: a moved data folder left its files listed as deleted, and opening one said "This file is not + /// in a git repository": git ran in the deleted file's folder, which no longer exists. + func testAFolderThatNoLongerExistsFindsTheRepositoryAroundIt() async throws { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gitmoved-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: repo.appendingPathComponent("data/run"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: repo) } + try git(["init", "-q", "-b", "main"], in: repo) + try "export {}\n".write(to: repo.appendingPathComponent("data/run/compiler.ts"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-q", "-m", "First"], in: repo) + try FileManager.default.moveItem(at: repo.appendingPathComponent("data"), to: repo.appendingPathComponent("moved")) + let gone = repo.appendingPathComponent("data/run") + XCTAssertFalse(FileManager.default.fileExists(atPath: gone.path)) + + XCTAssertEqual(GitQueries(provider: GenesisKitGTCLIProvider()).root(of: gone).map { GitWorkingTreeSource.realPath($0.path) }, + GitWorkingTreeSource.realPath(repo.path)) + _ = GitRepos.shared.lookup(for: gone) + await wait("the repository of a folder that is gone") { GitRepos.shared.model(for: gone) != nil } + + let sides = try GitFileDiffModel.sides(gone.appendingPathComponent("compiler.ts"), text: nil, scope: .uncommitted, + pick: nil, provider: GenesisKitGTCLIProvider()) + XCTAssertEqual(sides.old, "export {}\n", "the deleted file's last committed text is the old side") + XCTAssertNil(sides.new) + } + func testAFolderOutsideARepositoryHasNoModel() async throws { let folder = FileManager.default.temporaryDirectory.appendingPathComponent("norepo-\(UUID().uuidString)") try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitWorktreeRenamesTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitWorktreeRenamesTests.swift new file mode 100644 index 0000000000..7cf0b2ae95 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitWorktreeRenamesTests.swift @@ -0,0 +1,154 @@ +import GenesisKitFixtures +import XCTest +@testable import GenesisKit + +/// Moves git does not report (a folder moved without `git mv`): the shared pairing rule on the contract both languages +/// pass, then a scratch repository through every reader that shows working-tree changes. +final class GitWorktreeRenamesTests: XCTestCase { + private struct Contract: Decodable { + struct Side: Decodable { + let path: String + let oid: String + } + struct Case: Decodable { + let name: String + let deleted: [Side] + let untracked: [Side] + let pairs: [[String]] + } + let cases: [Case] + } + + func testEveryContractCasePairsAsTheCLIDoes() throws { + let contract = try JSONDecoder().decode(Contract.self, from: KitFixture.data("contract-worktree-renames.json", in: "Git")) + XCTAssertGreaterThanOrEqual(contract.cases.count, 6) + for item in contract.cases { + let pairs = GitWorktreeRenames.pair(deleted: item.deleted.map { .init(path: $0.path, oid: $0.oid) }, + untracked: item.untracked.map { .init(path: $0.path, oid: $0.oid) }) + XCTAssertEqual(pairs.map { [$0.old, $0.new] }, item.pairs, item.name) + } + } + + private let provider = GenesisKitGTCLIProvider() + + private func git(_ args: [String], in repo: URL) throws { + let result = try provider.git(args, in: repo, input: nil, gitDirectory: nil, environment: [ + "GIT_AUTHOR_NAME": "Alice", "GIT_AUTHOR_EMAIL": "alice@example.com", + "GIT_COMMITTER_NAME": "Alice", "GIT_COMMITTER_EMAIL": "alice@example.com", + ]) + XCTAssertEqual(result.status, 0, String(decoding: result.stderr, as: UTF8.self)) + } + + /// A repository whose `data/run` folder moved to `moved/run` outside git, with one file edited after the move, one + /// empty file and one file really deleted. + private func movedFolderRepository() throws -> URL { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("gitrenames-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: repo.appendingPathComponent("data/run"), withIntermediateDirectories: true) + try "export const compiler = 1\n".write(to: repo.appendingPathComponent("data/run/compiler.ts"), atomically: true, encoding: .utf8) + try "line one\nline two\n".write(to: repo.appendingPathComponent("data/run/notes.md"), atomically: true, encoding: .utf8) + try "".write(to: repo.appendingPathComponent("data/run/empty.err"), atomically: true, encoding: .utf8) + try "bye\n".write(to: repo.appendingPathComponent("gone.md"), atomically: true, encoding: .utf8) + try git(["init", "-q", "-b", "main"], in: repo) + try git(["add", "."], in: repo) + try git(["commit", "-q", "-m", "First"], in: repo) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("moved"), withIntermediateDirectories: true) + try FileManager.default.moveItem(at: repo.appendingPathComponent("data/run"), to: repo.appendingPathComponent("moved/run")) + try "line one\nline two, edited\n".write(to: repo.appendingPathComponent("moved/run/notes.md"), atomically: true, encoding: .utf8) + try FileManager.default.removeItem(at: repo.appendingPathComponent("gone.md")) + return repo + } + + func testTheRepositoryStatusShowsAMovedFolderAsRenames() throws { + let repo = try movedFolderRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let status = try XCTUnwrap(try provider.repoStatus(repo, commits: 0)) + let byPath = Dictionary(uniqueKeysWithValues: status.entries.map { ($0.path, $0) }) + XCTAssertEqual(byPath["moved/run/compiler.ts"]?.unstaged, .renamed) + XCTAssertEqual(byPath["moved/run/compiler.ts"]?.oldPath, "data/run/compiler.ts") + XCTAssertNil(byPath["data/run/compiler.ts"], "the old path is not listed again as deleted") + // Exact content only: an edited file and an empty file stay deleted plus untracked, as git itself reports them. + XCTAssertEqual(byPath["data/run/notes.md"]?.unstaged, .deleted) + XCTAssertEqual(byPath["moved/run/notes.md"]?.unstaged, .untracked) + XCTAssertEqual(byPath["data/run/empty.err"]?.unstaged, .deleted) + XCTAssertEqual(byPath["moved/run/empty.err"]?.unstaged, .untracked) + XCTAssertEqual(byPath["gone.md"]?.unstaged, .deleted) + } + + func testTheWorkingTreeDiffTheHubShowsPairsTheMoveToo() throws { + let repo = try movedFolderRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let snapshot = try GitWorkingTreeSource(repo: repo).load(scope: .uncommitted) + let file = try XCTUnwrap(snapshot.files.first { $0.path == "moved/run/compiler.ts" }) + XCTAssertEqual(file.status, .renamed) + XCTAssertEqual(file.oldPath, "data/run/compiler.ts") + XCTAssertEqual(file.oldContents, "export const compiler = 1\n") + XCTAssertEqual(file.newContents, "export const compiler = 1\n") + XCTAssertEqual(file.additions, 0) + XCTAssertEqual(file.deletions, 0) + XCTAssertFalse(snapshot.files.contains { $0.path == "data/run/compiler.ts" }) + XCTAssertEqual(snapshot.files.first { $0.path == "gone.md" }?.status, .deleted) + let unstaged = try GitWorkingTreeSource(repo: repo).load(scope: .unstaged) + XCTAssertEqual(unstaged.files.first { $0.path == "moved/run/compiler.ts" }?.status, .renamed) + } + + /// The hash cache keys on the ctime too: a rewrite that keeps the size and puts the mtime back is hashed again. + func testARewriteThatKeepsSizeAndMtimeIsHashedAgain() throws { + let repo = try movedFolderRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let moved = repo.appendingPathComponent("moved/run/compiler.ts") + func pairs() -> [String] { + GitWorktreeRenames.find(in: repo, deleted: ["data/run/compiler.ts"], untracked: ["moved/run/compiler.ts"], + revision: "", provider: provider).map(\.new) + } + XCTAssertEqual(pairs(), ["moved/run/compiler.ts"]) + let mtime = try XCTUnwrap(try FileManager.default.attributesOfItem(atPath: moved.path)[.modificationDate] as? Date) + let handle = try FileHandle(forWritingTo: moved) + try handle.write(contentsOf: Data("export const compiler = 2\n".utf8)) + try handle.close() + try FileManager.default.setAttributes([.modificationDate: mtime], ofItemAtPath: moved.path) + XCTAssertEqual(pairs(), [], "the same size and mtime with new content is not the old blob") + } + + /// `GIT_RENAMES_REAL=<repository>`: the pairs and the cost on a real working tree, read only. + func testARealRepositorysMovesAndTheirCost() throws { + guard let path = ProcessInfo.processInfo.environment["GIT_RENAMES_REAL"] else { throw XCTSkip("GIT_RENAMES_REAL is not set") } + let root = URL(fileURLWithPath: path) + let raw = GitQueries.parseStatus(String(decoding: try provider.git( + ["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], + in: root, input: nil, gitDirectory: nil, environment: nil).stdout, as: UTF8.self)).entries + let started = Date() + let cpu = clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID) + let paired = GitWorktreeRenames.paired(raw, in: root, provider: provider) + let renamed = paired.filter { $0.unstaged == .renamed } + print("worktree renames: \(raw.filter { $0.unstaged == .deleted }.count) deleted, \(raw.filter { $0.unstaged == .untracked }.count) untracked" + + " → \(renamed.count) renames in \(Int(Date().timeIntervalSince(started) * 1000)) ms wall, " + + "\((clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID) - cpu) / 1_000_000) ms thread CPU") + for entry in renamed.prefix(3) { print(" \(entry.oldPath ?? "") → \(entry.path)") } + XCTAssertEqual(paired.count, raw.count - renamed.count, "each rename replaces a deleted and an untracked entry") + } + + func testAMovedFilesStateSaysWhereItCameFrom() throws { + let repo = try movedFolderRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let state = try XCTUnwrap(try provider.fileState(repo.appendingPathComponent("moved/run/compiler.ts"))) + XCTAssertEqual(state.entry?.unstaged, .renamed) + XCTAssertEqual(state.summary, "Moved from data/run/compiler.ts") + let edited = try XCTUnwrap(try provider.fileState(repo.appendingPathComponent("moved/run/notes.md"))) + XCTAssertEqual(edited.summary, "new file, not in git", "an edited copy is not an exact move") + } + + func testOpeningTheMovedFileShowsItsOldSide() throws { + let repo = try movedFolderRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let moved = repo.appendingPathComponent("moved/run/compiler.ts") + let text = try String(contentsOf: moved, encoding: .utf8) + let sides = try GitFileDiffModel.sides(moved, text: text, scope: .unstaged, pick: nil, provider: provider, + renamedFrom: "data/run/compiler.ts") + XCTAssertEqual(sides.old, text, "the old side is the file at its old path") + XCTAssertEqual(sides.emptyReason, "Moved from data/run/compiler.ts without changes.") + let uncommitted = try GitFileDiffModel.sides(moved, text: text, scope: .uncommitted, pick: nil, provider: provider, + renamedFrom: "data/run/compiler.ts") + XCTAssertEqual(uncommitted.old, text) + XCTAssertEqual(GitFileDiffModel.diffFile(path: sides.path, old: "a\n", new: "b\n", oldPath: "data/run/compiler.ts").status, .renamed) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift index eba8e5abe8..e70e8656c1 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Disk/DuIndexTests.swift @@ -60,7 +60,9 @@ final class DuIndexContractTests: XCTestCase { XCTAssertEqual(info.dbBytes, 1_835_008) let diff = try decodeIndex(DuIndexDiff.self, "contract-du-index-diff") - XCTAssertEqual(diff.rows.map(\.status), ["grown", "new", "grown", "gone", "shrunk"]) + XCTAssertEqual(diff.rows.map(\.status), ["grown", "new", "grown", "gone", "shrunk", "shrunk"]) + XCTAssertNil(diff.rows[5].after, "a folder below the floor keeps its new size unknown (diff.ts writes null)") + XCTAssertEqual(diff.rows[5].approx, true) XCTAssertNil(diff.rows[2].before, "an approximate old side") XCTAssertEqual(diff.rows[2].approx, true) XCTAssertEqual(diff.to.live, true) @@ -291,6 +293,21 @@ final class DuIndexReaderTests: XCTestCase { XCTAssertEqual(try reader.node(id: 22)?.path, "/Users/example/Library/Logs") } + func testTheLastFinishedScanIsFoundBehindARunOfFailedUpdates() throws { + let failed = (44 ..< 69).map { + "(\($0), 'update', '/Users/example', \(1760390000000 + Int64($0) * 1_200_000), NULL, 'failed', NULL, NULL, NULL, NULL, NULL, '{}')" + } + try exec(""" + INSERT INTO scans (id, kind, root, started_at_ms, finished_at_ms, status, files, dirs, alloc_bytes, walk_ms, threads, options_json) + VALUES \(failed.joined(separator: ",\n")); + """) + let reader = try DuIndexReader(path: dbPath) + XCTAssertEqual(try reader.scans(limit: 20).filter { $0.status == "done" }.count, 0, "the old window saw only failures") + XCTAssertEqual(try reader.scans(limit: 1, status: "done").first?.id, 43) + XCTAssertEqual(try reader.scans(limit: 1, status: "done", kind: "build").first?.id, 42) + XCTAssertEqual(try reader.scans(limit: 1).first?.status, "failed") + } + func testChildrenLocalTopFilesAndScansMatchTheContract() throws { let reader = try DuIndexReader(path: dbPath) guard case .found(let library) = try reader.lookup("/Users/example/Library") else { return XCTFail("found") } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift index 0b4b053585..020c34013c 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift @@ -817,8 +817,15 @@ final class WidgetVisibilityTests: XCTestCase { kind: "decision", key: key, at: Date().timeIntervalSince1970 * 1000, needsAnswer: true, title: "Pick a parser for DECISION 8", number: 8) seen = try read("peek", WidgetPeekView(model: model, item: peek)) - XCTAssertTrue(seen.contains("New decision"), "read: \(seen)") + XCTAssertTrue(seen.contains("Decision 8"), "read: \(seen)") XCTAssertTrue(seen.contains("Pick a parser"), "read: \(seen)") + // The card fills a pane taller than itself (the rail sets the panel's height) with one inset on every side. + let pane = WidgetPeekView(model: model, item: peek).padding(WidgetPeekView.inset) + .frame(width: 324, height: 270, alignment: .top) + .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(Color(white: 0.17))) + seen = try read("peek-pane", pane) + XCTAssertTrue(seen.contains("Click to answer"), "read: \(seen)") + XCTAssertEqual(WidgetPeekView.cornerRadius + WidgetPeekView.inset, 18, "the card's corner runs parallel to the panel's") model.openDecision(id: "d_8_fixture-session", sessionKey: key) XCTAssertEqual(model.card?.id, "decision:d_8_fixture-session", "the link opens the newer decision") @@ -826,7 +833,8 @@ final class WidgetVisibilityTests: XCTestCase { func testAlertKickerAndOwnAnswerCardStates() { let item = WidgetInboxItem(id: "a", sourceId: "a", kind: "decision", key: "k", at: 1, needsAnswer: true, number: 9) - XCTAssertEqual(WidgetAlerts.kicker(item), "New decision · DECISION 9") + // The number once, in sentence case (R10). + XCTAssertEqual(WidgetAlerts.kicker(item), "Decision 9") var card = WidgetCard(id: "decision:d_9_s", kind: "decision", sessionKey: "k", sourceId: "d_9_s", at: 1, title: "T", body: "", status: "superseded", choices: [], attachments: [], refs: [], read: true) card.supersededBy = .init(id: "d_12_s", number: 12, sessionKey: "k", title: "Newer") @@ -1205,6 +1213,45 @@ final class EdgePanelControllerTests: XCTestCase { } } + /// The hover list measures itself in the layout that opening the panel runs, and asks for its real height from + /// inside it. That nested request was overwritten when the opening stored its own transition, so the panel stayed + /// at the estimate and cut its last row (2026-10-23 17:48: a reshape to 489 pt logged, the window stayed 336 pt). + @MainActor + final class Box { var controller: EdgePanelController<AnyView>?; var asked = false } + + func testASizeRequestedDuringTheOpeningLayoutIsNotLost() async throws { + _ = NSApplication.shared + let screen = try XCTUnwrap(NSScreen.screens.first) + let box = Box() + let liveBefore = EdgeFrameTicker.live + let value = EdgePanelController<AnyView>( + placement: .right, screen: screen, compactSize: CGSize(width: 40, height: 100), + expandedSize: CGSize(width: 340, height: 400), title: "Hidden reentrancy test") { + AnyView(Color.black.onGeometryChange(for: CGFloat.self, of: \.size.width) { width in + guard width > 100, !box.asked, let controller = box.controller else { return } + box.asked = true + controller.setPreviewSize(CGSize(width: 300, height: 360)) + controller.setPresentation(.preview, reduceMotion: false) + }) + } + box.controller = value + value.panel.alphaValue = 0 + value.panel.level = NSWindow.Level(rawValue: -1000) + defer { value.hide(); value.panel.close() } + value.setSideCenterY(400) + value.setPreviewSize(CGSize(width: 300, height: 240)) + value.setPresentation(.compact, reduceMotion: true) + value.show() + value.setPresentation(.preview, reduceMotion: false) + let deadline = ContinuousClock.now + .seconds(3) + while (value.lastTransitionTiming?.outcome != "completed" || value.panel.frame.height < 359), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(50)) + } + XCTAssertTrue(box.asked, "the content asked for its height while the panel opened") + XCTAssertEqual(value.panel.frame.height, 360, accuracy: 0.5, "the height asked during the opening wins") + XCTAssertEqual(EdgeFrameTicker.live, liveBefore, "no frame ticker keeps firing once the panel has settled") + } + func testContentReplacementRetainsWindowAndAnimatesNewCompactSize() async throws { let value = try controller() defer { value.hide(); value.panel.close() } @@ -1480,11 +1527,58 @@ final class WidgetRosterTests: XCTestCase { } } + /// The first preview after a launch opens at the estimate; its short first layout pass must not shrink the panel + /// before the rows arrive (2026-10-23 17:10: 336, 295, 489 pt for one alert). + func testTheFirstPreviewMeasurementDoesNotShrinkBelowTheEstimateBeforeTheRowsArrive() async throws { + try await withFixture(sessionCount: 4) { model, _, _ in + XCTAssertNil(model.previewFitHeight) + let estimate = model.previewHeight + model.reportPreviewFit(estimate - 41) + XCTAssertNil(model.previewFitHeight, "the short first pass waits") + model.reportPreviewFit(estimate + 153) + XCTAssertEqual(model.previewFitHeight, estimate + 153, "the full list applies at once") + try await Task.sleep(for: .milliseconds(450)) + XCTAssertEqual(model.previewFitHeight, estimate + 153, "the short pass never lands afterwards") + + model.reportPreviewFit(estimate - 41) + try await Task.sleep(for: .milliseconds(450)) + XCTAssertEqual(model.previewFitHeight, estimate - 41, "a shrink that holds still applies") + } + } + + /// The rail's bubbles read in the hover list's order (Martin, 2026-10-23: "the order should be the same in the + /// right and the expanded list"); "stable" keeps the old sticky places. + func testTheRailFollowsTheHoverListOrderUnlessSetToKeepPlaces() async throws { + try await withFixture(sessionCount: 12) { model, _, original in + var snapshot = original + let keys = snapshot.sessions.map(\.key) + snapshot.notifications = WidgetInboxSummary( + unread: 0, needsAnswer: 3, complete: true, truncated: false, + sessions: [keys[1], keys[4], keys[10]].map { + WidgetInboxSession(key: $0, unread: 0, needsAnswer: 1, latest: nil, unreadItem: nil, pendingItem: nil) + }) + model.receive([String(decoding: try JSONEncoder().encode(snapshot), as: UTF8.self)]) + let list = model.previewList.map(\.key) + let rail = model.railActivitySessions.map(\.key) + XCTAssertFalse(list.isEmpty) + XCTAssertEqual(rail.filter(list.contains), list.filter(rail.contains), "bubbles and rows share one order") + + snapshot.state.preferences.railOrder = "stable" + snapshot.state.revision += 1 + model.receive([String(decoding: try JSONEncoder().encode(snapshot), as: UTF8.self)]) + let kept = model.railActivitySessions.map(\.key) + XCTAssertEqual(kept, model.railSessions.map(\.key).filter(kept.contains), "Keep places is the sticky order") + XCTAssertNotEqual(kept.filter(list.contains), list.filter(kept.contains), + "this fixture's sticky order differs, so the first assertion proves the change") + } + } + func testSessionSwitchWaitsForItsMatchingInboxBeforeClaimingItIsEmpty() async throws { - try await withFixture(sessionCount: 2) { model, _, original in + try await withFixture(sessionCount: 3) { model, _, original in var snapshot = original let first = snapshot.sessions[0].key let second = snapshot.sessions[1].key + let third = snapshot.sessions[2].key model.selectedKey = first snapshot.selectedKey = first model.receive([String(decoding: try JSONEncoder().encode(snapshot), as: UTF8.self)]) @@ -1498,8 +1592,12 @@ final class WidgetRosterTests: XCTestCase { snapshot.selectedKey = second model.receive([String(decoding: try JSONEncoder().encode(snapshot), as: UTF8.self)]) XCTAssertFalse(model.inboxLoading, "The matching response ends the loading state even when empty") + // A session seen once opens with what was last seen, at once: here its empty inbox (2026-10-24, the + // session cache; this asserted "loading" before). model.select(first) - XCTAssertTrue(model.inboxLoading) + XCTAssertFalse(model.inboxLoading, "A session seen once opens from the cache") + model.select(third) + XCTAssertTrue(model.inboxLoading, "A session no snapshot carried yet loads") model.error = "Fixture source failure" XCTAssertFalse(model.inboxLoading, "A failed request must leave an actionable error rather than an endless spinner") } @@ -1662,7 +1760,7 @@ final class WidgetRosterTests: XCTestCase { var heights: [CGFloat] = [] for count in [0, 128, 0] { model.updateInbox(WidgetInboxSummary( - unread: count, needsAnswer: 0, complete: true, truncated: false, sessions: [])) + unread: count, needsAnswer: count, complete: true, truncated: false, sessions: [])) let metrics = WidgetSideStripMetrics(classic: style == "classic", moduleIDs: ids, visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: count > 0) let root = WidgetHostView(model: model, registry: registry, diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/FlowFocus/FlowFocusLegacyHostTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/FlowFocus/FlowFocusLegacyHostTests.swift new file mode 100644 index 0000000000..2bb2a85574 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/FlowFocus/FlowFocusLegacyHostTests.swift @@ -0,0 +1,251 @@ +import AppKit +import Foundation +import SwiftUI +import Vision +import XCTest +@testable import GenesisKit + +/// A running Genesis.app that predates the Flow and Focus protocol must never stop this host from recording, and a +/// current Genesis started without LaunchServices must not count as older (2026-10-23: the widget's Voice Notes said +/// "An older Genesis is running its own Flow and Focus services" while that Genesis owned the runtime). +@MainActor +final class FlowFocusLegacyHostTests: XCTestCase { + private var directory: URL! + private var children: [Process] = [] + + override func setUp() async throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("flow-focus-legacy-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + for child in children where child.isRunning { child.terminate() } + children = [] + try FileManager.default.removeItem(at: directory) + } + + /// A stand-in for a running Genesis: a real process, so its pid and kernel start time are real. + private func spawnPeer() throws -> (Process, FlowFocusPeerApp) { + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sleep") + child.arguments = ["30"] + child.standardOutput = FileHandle.nullDevice + child.standardError = FileHandle.nullDevice + try child.run() + children.append(child) + let pid = child.processIdentifier + let peer = FlowFocusPeerApp(pid: pid, bundlePath: "/Applications/Genesis.app", version: "0.1.0 (1)", + launchDate: nil, startedAt: FlowFocusPeerApp.processStart(pid)) + XCTAssertNotNil(peer.startedAt) + return (child, peer) + } + + /// The record a current Genesis writes, byte for byte the shape of one launched without LaunchServices: no launchDate. + private func writeParticipantRecord(pid: Int32, modified: Date? = nil) throws { + let runtimeDirectory = directory.appendingPathComponent("feature-runtime", isDirectory: true) + try FileManager.default.createDirectory(at: runtimeDirectory, withIntermediateDirectories: true) + let record = runtimeDirectory.appendingPathComponent("participant-\(pid).json") + let json = #"{"hostID":"dev.foltyn.genesis","version":1,"nonce":"\#(UUID().uuidString)","pid":\#(pid)}"# + try FlowFocusLease.writePrivate(Data(json.utf8), to: record) + if let modified { + try FileManager.default.setAttributes([.modificationDate: modified], ofItemAtPath: record.path) + } + } + + private func runtime(_ hostID: String, peers: @escaping @MainActor () -> [FlowFocusPeerApp] = { [] }) -> FlowFocusRuntime { + let runtime = FlowFocusRuntime(dataRoot: directory, hostID: hostID, liveServices: false, presentsWindows: false) + runtime.peerApps = peers + return runtime + } + + private func waitUntil(_ predicate: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(3) + while !predicate() { + guard Date() < deadline else { throw FlowFocusMailbox.Failure.unavailable("Timed out waiting for runtime state") } + try await Task.sleep(nanoseconds: 50_000_000) + } + } + + func testAnOlderGenesisLeavesTheRecordingPathWorkingAndTheLockFree() async throws { + let (child, peer) = try spawnPeer() + let host = runtime("test.legacy-host", peers: { [peer] }) + await host.start() + XCTAssertFalse(host.role.isOwner, "an older Genesis runs the shortcut, capture and DND; owning them would run them twice") + XCTAssertEqual(host.legacyHosts, [peer]) + XCTAssertEqual(host.role, .unavailable(FlowFocusRuntime.legacyMessage([peer]))) + // The explicit Record press: admitted without any owner, and the recorder attaches and releases as usual. + let lease = try await host.acquireExternalAudio() + XCTAssertTrue(lease.standalone) + try await lease.attachRecorder(pid: child.processIdentifier) + try await lease.release() + // The host stepped back without holding the lock: a current host can still own Flow and Focus. + let rival = runtime("test.rival") + await rival.start() + XCTAssertTrue(rival.role.isOwner) + await rival.stop() + await host.stop() + XCTAssertTrue(host.legacyHosts.isEmpty) + } + + func testWithALiveOwnerTheHostJoinsAsAClientDespiteAnOlderGenesis() async throws { + let (_, peer) = try spawnPeer() + let owner = runtime("test.owner") + await owner.start() + XCTAssertTrue(owner.role.isOwner) + let host = runtime("test.client-beside-legacy", peers: { [peer] }) + await host.start() + XCTAssertEqual(host.role, .client("test.owner"), "joining a live owner starts no service of its own") + XCTAssertTrue(host.legacyHosts.isEmpty) + let lease = try await host.acquireExternalAudio() + XCTAssertFalse(lease.standalone, "the owner admits the recorder, as without an older Genesis") + try await waitUntil { owner.flow.externalAudioHeld } + try await lease.release() + try await waitUntil { !owner.flow.externalAudioHeld } + await host.stop() + await owner.stop() + } + + func testWhenTheOlderGenesisQuitsTheHostTakesItsPartBack() async throws { + let (child, peer) = try spawnPeer() + var legacyRunning = true + let host = runtime("test.takes-back", peers: { legacyRunning ? [peer] : [] }) + await host.start() + XCTAssertEqual(host.legacyHosts, [peer]) + legacyRunning = false + child.terminate() + try await waitUntil { host.role.isOwner } + XCTAssertTrue(host.legacyHosts.isEmpty) + let lease = try await host.acquireExternalAudio() + XCTAssertFalse(lease.standalone) + try await lease.release() + await host.stop() + } + + func testACurrentGenesisStartedWithoutLaunchServicesIsAParticipantNotAnOlderCopy() async throws { + let (_, peer) = try spawnPeer() + XCTAssertNil(peer.launchDate) + try writeParticipantRecord(pid: peer.pid) + let host = runtime("test.beside-current", peers: { [peer] }) + await host.start() + XCTAssertTrue(host.role.isOwner, "a record written by the running process proves the protocol") + XCTAssertTrue(host.legacyHosts.isEmpty) + await host.stop() + } + + func testARecordOlderThanTheProcessIsLeftByAnEarlierProcessWithTheSamePid() async throws { + let (_, peer) = try spawnPeer() + let started = try XCTUnwrap(peer.startedAt) + try writeParticipantRecord(pid: peer.pid, modified: started.addingTimeInterval(-120)) + let host = runtime("test.stale-record", peers: { [peer] }) + await host.start() + XCTAssertFalse(host.role.isOwner) + XCTAssertEqual(host.legacyHosts, [peer]) + await host.stop() + } + + func testWithoutAnyGenesisTheRecordingPathIsTheOwnersAdmission() async throws { + let host = runtime("test.alone") + await host.start() + XCTAssertTrue(host.role.isOwner) + XCTAssertTrue(host.legacyHosts.isEmpty) + let lease = try await host.acquireExternalAudio() + XCTAssertFalse(lease.standalone) + XCTAssertTrue(host.flow.externalAudioHeld, "the owner records the admission, so its pre-roll stays off") + try await lease.release() + XCTAssertFalse(host.flow.externalAudioHeld) + await host.stop() + } + + func testTheLegacyNoticeNamesTheCopyAndItsQuitAsksFirst() async throws { + let (_, peer) = try spawnPeer() + let host = runtime("test.notice", peers: { [peer] }) + var asked: [Int32] = [] + host.terminatePeer = { asked.append($0); return true } + await host.start() + let notice = host.legacyNotice() + XCTAssertTrue(notice.found.contains("pid \(peer.pid)")) + XCTAssertTrue(notice.found.contains("/Applications/Genesis.app")) + XCTAssertTrue(notice.found.contains("Version: 0.1.0 (1)")) + let quit = try XCTUnwrap(notice.fixes.first { $0.label == "Quit the older Genesis (pid \(peer.pid))" }) + guard case .action(let confirm, let perform) = quit.kind else { return XCTFail("the quit fix is a button") } + XCTAssertNotNil(confirm, "quitting another app asks first") + perform() + XCTAssertEqual(asked, [peer.pid]) + let command = try XCTUnwrap(notice.fixes.first { if case .command = $0.kind { return true }; return false }) + guard case .command(let text) = command.kind else { return XCTFail() } + XCTAssertEqual(text, "kill \(peer.pid)") + await host.stop() + } + + func testARefusedRecordingSaysWhoHoldsAudioAndOffersTheFix() async throws { + let owner = runtime("test.refusal") + await owner.start() + owner.flow.preRollEffect = { _ in } + owner.flow.hotkeyBindingEffect = {} + owner.flow.recognitionStartEffect = {} + owner.flow.config.showPill = false + owner.flow.start() + owner.flow.beginTurn(captureCurrentTarget: false) + XCTAssertEqual(owner.flow.phase, .listening) + var refusal: Error? + do { _ = try await owner.acquireExternalAudio() } catch { refusal = error } + let message = try XCTUnwrap(refusal?.localizedDescription) + let notice = try XCTUnwrap(FlowFocusRuntime.lastAudioRefusal) + XCTAssertEqual(notice.text, message) + XCTAssertTrue(notice.found.contains("Flow and Focus owner: test.refusal")) + XCTAssertTrue(notice.found.contains("Dictation: listening")) + // The Voice Notes module shows this notice for the store's error text, with Record again after the runtime's fixes. + let content = WidgetVoiceNotesNotice.content(for: message, permission: .authorized, bundleID: "test.bundle", + reviewMicrophone: {}, recordAgain: {}) + XCTAssertEqual(content.more, notice.found) + XCTAssertEqual(content.fixes.map(\.label), ["Cancel the dictation in progress", "Record again"]) + guard case .action(let confirm, let perform) = content.fixes[0].kind else { return XCTFail() } + XCTAssertNotNil(confirm, "cancelling a dictation drops its words, so it asks first") + perform() + XCTAssertEqual(owner.flow.phase, .idle) + await owner.stop() + } + + func testTheMicrophoneNoticeOffersTheReviewAndTheTerminalReset() { + var reviewed = 0 + let error = VoiceCommandFailure.microphonePermission.errorDescription ?? "" + let content = WidgetVoiceNotesNotice.content(for: error, permission: .denied, bundleID: "test.bundle", + reviewMicrophone: { reviewed += 1 }, recordAgain: {}) + XCTAssertEqual(content.detail, "macOS answers \"denied\" for this app's microphone access.") + XCTAssertEqual(content.fixes.map(\.label).first, "Review microphone access") + guard case .action(let confirm, let perform) = content.fixes[0].kind else { return XCTFail() } + XCTAssertNil(confirm, "opening the permission dialog changes nothing by itself") + perform() + XCTAssertEqual(reviewed, 1) + guard case .command(let command) = content.fixes[1].kind else { return XCTFail() } + XCTAssertEqual(command, "tccutil reset Microphone test.bundle") + let capture = WidgetVoiceNotesNotice.content(for: VoiceCommandFailure.captureFailed.errorDescription ?? "", + permission: .authorized, bundleID: nil, + reviewMicrophone: {}, recordAgain: {}) + XCTAssertEqual(capture.fixes.map(\.label), ["Record again", "See why the recorder failed"]) + } + + func testTheNoticeDrawsItsFixesAndCommand() throws { + let pill = NoticePill( + text: "An older Genesis runs dictation and Focus", detail: "Voice Notes record as usual.", tone: .warning, + fixes: [.button("Quit the older Genesis (pid 4321)", symbol: "power", + confirm: .init("Quit Genesis?", confirmLabel: "Quit Genesis")) {}, + .command("Or quit it from a terminal", "kill 4321")]) + .frame(width: 420) + .padding(10) + .background(Color.black) + .environment(\.colorScheme, .dark) + let renderer = ImageRenderer(content: pill) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.cgImage) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + try VNImageRequestHandler(cgImage: image).perform([request]) + let text = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n") + XCTAssertTrue(text.contains("Quit the older Genesis (pid 4321)"), text) + XCTAssertTrue(text.contains("kill 4321"), text) + XCTAssertTrue(text.contains("Or quit it from a terminal"), text) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/FlowFocus/FocusAttentionTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/FlowFocus/FocusAttentionTests.swift index a2edd9b42d..9c71a772ec 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/FlowFocus/FocusAttentionTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/FlowFocus/FocusAttentionTests.swift @@ -280,4 +280,14 @@ final class AttentionPulseMotionTests: XCTestCase { XCTAssertEqual(blinking.count, style.cycles * 2) XCTAssertTrue(blinking.allSatisfy { $0.animation != nil }) } + + /// A row built while a flash runs plays the rest of it (the widget's hover list opens after the alert flashed). + func testAViewThatAppearsMidFlashPlaysTheRest() { + let now = Date() + XCTAssertNil(AttentionPulse<Int>.remaining(since: nil, now: now, duration: 5)) + XCTAssertEqual(AttentionPulse<Int>.remaining(since: now.addingTimeInterval(-2), now: now, duration: 5) ?? 0, 3, + accuracy: 0.001) + XCTAssertNil(AttentionPulse<Int>.remaining(since: now.addingTimeInterval(-6), now: now, duration: 5), "over") + XCTAssertNil(AttentionPulse<Int>.remaining(since: now.addingTimeInterval(1), now: now, duration: 5), "future") + } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift index e54ea1ecae..8f346c22d6 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ForgeBadgeTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import XCTest @testable import GenesisKitBase @@ -20,7 +21,7 @@ final class ForgeBadgeTests: XCTestCase { } func testAnUnknownCommandEndsThePathInsteadOfMisdrawing() { - let path = SVGPath.parse("M0 0L4 4A1 1 0 0 0 9 9") + let path = SVGPath.parse("M0 0L4 4Q1 1 9 9") XCTAssertEqual(path.boundingRect, CGRect(x: 0, y: 0, width: 4, height: 4)) } @@ -31,4 +32,50 @@ final class ForgeBadgeTests: XCTestCase { XCTAssertNil(Forge(kind: "bitbucket")) XCTAssertNil(Forge(kind: nil)) } + + func testProviderMarksParseToTheEndAndFillTheirBox() { + for kind in AIProviderMark.Kind.allCases { + let box = (AIProviderMark.paths[kind] ?? Path()).boundingRect + XCTAssertGreaterThan(box.width, 20, "\(kind) spans its 24-unit box (a path cut short by an arc stays small)") + XCTAssertGreaterThan(box.height, 20, "\(kind)") + XCTAssertLessThanOrEqual(box.maxX, 24.5, "\(kind)") + XCTAssertGreaterThanOrEqual(box.minY, -0.5, "\(kind)") + } + XCTAssertEqual(AIProviderMark.Kind(provider: "anthropic-sub"), .claude) + XCTAssertEqual(AIProviderMark.Kind(provider: "openai-sub"), .codex) + XCTAssertEqual(AIProviderMark.Kind(provider: "Grok"), .grok) + XCTAssertNil(AIProviderMark.Kind(provider: "gemini"), "an agent with no mark keeps its letter") + } + + func testAnArcWithPackedFlagsReachesItsEndPoint() { + // `a1 1 0 012 0` is flags 0 and 1, then the point (2, 0): a half circle from (0, 0) to (2, 0). + let path = SVGPath.parse("M0 0a1 1 0 012 0") + XCTAssertEqual(path.currentPoint?.x ?? 0, 2, accuracy: 0.001) + XCTAssertEqual(path.boundingRect.height, 1, accuracy: 0.05, "the half circle bulges by its radius") + } + + /// Renders every provider glyph style to a PNG, for the eye: PROVIDER_MARKS_RENDER=<out.png>. + @MainActor + func testRenderProviderMarks() throws { + guard let out = ProcessInfo.processInfo.environment["PROVIDER_MARKS_RENDER"] else { + throw XCTSkip("set PROVIDER_MARKS_RENDER") + } + let view = VStack(alignment: .leading, spacing: 10) { + ForEach(["claude", "codex", "grok", "gemini"], id: \.self) { provider in + HStack(spacing: 12) { + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 32) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 18) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 11) + AIProviderGlyph(meta: AIProviders.meta(for: provider), size: 24, style: .plain) + ProviderBadge(provider: provider, size: 24) + Text(provider).foregroundStyle(.white) + } + } + }.padding(16).background(Color(white: 0.1)) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.nsImage) + let rep = try XCTUnwrap(NSBitmapImageRep(data: try XCTUnwrap(image.tiffRepresentation))) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: out)) + } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift index 3f2f187866..0302655152 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift @@ -273,3 +273,38 @@ final class GateAuditTests: XCTestCase { XCTAssertEqual(mode & 0o777, 0o600) } } + +/// The vault window's command block (Martin, 2026-10-23): the program, its flags, their values and the rest each get +/// their own color, and a quoted empty value stays one word. +final class ApprovalCommandTextTests: XCTestCase { + func testACommandLineSplitsIntoProgramFlagsValuesAndArguments() { + let words = ApprovalCommandText.words( + "tools hub widget voice-notes transcribe 0b2b2787-0f55 --revision 1 --provider xai --language \"\" --json") + XCTAssertEqual(words.map(\.text), ["tools", "hub", "widget", "voice-notes", "transcribe", "0b2b2787-0f55", + "--revision", "1", "--provider", "xai", "--language", "\"\"", "--json"]) + XCTAssertEqual(words.map(\.role), [.program, .program, .program, .program, .program, .argument, + .flag, .value, .flag, .value, .flag, .value, .flag]) + XCTAssertEqual(ApprovalCommandText.words("tools say --voice=Zoe \"two words\"").map(\.role), + [.program, .program, .flag, .argument], "a flag with = carries its own value") + } +} + +/// A waiting approval reminds the user once, at the delay the setting picks, and never in its last seconds. +final class ApprovalAttentionTests: XCTestCase { + func testTheReminderFollowsItsSettingAndNeverOutlivesTheRequest() { + let now = Date() + let deadline = now.addingTimeInterval(170) + XCTAssertEqual(ApprovalAttention.reminderDelay(notifyAfter: 15, deadline: deadline, now: now), 15) + XCTAssertEqual(ApprovalAttention.reminderDelay(notifyAfter: 0, deadline: deadline, now: now), 0, "at once") + XCTAssertNil(ApprovalAttention.reminderDelay(notifyAfter: -1, deadline: deadline, now: now), "never") + XCTAssertNil(ApprovalAttention.reminderDelay(notifyAfter: 168, deadline: deadline, now: now), + "a card in the last seconds before the request denies itself could only mislead") + XCTAssertEqual(ApprovalAttention.reminderDelay(notifyAfter: 60, deadline: nil, now: now), 60) + } + + func testTheDefaultsAreASoftSoundOfItsOwnAndAReminderAfter15Seconds() { + XCTAssertEqual(ApprovalAttention.defaultSound, "Purr") + XCTAssertTrue(ApprovalAttention.sounds.contains(ApprovalAttention.defaultSound)) + XCTAssertTrue(ApprovalAttention.notifyAfterChoices.contains { $0.seconds == ApprovalAttention.defaultNotifyAfter }) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift index e13a7a0260..751cae9b25 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift @@ -315,7 +315,7 @@ final class WidgetShelfStoreTests: XCTestCase { store.importFiles([URL(string: "https://example.com/etc/hosts")!]) XCTAssertEqual(calls, 0) XCTAssertFalse(store.isBusy) - XCTAssertEqual(store.error, "Only local files can be staged.") + XCTAssertEqual(store.error, "Only local files can be added.") } func testFailedImportContinuesOtherFilesAndReleasesOwnerForRetry() async { @@ -439,6 +439,41 @@ final class WidgetShelfStoreTests: XCTestCase { XCTAssertEqual(attached, ["chosen-recipient"]) XCTAssertTrue(store.notice?.contains("Nothing has been sent") == true) } + + /// Remove from shelf takes the row off at once and removes the item only after the Undo time; Undo brings it + /// back, and the status line is spoken (ux catalogue P5, P9). + func testRemoveWaitsBehindUndoAndUndoBringsTheItemBack() async throws { + let itemJSON = String(decoding: try JSONEncoder().encode(item), as: UTF8.self) + let listed = Data(#"{"revision":1,"items":[\#(itemJSON)],"statePath":"/fixture/widget-shelf/state.json"}"#.utf8) + var calls: [[String]] = [] + var spoken: [String] = [] + let store = WidgetShelfStore(request: { args, _ in + calls.append(args) + return listed + }, announce: { text, _ in spoken.append(text) }, undoSeconds: { 0.1 }) + defer { store.stop() } + store.refresh() + guard try await pollUntil("the list is read", { store.items.count == 1 }) else { return } + + store.remove(item) + XCTAssertTrue(store.items.isEmpty, "the row leaves at once") + XCTAssertEqual(store.removed, item) + store.undoRemove() + XCTAssertEqual(store.items, [item]) + XCTAssertNil(store.removed) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertFalse(calls.contains { $0.first == "remove" }, "Undo in time removes nothing") + + store.remove(item) + store.refresh() + try await Task.sleep(for: .milliseconds(30)) + XCTAssertTrue(store.items.isEmpty, "a list read during the Undo time does not bring the row back") + guard try await pollUntil("the removal runs", { calls.contains { $0.first == "remove" } }) else { return } + await store.waitForRemoval() + XCTAssertEqual(calls.last { $0.first == "remove" }, ["remove", item.id]) + XCTAssertTrue(spoken.contains("Removed from the shelf. The original file and existing drafts are kept.")) + XCTAssertTrue(spoken.contains("notes.pdf is back on the shelf.")) + } } @MainActor @@ -537,15 +572,37 @@ final class WidgetVoiceDraftOrderingTests: XCTestCase { model.action(["action": "fixture-barrier"], completed: { saved.fulfill() }) await fulfillment(of: [saved], timeout: 2) } - func testHostVoiceActionOpensNotesWithoutStartingLegacyListener() async throws { + /// A test program made once under `name` and never renamed or deleted, so syspolicyd never checks a file that is + /// gone. Concurrent runs write their own draft and hard-link it into place; the loser uses the winner's file. + static func persistentScript(_ name: String, _ body: String) throws -> URL { + let folder = try XCTUnwrap(FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first) + .appendingPathComponent("GenesisKitTests/\(name)") + let script = folder.appendingPathComponent("tools") + if FileManager.default.isExecutableFile(atPath: script.path) { return script } + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let draft = folder.appendingPathComponent("draft-\(UUID().uuidString)") + try body.write(to: draft, atomically: false, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: draft.path) + do { try FileManager.default.linkItem(at: draft, to: script) } catch CocoaError.fileWriteFileExists {} + return script + } + /// The reply box microphone dictates live in both modes (Martin, 2026-10-23: the words should appear "live" while + /// he talks). A voice note also passes `--save-audio`, and when it stops it is filed with its session and text. + func testVoiceNoteDictatesLiveAndFilesItsAudioWithTheSession() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("voice-routing-\(UUID())") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - let binary = root.appendingPathComponent("tools") let marker = root.appendingPathComponent("voice-invoked") let snapshot = #"{"version":1,"state":{"version":1,"revision":0,"preferences":{"excludedKeys":[],"projects":[],"sessions":[],"showChanges":true,"placement":"both","side":"right","quietSeconds":15,"voiceProvider":"fixture","voiceLanguage":"en"},"assets":{},"drafts":{},"outgoing":[]},"sessions":[],"cards":[],"manifests":{},"errors":[]}"# - try "#!/bin/sh\nif [ \"$1\" = voice ]; then echo \"$*\" > '\(marker.path)'; exit 0; fi\nprintf '%s\\n' '\(snapshot)'\n".write(to: binary, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + let complete = #"{"kind":"complete","text":"Fixture words"}"# + let clip = root.appendingPathComponent("incoming.pcm").path + // The real listener prints "complete" before it closes the audio file; the fixture flushes it a moment later. + // The fake `tools` is made once and kept (tests.md: a new executable that is started and then deleted crashed + // syspolicyd); this run reaches it through a link in its own folder, which is where `$0` puts the files. + let binary = root.appendingPathComponent("tools") + try FileManager.default.createSymbolicLink(at: binary, withDestinationURL: try Self.persistentScript( + "voice-routing-v1", + "#!/bin/sh\nrun=\"$(dirname \"$0\")\"\nif [ \"$1\" = voice ]; then echo \"$*\" > \"$run/voice-invoked\"; printf '%s\\n' '\(complete)'; sleep 0.3; printf flushed > \"$run/incoming.pcm\"; exit 0; fi\nprintf '%s\\n' '\(snapshot)'\n")) let defaults = UserDefaults(suiteName: "voice-routing-\(UUID())")! let model = WidgetModel(binaryPath: binary.path, stateRoot: root.path, defaults: defaults) defer { model.stop() } @@ -554,21 +611,42 @@ final class WidgetVoiceDraftOrderingTests: XCTestCase { model.startSettings() await fulfillment(of: [loaded], timeout: 3) model.selectedKey = "pinned-recipient" - var requested: [String] = [] - model.recordVoiceNote = { requested.append($0) } - model.toggleVoice() - XCTAssertEqual(requested, ["pinned-recipient"]) - XCTAssertEqual(model.voiceActionLabel, "Record voice note") - XCTAssertFalse(model.voiceActive) - XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) - // Control: the same model and executable can still reach the standalone legacy listener. - model.recordVoiceNote = nil - let finished = expectation(description: "legacy fixture exits") - let voiceSubscription = model.$voiceActive.dropFirst().filter { !$0 }.prefix(1).sink { _ in finished.fulfill() } - model.toggleVoice() - await fulfillment(of: [finished], timeout: 3) - XCTAssertTrue(try String(contentsOf: marker, encoding: .utf8).contains("voice listen")) - withExtendedLifetime((subscription, voiceSubscription)) {} + var filed: [(String, String, WidgetVoiceNoteContext)] = [] + var filedAudio: [String?] = [] + model.voiceNoteClip = { clip } + model.fileVoiceNote = { path, text, context in + filed.append((path, text, context)) + filedAudio.append(try? String(contentsOfFile: path, encoding: .utf8)) + } + XCTAssertEqual(model.composerVoiceChoice, "ask", "the microphone asks by default") + + func run(_ mode: WidgetVoiceMode) async { + let finished = expectation(description: "fixture listener exits") + let watch = model.$voiceActive.dropFirst().filter { !$0 }.prefix(1).sink { _ in finished.fulfill() } + model.startVoice(mode) + XCTAssertTrue(model.voiceActive) + XCTAssertEqual(model.voiceMode, mode) + await fulfillment(of: [finished], timeout: 5) + withExtendedLifetime(watch) {} + } + + await run(.note) + let noteArgs = try String(contentsOf: marker, encoding: .utf8) + XCTAssertTrue(noteArgs.contains("voice listen"), noteArgs) + XCTAssertTrue(noteArgs.contains("--save-audio \(clip)"), "a voice note keeps its audio: \(noteArgs)") + XCTAssertEqual(filed.map(\.0), [clip]) + XCTAssertEqual(filedAudio, ["flushed"], "the note is filed after the listener closed its audio") + XCTAssertEqual(filed.map(\.1), ["Fixture words"]) + XCTAssertEqual(filed.first?.2.sessionKey, "pinned-recipient") + XCTAssertNil(filed.first?.2.decisionNumber, "a follow-up names no decision") + XCTAssertEqual(model.draft.text, "Fixture words", "the words land in the reply box") + XCTAssertEqual(model.preferredVoiceMode, .note, "the next press starts the mode picked last") + + await run(.dictate) + XCTAssertFalse(try String(contentsOf: marker, encoding: .utf8).contains("--save-audio"), "dictation keeps no audio") + XCTAssertEqual(filed.count, 1, "dictation files no voice note") + XCTAssertEqual(model.draft.text, "Fixture words Fixture words") + withExtendedLifetime(subscription) {} } func testCoordinatorRegistersModulesAndSettingsOverInjectedRuntimeWithoutStartingIt() async throws { @@ -1112,7 +1190,7 @@ final class WidgetInboxNotificationTests: XCTestCase { func testInboxTooltipDescribesOnlyTheAgentsModule() { let inbox = WidgetInboxSummary(unread: 3, needsAnswer: 1, complete: true, truncated: false, sessions: []) XCTAssertEqual(WidgetHostView.moduleTooltip(id: "agents", title: "Agents", inbox: inbox), - "Inbox: 3 unread, 1 need an answer") + "Inbox: 1 waiting for your answer, 3 unread") XCTAssertEqual(WidgetHostView.moduleTooltip(id: "shelf", title: "Shelf", inbox: inbox), "Shelf") XCTAssertEqual(WidgetHostView.moduleTooltip(id: nil, title: "Widgets", inbox: inbox), "Widgets") } @@ -1160,6 +1238,19 @@ final class WidgetInboxNotificationTests: XCTestCase { XCTAssertEqual(model.inboxCount, 1) } + /// The rail badge counts only what waits for an answer, so a new unread answer must not pulse it (judge G7). + func testTheWaitingBadgePulsesOnlyForANewWaitingItem() { + let model = model() + defer { model.stop() } + let now = Date().timeIntervalSince1970 * 1000 + model.updateInbox(summary(item("old", at: now - 1000))) + model.updateInbox(summary(item("answer", at: now + 100_000))) + XCTAssertEqual(model.inboxPulse, 1, "the hover list's unread count still pulses for the answer") + XCTAssertEqual(model.waitingPulse, 0, "the waiting badge does not") + model.updateInbox(summary(item("question", at: now + 200_000, pending: true))) + XCTAssertEqual(model.waitingPulse, 1, "a new question raises the waiting count and pulses the badge") + } + func testIncompleteSnapshotDoesNotSwallowTheArrivalItCannotRaise() { let model = model() defer { model.stop() } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/HubPreferencesTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/HubPreferencesTests.swift index 4533446ccb..9aef29fe59 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/HubPreferencesTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/HubPreferencesTests.swift @@ -59,6 +59,25 @@ final class HubPreferencesTests: XCTestCase { XCTAssertEqual(preferences(defaults, name).cleanupIdleDays, 0) } + func testHistoryKeepsTwoHundredPlacesAndAgentsNavigateUntilChanged() { + let (defaults, name) = suite() + defer { defaults.removePersistentDomain(forName: name) } + let hub = preferences(defaults, name) + XCTAssertEqual(hub.navigationLimit, 200) + XCTAssertEqual(hub.agentNavigation, .navigate) + hub.navigationLimit = 50 + hub.agentNavigation = .listOnly + XCTAssertEqual(defaults.integer(forKey: "hub.nav.limit"), 50) + XCTAssertEqual(defaults.string(forKey: "hub.nav.agentNavigation"), "listOnly") + let reopened = preferences(defaults, name) + XCTAssertEqual(reopened.navigationLimit, 50) + XCTAssertEqual(reopened.agentNavigation, .listOnly) + defaults.set(5, forKey: HubPreferences.Key.navigationLimit) + defaults.set("steal", forKey: HubPreferences.Key.agentNavigation) + XCTAssertEqual(preferences(defaults, name).navigationLimit, HubPreferences.navigationLimitRange.lowerBound) + XCTAssertEqual(preferences(defaults, name).agentNavigation, .navigate) + } + func testAChangeInOneProcessReachesAnotherThroughTheDistributedNotification() async { let (defaults, name) = suite() defer { defaults.removePersistentDomain(forName: name) } @@ -111,7 +130,7 @@ final class HubPreferencesTests: XCTestCase { defer { defaults.removePersistentDomain(forName: name) } let section = HubSettingsPages.section(preferences: preferences(defaults, name)) XCTAssertEqual(section.title, "Hub") - XCTAssertEqual(section.pages.map(\.id), [HubSettingsPages.appearancePageID, HubSettingsPages.panesPageID]) + XCTAssertEqual(section.pages.map(\.id), [HubSettingsPages.appearancePageID, HubSettingsPages.panesPageID, HubSettingsPages.historyPageID]) let appearance = NativeSettingsAppearance(defaults: defaults, notificationNamespace: name, observeExternalChanges: false) let clicky = ClickyModel(defaults: defaults, previewOnly: true, appearance: appearance, observeSystemEvents: false) let widget = WidgetModel(binaryPath: "/fixture/no-process", defaults: defaults, appearance: appearance) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDashboardShareTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDashboardShareTests.swift index c4bb91736d..25d39f7401 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDashboardShareTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDashboardShareTests.swift @@ -33,12 +33,31 @@ final class MarkdownDashboardShareTests: XCTestCase { stderr: "obsidian vault not configured\n" ) XCTAssertNil(outcome.url) - XCTAssertEqual(outcome.message, "obsidian vault not configured") + XCTAssertEqual(outcome.message, "The dev dashboard could not share this note. Details say why.") + XCTAssertEqual(outcome.detail, "obsidian vault not configured\nExit code 1.") + } + + func testAFailureWithNoOutputStillNamesTheExitCode() { + let outcome = MarkdownDashboardShare.outcome(exitCode: 3, stdout: "", stderr: "") + XCTAssertEqual(outcome.message, "The dev dashboard could not share this note. Details say why.") + XCTAssertEqual(outcome.detail, "Exit code 3.") + } + + func testATimeoutAndAMissingCommandEachHaveTheirOwnSentence() { + let timeout = MarkdownDashboardShare.failure(ToolsBridgeError.timeout(seconds: 30)) + XCTAssertEqual(timeout.message, "The dev dashboard did not answer within 30 seconds. Check that it is running, then share again.") + XCTAssertNil(timeout.detail) + let missing = MarkdownDashboardShare.failure(ToolsBridgeError.binaryNotFound("/nowhere/tools")) + XCTAssertEqual(missing.message, ToolsBridgeError.binaryNotFound("/nowhere/tools").localizedDescription) + let other = MarkdownDashboardShare.failure(CocoaError(.fileReadNoPermission)) + XCTAssertEqual(other.message, "The share could not start.") + XCTAssertNotNil(other.detail) } func testSuccessWithoutAUrlSaysSo() { let outcome = MarkdownDashboardShare.outcome(exitCode: 0, stdout: "not a url\n", stderr: "") XCTAssertNil(outcome.url) - XCTAssertEqual(outcome.message, "dev-dashboard share did not print a URL") + XCTAssertEqual(outcome.message, "The dev dashboard answered without a share link. Try again in a moment.") + XCTAssertEqual(outcome.detail, "not a url") } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDefaultAppTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDefaultAppTests.swift index 3009af5be5..be1d00ffd6 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDefaultAppTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownDefaultAppTests.swift @@ -73,7 +73,7 @@ final class MarkdownDefaultAppTests: XCTestCase { XCTAssertEqual(MarkdownDefaultApp.sharesType("yml", in: statuses), ["yaml"]) XCTAssertEqual(MarkdownDefaultApp.sharesType("json", in: statuses), []) let yml = statuses.first { $0.ext == "yml" }! - XCTAssertEqual(MarkdownDefaultAppDialog.rowTitle(yml, statuses: statuses), ".yml (now opens in no app), same type as .yaml") + XCTAssertEqual(MarkdownDefaultAppDialog.rowTitle(yml, statuses: statuses), ".yml (no app opens it yet), same type as .yaml") } func testApplySetsEachContentTypeOnceAndReportsARefusalForEverySiblingExtension() async { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownEditingUxTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownEditingUxTests.swift new file mode 100644 index 0000000000..f1a7cde26e --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownEditingUxTests.swift @@ -0,0 +1,111 @@ +import AppKit +@testable import GenesisKit +import SwiftUI +import XCTest + +/// The UX audit of 2026-10-25 (editing and sidebar): a change from outside is one undo step, discarding edits names +/// the loss, the gutter bars are reachable without the mouse. +@MainActor +final class MarkdownEditingUxTests: XCTestCase { + // MARK: A change from outside the editor is one undo step + + func testTheChangedRangeKeepsTheCommonStartAndEnd() { + let change = MarkdownSourceEditor.changedRange(old: "one two three", new: "one 2 three") + XCTAssertEqual(change.range, NSRange(location: 4, length: 3)) + XCTAssertEqual(change.replacement, "2") + + let same = MarkdownSourceEditor.changedRange(old: "abc", new: "abc") + XCTAssertEqual(same.range.length, 0) + XCTAssertEqual(same.replacement, "") + + let appended = MarkdownSourceEditor.changedRange(old: "abc", new: "abcd") + XCTAssertEqual(appended.range, NSRange(location: 3, length: 0)) + XCTAssertEqual(appended.replacement, "d") + } + + func testTheChangedRangeNeverSplitsASurrogatePair() { + // 😀 and 😃 share their high surrogate: a cut between the two halves would leave half a character. + let old: NSString = "a😀b" + let new: NSString = "a😃b" + let change = MarkdownSourceEditor.changedRange(old: old, new: new) + XCTAssertEqual(change.range, NSRange(location: 1, length: 2)) + XCTAssertEqual(change.replacement, "😃") + XCTAssertEqual(old.replacingCharacters(in: change.range, with: change.replacement), new as String) + } + + func testReplaceAllFromOutsideIsOneUndoStep() { + var bound = "alpha beta alpha" + let editor = MarkdownSourceEditor(text: Binding(get: { bound }, set: { bound = $0 }), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + textColor: .white, background: .black, completesWhileTyping: false) + let host = NSHostingView(rootView: editor.frame(width: 400, height: 200)) + host.layoutSubtreeIfNeeded() + func find(_ v: NSView) -> NSTextView? { (v as? NSTextView) ?? v.subviews.lazy.compactMap(find).first } + let view = try! XCTUnwrap(find(host)) + let coordinator = try! XCTUnwrap(view.delegate as? MarkdownSourceEditor.Coordinator) + + // The model replaced every "alpha" (Replace All), and the binding already holds the result. + bound = "omega beta omega" + coordinator.replaceFromOutside(with: bound, in: view) + XCTAssertEqual(view.string, "omega beta omega") + XCTAssertEqual(view.undoManager?.canUndo, true, "the outside change is on the editor's undo stack") + + view.undoManager?.undo() + XCTAssertEqual(view.string, "alpha beta alpha") + XCTAssertEqual(bound, "alpha beta alpha", "the undo reaches the document through the binding") + + view.undoManager?.redo() + XCTAssertEqual(view.string, "omega beta omega") + XCTAssertEqual(bound, "omega beta omega") + } + + // MARK: Discarding edits asks, and names the loss + + func testTheDiscardAlertCountsTheChangedLines() { + let diff = TextMerge.diff(old: "a\nb\nc\n", new: "a\nB\nc\nd\n") + XCTAssertEqual(MarkdownDiscardEdits.changedLines(diff), 3, "b removed, B and d added") + XCTAssertEqual(MarkdownDiscardEdits.message(changedLines: 1, missing: false), + "Your unsaved edits (1 changed line) are thrown away and the file is read from disk again.") + XCTAssertTrue(MarkdownDiscardEdits.message(changedLines: 4, missing: true).contains("4 changed lines")) + XCTAssertEqual(MarkdownDiscardEdits.confirmTitle(missing: false), "Discard edits and reload") + XCTAssertEqual(MarkdownDiscardEdits.confirmTitle(missing: true), "Discard edits") + } + + func testAskingToDiscardChangesNothingUntilConfirmed() { + var state = MarkdownDiscardEdits() + state.ask(.takeDisk) + XCTAssertEqual(state.pending, .takeDisk) + } + + // MARK: Gutter bars without the mouse + + func testNextAndPreviousChangeWrapAtTheEnds() { + let hunks = [ + GitHunk(kind: .added, newStart: 2, newCount: 1, oldStart: 2, oldLines: []), + GitHunk(kind: .modified, newStart: 10, newCount: 2, oldStart: 10, oldLines: ["x"]), + ] + XCTAssertEqual(SourceTextView.hunkIndex(hunks, from: 0, step: 1), 0) + XCTAssertEqual(SourceTextView.hunkIndex(hunks, from: 2, step: 1), 1) + XCTAssertEqual(SourceTextView.hunkIndex(hunks, from: 12, step: 1), 0, "past the last change: back to the first") + XCTAssertEqual(SourceTextView.hunkIndex(hunks, from: 12, step: -1), 1) + XCTAssertEqual(SourceTextView.hunkIndex(hunks, from: 1, step: -1), 1, "before the first change: the last") + XCTAssertNil(SourceTextView.hunkIndex([], from: 0, step: 1)) + XCTAssertEqual(SourceTextView.hunkTip(.deleted), "Removed lines. Click to see what was removed") + } + + func testTheSourceViewHasASpokenName() { + let editor = MarkdownSourceEditor(text: .constant("x"), font: .systemFont(ofSize: 12), textColor: .white, background: .black) + let host = NSHostingView(rootView: editor.frame(width: 200, height: 100)) + host.layoutSubtreeIfNeeded() + func find(_ v: NSView) -> NSTextView? { (v as? NSTextView) ?? v.subviews.lazy.compactMap(find).first } + XCTAssertEqual(find(host)?.accessibilityLabel(), "Markdown source") + } + + // MARK: Find bar + + func testTheFindCounterIsSpokenInWords() { + XCTAssertEqual(MarkdownFindBar.spokenCounter("3/12"), "Match 3 of 12") + XCTAssertEqual(MarkdownFindBar.spokenCounter("no match"), "No match") + XCTAssertEqual(MarkdownFindBar.spokenCounter("invalid regex"), "Invalid regex") + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownReadingUxTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownReadingUxTests.swift new file mode 100644 index 0000000000..03907e2acf --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownReadingUxTests.swift @@ -0,0 +1,252 @@ +import SwiftUI +import WebKit +import XCTest +@testable import GenesisKit + +/// The reading pane's answers from the UX audit of 2026-10-25 (P1 to P21): a dead link or anchor says so, a page +/// that fails to draw offers Retry, headings with the same title stay apart, a removed property can be undone, and +/// pictures say what they are. Pages run in hidden, never-ordered-front windows. +@MainActor +final class MarkdownReadingUxTests: XCTestCase { + private var window: NSWindow? + private var coordinator: MarkdownWebView.Coordinator? + private var messages: [[String: Any]] = [] + + override func tearDown() { + coordinator?.hideNotice() + window?.contentView = nil + window = nil + coordinator = nil + messages = [] + } + + private func view(_ markdown: String = "", onRelativeLink: ((String) -> Void)? = nil, + onProperties: (([MarkdownProperty]) -> Void)? = nil) -> MarkdownWebView { + MarkdownWebView(markdown: markdown, scrollTarget: nil, highlightQuery: nil, highlightToken: 0, + onWikilink: { _ in }, onActiveSection: { _ in }, onRelativeLink: onRelativeLink, + onProperties: onProperties) + } + + /// A page driven by the app's own coordinator, in a hidden window. + private func page(_ view: MarkdownWebView) async throws -> (WKWebView, MarkdownWebView.Coordinator) { + let coordinator = MarkdownWebView.Coordinator(view) + let config = MarkdownWebView.configuration(for: coordinator) + let web = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: config) + web.navigationDelegate = coordinator + coordinator.web = web + let window = NSWindow(contentRect: web.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = web + web.loadHTMLString(MarkdownWebView.template, baseURL: URL(string: "\(MarkdownWebView.assetScheme)://asset/")) + for _ in 0..<100 where !coordinator.ready { + try await Task.sleep(nanoseconds: 50_000_000) + } + XCTAssertTrue(coordinator.ready) + self.window = window + self.coordinator = coordinator + return (web, coordinator) + } + + private func render(_ web: WKWebView, _ markdown: String, props: String = "[]", editable: Bool = false, + media: String = "{}") async throws -> String? { + try await web.evaluateJavaScript( + "window.gnRender(\(MarkdownWebView.jsString(markdown)),\(props),\(editable),null,\(media),false,1)") as? String + } + + /// Lets the page's script messages reach the coordinator. + private func settle() async throws { + try await Task.sleep(nanoseconds: 150_000_000) + } + + // MARK: P3, P4: headings with the same title, a second click + + func testHeadingsWithTheSameTitleGetTheIdsTheOutlineNames() async throws { + let markdown = "# Notes\n\n## Examples\n\nOne.\n\n## Examples\n\nTwo.\n\n## Examples 1\n\nThree." + let (web, _) = try await page(view()) + _ = try await render(web, markdown) + let ids = try await web.evaluateJavaScript( + "Array.from(document.querySelectorAll('#gn-article h1,#gn-article h2')).map(function(h){return h.id})") as? [String] + XCTAssertEqual(ids, ["notes", "examples", "examples-1", "examples-1-1"]) + XCTAssertEqual(MarkdownOutline.items(of: markdown).map(\.slug), ids, "one numbering on both sides") + XCTAssertEqual(MarkdownBlocks.uniqueSlugs(["a", "a", "a-1", "a"]), ["a", "a-1", "a-1-1", "a-2"]) + } + + func testASecondClickOnTheSameRowIsANewTargetForTheSameHeading() { + let item = MarkdownOutline.Item(level: 2, text: "Examples", slug: "examples-1", index: 3) + let first = MarkdownOutline.target(for: item, current: nil) + XCTAssertEqual(first, "examples-1") + let second = MarkdownOutline.target(for: item, current: first) + XCTAssertNotEqual(second, first, "a new value, so both renderers see a change") + XCTAssertEqual(MarkdownOutline.target(for: item, current: second), first, "and back again on a third click") + XCTAssertEqual(MarkdownBlocks.slugify(second), "examples-1", "it still names the same heading") + } + + // MARK: P1: a link that goes nowhere answers + + func testAHeadingThatIsNotThereIsAnsweredOnThePage() async throws { + let (web, coordinator) = try await page(view()) + _ = try await render(web, "# Setup\n\n[gone](#renamed-heading) and [[#Missing part]]") + let found = try await web.evaluateJavaScript("window.gnScrollToSlug('setup','Setup')") as? Bool + XCTAssertEqual(found, true) + try await settle() + XCTAssertNil(coordinator.currentNotice, "a heading that is there gets no notice") + + _ = try await web.evaluateJavaScript("document.querySelector('a.dd-wikilink').click();true") + try await settle() + XCTAssertEqual(coordinator.currentNotice?.text, "No heading “Missing part” in this note.") + XCTAssertTrue(coordinator.noticeHost?.superview === web, "shown over the page") + + _ = try await web.evaluateJavaScript("document.querySelector('a[href^=\"#\"]:not(.dd-wikilink)').click();true") + try await settle() + XCTAssertEqual(coordinator.currentNotice?.text, "No heading “renamed-heading” in this note.") + } + + func testABlockedSchemeAndAPaneWithNoFileLinksSaySo() async throws { + let (web, coordinator) = try await page(view()) + _ = try await render(web, "[server](ssh://example.org) and [file](./x.pdf)") + _ = try await web.evaluateJavaScript("document.querySelector('a[href^=\"ssh\"]').click();true") + try await settle() + XCTAssertEqual(coordinator.currentNotice?.text, "The viewer does not open ssh: links.") + XCTAssertEqual(coordinator.currentNotice?.kind, .link) + XCTAssertEqual(coordinator.currentNotice?.lasts, 6, "a link notice goes by itself") + + _ = try await web.evaluateJavaScript("document.querySelector('a[href^=\"./\"]').click();true") + try await settle() + XCTAssertEqual(coordinator.currentNotice, .noFileLinks) + } + + // MARK: P2: a page that fails to draw + + func testAFailedRenderKeepsTheLastTextAndOffersRetryWithTheRawErrorBehindDetails() async throws { + let (web, coordinator) = try await page(view()) + let ok = try await render(web, "# Kept\n\nThe last good text.") + XCTAssertEqual(ok, "ok") + // A number has no `split`: the render throws before it touches the article. + let failed = try await web.evaluateJavaScript("window.gnRender(42,[],false,null,{},false,2)") as? String + XCTAssertEqual(failed, "failed") + try await settle() + let text = try await web.evaluateJavaScript("document.getElementById('gn-article').textContent") as? String ?? "" + XCTAssertTrue(text.contains("The last good text."), "the last good text stays: \(text)") + XCTAssertFalse(text.contains("split"), "no raw error on the page") + let notice = try XCTUnwrap(coordinator.currentNotice) + XCTAssertEqual(notice.text, "This page could not be drawn.") + XCTAssertEqual(notice.action, .retry) + XCTAssertNil(notice.lasts, "a failure stays until Retry works or the reader closes it") + XCTAssertTrue(notice.more?.contains("split") == true, "the raw message is behind Details: \(notice.more ?? "nil")") + + // Retry loads the page again; the next render that draws takes the notice away. + coordinator.lastMarkdown = "# Back" + coordinator.retryPage() + XCTAssertNil(coordinator.noticeHost, "Retry closes the notice first") + for _ in 0..<100 where !coordinator.ready { + try await Task.sleep(nanoseconds: 50_000_000) + } + try await settle() + let back = try await web.evaluateJavaScript("document.querySelector('#gn-article h1').textContent") as? String + XCTAssertEqual(back, "Back") + } + + func testANoticeSitsAtTheBottomAndLetsClicksBesideItReachThePage() async throws { + let (web, coordinator) = try await page(view()) + coordinator.show(.failure("raw")) + try await settle() + let host = try XCTUnwrap(coordinator.noticeHost) + XCTAssertGreaterThan(host.frame.height, 20) + XCTAssertLessThanOrEqual(host.frame.width, 520) + let bottomGap = web.isFlipped ? web.bounds.height - host.frame.maxY : host.frame.minY + XCTAssertEqual(bottomGap, 16, accuracy: 1) + // Far left of the host, outside the centred pill: the page gets the click. + let beside = host.convert(NSPoint(x: 1, y: host.bounds.midY), to: web) + XCTAssertNil(host.hitTest(beside), "a click beside the pill reaches the page") + coordinator.hideNotice() + XCTAssertNil(host.superview) + } + + // MARK: P5: removing a property + + func testRemovingAPropertyIsLabelledReachableAndCanBeUndone() async throws { + var posted: [[MarkdownProperty]] = [] + let (web, coordinator) = try await page(view(onProperties: { posted.append($0) })) + let props = #"[{"key":"status","kind":"text","value":"draft","items":[],"inline":false},{"key":"tags","kind":"tags","value":"","items":["a","b"],"inline":true}]"# + _ = try await render(web, "Body", props: props, editable: true) + let labels = try await web.evaluateJavaScript(""" + [document.querySelector('.gn-prop-remove').getAttribute('aria-label'), + document.querySelector('.gn-chip-x').tagName,document.querySelector('.gn-chip-x').getAttribute('aria-label')].join('|') + """) as? String + XCTAssertEqual(labels, "Remove property status|BUTTON|Remove tag a") + let focusRule = try await web.evaluateJavaScript(""" + Array.from(document.styleSheets).some(function(s){try{return Array.from(s.cssRules).some(function(r){ + return (r.selectorText||'').indexOf('.gn-prop:focus-within .gn-prop-remove')>=0;});}catch(e){return false;}}) + """) as? Bool + XCTAssertEqual(focusRule, true, "the button shows when the keyboard reaches its row") + + _ = try await web.evaluateJavaScript("document.querySelector('.gn-prop-remove').click();true") + try await settle() + XCTAssertEqual(posted.last?.map(\.key), ["tags"]) + XCTAssertEqual(coordinator.currentNotice?.text, "Property “status” removed.") + XCTAssertEqual(coordinator.currentNotice?.action, .undoProperties) + + _ = try await web.evaluateJavaScript("window.gnPropsUndo();true") + try await settle() + XCTAssertEqual(posted.last?.map(\.key), ["status", "tags"], "Undo posts the rows from before the removal") + } + + // MARK: P6, P7, P17, P20: pictures and embeds + + func testViewablePicturesTakeTheKeyboardAndMissingOnesSayWhy() async throws { + let (web, _) = try await page(view()) + let media = #"{"pending.png":{"kind":"pending","name":"pending.png"}}"# + let png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + _ = try await render(web, "![dot](\(png))\n\n![[gone.png]]\n\n![[pending.png]]\n\n![[Other note]]", media: media) + let image = try await web.evaluateJavaScript(""" + var i=document.querySelector('#gn-article img[data-gn-img]'); + [i.getAttribute('tabindex'),i.getAttribute('role'),i.getAttribute('aria-label'),getComputedStyle(i).cursor].join('|') + """) as? String + XCTAssertEqual(image, "0|button|Open picture dot|zoom-in") + let missing = try await web.evaluateJavaScript( + "var m=document.querySelector('.gn-img-missing');m.textContent.trim()+'|'+m.getAttribute('role')") as? String + XCTAssertEqual(missing, "⧉ Picture not found: gone.png|img", "the words are on the pill, not only in a tooltip") + let pending = try await web.evaluateJavaScript( + "var p=document.querySelector('.gn-img-pending');p?p.getAttribute('aria-label'):'none'") as? String + XCTAssertEqual(pending, "Loading picture pending.png", "a lookup still running shows no negative answer") + let embed = try await web.evaluateJavaScript( + "var e=document.querySelector('a.dd-md-embed-stub');e?e.getAttribute('data-wikilink'):'none'") as? String + XCTAssertEqual(embed, "Other note", "an embedded note is a link to it") + } + + func testANameOnlyPictureIsPendingUntilTheVaultScanAnswers() async throws { + let vault = FileManager.default.temporaryDirectory.appendingPathComponent("reading-ux-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: vault, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: vault) } + let index = VaultMediaIndex(root: vault) { _ in VaultMediaIndex.Snapshot(paths: [], attachmentFolder: nil) } + let registry = MarkdownMediaRegistry(indexForVault: { _ in index }) + let context = MarkdownMedia.Context(folder: vault, vaultRoot: vault) + let ready = expectation(description: "scan answered") + let before = registry.update(markdown: "![[photo.png]]", context: context) { ready.fulfill() } + XCTAssertEqual(before["photo.png"]?["kind"], "pending") + await fulfillment(of: [ready], timeout: 5) + let after = registry.update(markdown: "![[photo.png]]", context: context) {} + XCTAssertNil(after["photo.png"], "after the scan a miss is a miss, and the page says not found") + } + + func testAnEmptyNoteSaysSo() async throws { + let (web, _) = try await page(view()) + _ = try await render(web, " \n") + let empty = try await web.evaluateJavaScript(""" + var a=document.getElementById('gn-article');a.classList.contains('gn-is-empty')+'|'+getComputedStyle(a,'::after').content + """) as? String + XCTAssertEqual(empty, "true|\"This note is empty.\"") + _ = try await render(web, "Text") + let full = try await web.evaluateJavaScript("document.getElementById('gn-article').classList.contains('gn-is-empty')") as? Bool + XCTAssertEqual(full, false) + } + + // MARK: P8, P9: the native panes + + func testTheNativePaneFollowsThePaletteAndSelectsText() { + let light = MarkdownTheme.light.native(.vault) + XCTAssertEqual(light.textColor, MarkdownTheme.light.text) + XCTAssertEqual(light.accentColor, MarkdownTheme.light.accent) + XCTAssertEqual(MarkdownTheme.genesis.native(.vault), .vault, "the default palette keeps the vault look") + XCTAssertEqual(MarkdownTheme.light.nativePage, MarkdownTheme.light.page) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownRepoPanelBenchTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownRepoPanelBenchTests.swift new file mode 100644 index 0000000000..ba710dc755 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownRepoPanelBenchTests.swift @@ -0,0 +1,749 @@ +import AppKit +import Combine +import Darwin +@testable import GenesisKit +import SwiftUI +import Vision +import XCTest + +/// The git sidebar's Repository tab as Martin has it (2026-10-25, "so laggy"): the viewer's real window content with +/// the vault's notes as tabs, the file tree, and the git sidebar 560 pt wide on the Repository tab. Each step records +/// the main thread's CPU time from the action until the main thread (and, for work that hops off it, the whole +/// process) is quiet again, the run loop's busy time and longest pass (`MainBusy.Meter`), and the bodies and publishes +/// that ran (`RenderProbe`). Real notes and the repository are only read: git runs with `--no-optional-locks`. +/// +/// MARKDOWN_REPO_BENCH=<repository or vault folder> swift test --filter MarkdownRepoPanelBench +/// +/// Options: MARKDOWN_REPO_BENCH_TABS (39) or MARKDOWN_REPO_BENCH_TAB_LIST=<file of note paths, the last selected>; +/// MARKDOWN_REPO_BENCH_OUT=<jsonl> (one line per step, appended; a summary over every run in the file is written +/// beside it as <out>.summary.md); MARKDOWN_REPO_BENCH_LABEL (before / after; default "run"); MARKDOWN_REPO_BENCH_PHASES +/// (comma list, default all: floor,open,publish,tabs,filter,sort,refresh,scroll,ab,fold,offmain); +/// MARKDOWN_REPO_BENCH_SAMPLE=<path> runs `/usr/bin/sample` on this process while MARKDOWN_REPO_BENCH_SAMPLE_PHASE +/// (publish) repeats for MARKDOWN_REPO_BENCH_SAMPLE_SECS (8), on MARKDOWN_REPO_BENCH_SAMPLE_TAB (repo; file for a +/// difference); MARKDOWN_REPO_BENCH_QUERY (analy), _SHOW_MORE (5 pages), +/// _WIDTH / _HEIGHT (2056 x 1290), _GIT_WIDTH (560), _FILES_WIDTH (346), _FILES=0 (no file tree), _WRAP=0, _OPENS (5), +/// _REFRESHES (3), _DEBUG=1 (print what the fold phase reads while it looks for a row). +/// +/// The window is hosted at alpha 0 below the desktop, like `MarkdownTreeLagBenchTests`: SwiftUI evaluates and lays +/// out everything, but nothing is drawn, the window is never key and no accessibility client watches it, so the +/// numbers leave out drawing and focus/accessibility upkeep. The fold phase reads the sidebar's picture to find a +/// folder row and its "Show more" button and posts clicks at them; it runs last. +final class MarkdownRepoPanelBenchTests: XCTestCase { + @MainActor + func testRepositoryPanelCost() throws { + let env = ProcessInfo.processInfo.environment + guard let rootPath = env["MARKDOWN_REPO_BENCH"] else { + throw XCTSkip("set MARKDOWN_REPO_BENCH to a repository folder (the notes vault reproduces the report)") + } + let bench = try RepoPanelBench(env: env, root: URL(fileURLWithPath: rootPath)) + let tab = UserDefaults.standard.object(forKey: RepoPanelBench.tabKey) + let order = UserDefaults.standard.object(forKey: RepoPanelBench.orderKey) + let untracked = UserDefaults.standard.object(forKey: RepoPanelBench.untrackedKey) + defer { + // The sidebar tab and the panel's order are @AppStorage in the process's defaults: put back what was there. + for (key, value) in [(RepoPanelBench.tabKey, tab), (RepoPanelBench.orderKey, order), (RepoPanelBench.untrackedKey, untracked)] { + if let value { UserDefaults.standard.set(value, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } + } + } + try bench.run() + } + + /// Nearest-rank percentiles over the measured steps. + func testPercentile() { + XCTAssertEqual(RepoPanelBench.percentile([], 0.5), 0) + XCTAssertEqual(RepoPanelBench.percentile([5], 0.95), 5) + XCTAssertEqual(RepoPanelBench.percentile([4, 1, 3, 2], 0.5), 2) + XCTAssertEqual(RepoPanelBench.percentile(Array(1...20).map(Double.init), 0.95), 19) + } +} + +/// One run of the repository panel bench: a window, its phases, the records. +@MainActor +final class RepoPanelBench { + static let tabKey = "markdown.config.gitSidebarTab" + static let orderKey = "genesiskit.gitRepoPanel.order" + static let untrackedKey = "genesiskit.gitRepoPanel.untracked" + + struct Settle { + /// Wait until the whole process is quiet too: derived lists and file dates are built off the main thread. + var processQuiet = false + var quietSlices = 4 + var minSeconds: TimeInterval = 0 + var cap: TimeInterval = 5 + var until: (() -> Bool)? + var trackHeight = false + } + + let env: [String: String] + let root: URL + let label: String + let runID = String(UUID().uuidString.prefix(8)) + let phases: Set<String> + private var model: MarkdownDocumentsModel! + private var window: NSWindow! + private var repo: GitRepoModel! + private var watches: [AnyCancellable] = [] + private var idleProcessMsPerMs = 0.0 + private var steps: [String: [[String: Any]]] = [:] + private var phaseOrder: [String] = [] + + init(env: [String: String], root: URL) throws { + self.env = env + self.root = root + label = env["MARKDOWN_REPO_BENCH_LABEL"] ?? "run" + let all = "floor,open,publish,tabs,filter,sort,refresh,scroll,ab,fold,offmain" + phases = Set((env["MARKDOWN_REPO_BENCH_PHASES"] ?? all).split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }) + } + + private func int(_ key: String, _ fallback: Int) -> Int { Int(env[key] ?? "") ?? fallback } + private func double(_ key: String, _ fallback: Double) -> Double { Double(env[key] ?? "") ?? fallback } + + // MARK: Clocks + + static func threadCpuMs() -> Double { Double(clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID)) / 1e6 } + + static func processCpuMs(_ who: Int32 = RUSAGE_SELF) -> Double { + var usage = rusage() + getrusage(who, &usage) + func ms(_ t: timeval) -> Double { Double(t.tv_sec) * 1000 + Double(t.tv_usec) / 1000 } + return ms(usage.ru_utime) + ms(usage.ru_stime) + } + + static func load1() -> Double { + var loads = [Double](repeating: 0, count: 3) + return getloadavg(&loads, 3) > 0 ? loads[0] : -1 + } + + nonisolated static func percentile(_ values: [Double], _ p: Double) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let rank = Int((p * Double(sorted.count)).rounded(.up)) + return sorted[min(max(rank, 1), sorted.count) - 1] + } + + // MARK: Run + + func run() throws { + let loadBefore = Self.load1() + try makeWindow() + print("repo bench: \(model.docs.count) tabs, repository \(repo.root.path), \(repo.status?.entries.count ?? 0) status entries, load \(loadBefore)") + RenderProbe.enabled = true + defer { RenderProbe.enabled = false } + watches = [ + RenderProbe.watch(model, "docs"), RenderProbe.watch(repo, "repo"), RenderProbe.watch(GitRepos.shared, "repos"), + RenderProbe.watch(model.configuration, "config"), + ].compactMap { $0 } + if phases.contains("floor") { floor() } + if phases.contains("open") { open() } else { showRepositoryTab() } + if phases.contains("publish") { publish() } + if phases.contains("tabs") { tabs() } + if phases.contains("filter") { try filter() } + if phases.contains("sort") { sort() } + if phases.contains("refresh") { refresh() } + if phases.contains("scroll") { scroll("scroll") } + if phases.contains("ab") { attribution() } + if phases.contains("fold") { try fold() } + if phases.contains("offmain") { try offMain() } + if let path = env["MARKDOWN_REPO_BENCH_SAMPLE"] { try sample(to: path) } + watches.removeAll() + window.orderOut(nil) + window.close() + try report(loadBefore: loadBefore, loadAfter: Self.load1()) + } + + private func notes() throws -> [URL] { + if let list = env["MARKDOWN_REPO_BENCH_TAB_LIST"] { + let paths = try String(contentsOfFile: list, encoding: .utf8).split(separator: "\n").map(String.init).filter { !$0.isEmpty } + let found = paths.map { URL(fileURLWithPath: $0) }.filter { FileManager.default.fileExists(atPath: $0.path) } + if !found.isEmpty { return found } + } + let limit = int("MARKDOWN_REPO_BENCH_TABS", 39) + var found: [URL] = [] + let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .skipsPackageDescendants]) + while let url = walker?.nextObject() as? URL, found.count < limit { + if url.pathExtension == "md" { found.append(url) } + } + if found.isEmpty { throw XCTSkip("no markdown notes under \(root.path)") } + return found + } + + private func makeWindow() throws { + let model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: "md-repo-bench-\(UUID().uuidString)")!) + // A configuration of its own: the shared one writes the test process's defaults, which other tests read. + model.configuration = GenesisKitMarkdownConfiguration(defaults: UserDefaults(suiteName: "md-repo-bench-config-\(UUID().uuidString)")!) + model.configuration.wrapTabs = env["MARKDOWN_REPO_BENCH_WRAP"] != "0" + model.filesRoot = root.path + model.showFiles = env["MARKDOWN_REPO_BENCH_FILES"] != "0" + model.filesWidth = double("MARKDOWN_REPO_BENCH_FILES_WIDTH", 346) + model.showGit = true + model.gitWidth = double("MARKDOWN_REPO_BENCH_GIT_WIDTH", 560) + UserDefaults.standard.set("file", forKey: Self.tabKey) + UserDefaults.standard.set(GitChangeOrder.lastUpdate.rawValue, forKey: Self.orderKey) + UserDefaults.standard.set(true, forKey: Self.untrackedKey) + model.open(try notes()) + self.model = model + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: double("MARKDOWN_REPO_BENCH_WIDTH", 2056), height: double("MARKDOWN_REPO_BENCH_HEIGHT", 1290)), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = MarkdownDocumentWindowController.contentView(model: model) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + self.window = window + slices(3) + // The repository and its first status: the lookup and the read run off the main thread. + let folder = try XCTUnwrap(model.selected).url.deletingLastPathComponent() + let deadline = Date().addingTimeInterval(90) + while Date() < deadline { + if let found = GitRepos.shared.lookup(for: folder).model, found.status != nil { + repo = found + break + } + slices(0.1) + } + guard repo != nil else { throw XCTSkip("no git status for \(folder.path) within 90 s") } + idleProcessMsPerMs = measureIdleRate() + } + + private func slice(_ seconds: TimeInterval = 0.016) { + window.contentView?.layoutSubtreeIfNeeded() + window.displayIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(seconds)) + } + + private func slices(_ seconds: TimeInterval) { + let end = Date().addingTimeInterval(seconds) + while Date() < end { slice() } + } + + /// Settles until the process is quiet, then the process's own CPU per ms at rest (web content, the tree's watcher). + private func measureIdleRate() -> Double { + var quiet = 0 + let deadline = Date().addingTimeInterval(30) + while quiet < 5, Date() < deadline { + let p0 = Self.processCpuMs() + slices(0.2) + quiet = Self.processCpuMs() - p0 < 10 ? quiet + 1 : 0 + } + let p0 = Self.processCpuMs(), t0 = CFAbsoluteTimeGetCurrent() + slices(1) + return (Self.processCpuMs() - p0) / ((CFAbsoluteTimeGetCurrent() - t0) * 1000) + } + + // MARK: Measuring one step + + @discardableResult + private func measure(_ phase: String, step: Int, _ settle: Settle = Settle(), extra: [String: Any] = [:], action: () -> Void) -> [String: Any] { + let bodies = RenderProbe.snapshot() + let meter = MainBusy.Meter() + let t0 = CFAbsoluteTimeGetCurrent(), c0 = Self.threadCpuMs(), p0 = Self.processCpuMs() + meter.start() + action() + var lastMain = Self.threadCpuMs(), lastProcess = Self.processCpuMs(), lastTick = CFAbsoluteTimeGetCurrent() + var quiet = 0 + var settledAt = t0 + var heights: [(Double, CGFloat)] = [] + var capped = false + while true { + slice() + let now = CFAbsoluteTimeGetCurrent(), main = Self.threadCpuMs(), process = Self.processCpuMs() + let mainQuiet = main - lastMain < 1.5 + let processQuiet = !settle.processQuiet || process - lastProcess < idleProcessMsPerMs * (now - lastTick) * 1000 + 2 + lastMain = main + lastProcess = process + lastTick = now + if settle.trackHeight, let height = sidebarScroll()?.documentView?.frame.height { heights.append(((now - t0) * 1000, height)) } + if mainQuiet && processQuiet { quiet += 1 } else { quiet = 0; settledAt = now } + if quiet >= settle.quietSlices, now - t0 >= settle.minSeconds, settle.until?() ?? true { break } + if now - t0 > settle.cap { + capped = true + break + } + } + let reading = meter.stop() + let ran = RenderProbe.snapshot().compactMapValues { $0 }.reduce(into: [String: Int]()) { result, pair in + let delta = pair.value - (bodies[pair.key] ?? 0) + if delta > 0 { result[pair.key] = delta } + } + var record: [String: Any] = [ + "label": label, "run": runID, "repo": root.lastPathComponent, "tabs": model.docs.count, "phase": phase, "step": step, + "mainCpuMs": Self.threadCpuMs() - c0, "busyMs": reading.busyMs, "longestMs": reading.longestMs, "passes": reading.passes, + "settledMs": (settledAt - t0) * 1000, "wallMs": (CFAbsoluteTimeGetCurrent() - t0) * 1000, + "processCpuMs": Self.processCpuMs() - p0, "bodies": ran, "capped": capped, "load1": Self.load1(), + "sidebarHeight": Double(sidebarScroll()?.documentView?.frame.height ?? 0), + ] + if let final = heights.last?.1, let first = heights.first?.1, final > first + 44 { + record["firstRowsMs"] = heights.first { $0.1 >= first + (final - first) * 0.9 }?.0 + } + record.merge(extra) { $1 } + append(record) + return record + } + + private func append(_ record: [String: Any]) { + let phase = record["phase"] as? String ?? "?" + if steps[phase] == nil { phaseOrder.append(phase) } + steps[phase, default: []].append(record) + guard let out = env["MARKDOWN_REPO_BENCH_OUT"], + let data = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) else { return } + if !FileManager.default.fileExists(atPath: out) { FileManager.default.createFile(atPath: out, contents: nil) } + if let handle = FileHandle(forWritingAtPath: out) { + handle.seekToEndOfFile() + handle.write(data + Data("\n".utf8)) + try? handle.close() + } + } + + // MARK: Finding the panel + + /// The git sidebar's scroll view: the tallest one whose middle lies in the sidebar's column. + private func sidebarScroll() -> NSScrollView? { + guard let content = window.contentView else { return nil } + let left = window.frame.width - CGFloat(model.gitWidth) - 4 + var found: [NSScrollView] = [] + func walk(_ view: NSView) { + if let scroll = view as? NSScrollView, scroll.convert(scroll.bounds, to: nil).midX > left { found.append(scroll) } + view.subviews.forEach(walk) + } + walk(content) + return found.max { ($0.documentView?.frame.height ?? 0) < ($1.documentView?.frame.height ?? 0) } + } + + private func filterField() -> NSTextField? { + guard let content = window.contentView else { return nil } + var found: NSTextField? + func walk(_ view: NSView) { + if found == nil, let field = view as? NSTextField, field.isEditable, field.placeholderString == "Filter" { found = field } + view.subviews.forEach(walk) + } + walk(content) + return found + } + + private func scrollTo(_ y: CGFloat) { + guard let scroll = sidebarScroll() else { return } + scroll.contentView.scroll(to: NSPoint(x: 0, y: max(0, y))) + scroll.reflectScrolledClipView(scroll.contentView) + } + + private func showRepositoryTab() { + UserDefaults.standard.set("repo", forKey: Self.tabKey) + _ = measure("setup", step: 0, Settle(processQuiet: true, cap: 20)) {} + } + + /// Slices until the process has been quiet for a quarter second (cap 20 s); records nothing. + private func settleQuiet(cap: TimeInterval = 20) { + var quiet = 0 + let deadline = Date().addingTimeInterval(cap) + while quiet < 5, Date() < deadline { + let p0 = Self.processCpuMs(), t0 = CFAbsoluteTimeGetCurrent() + slices(0.05) + let allowed = idleProcessMsPerMs * (CFAbsoluteTimeGetCurrent() - t0) * 1000 + 3 + quiet = Self.processCpuMs() - p0 < allowed ? quiet + 1 : 0 + } + } + + // MARK: Phases + + /// The cost of measuring nothing: the settle loop's own floor. + private func floor() { + for step in 0..<5 { measure("floor", step: step) {} } + } + + /// Switching the sidebar to Repository builds the panel from nothing: its file dates and its lists. + private func open() { + for step in 0..<int("MARKDOWN_REPO_BENCH_OPENS", 5) { + UserDefaults.standard.set("file", forKey: Self.tabKey) + settleQuiet() + measure("open", step: step, Settle(processQuiet: true, cap: 20, trackHeight: true)) { + UserDefaults.standard.set("repo", forKey: Self.tabKey) + } + } + } + + /// What a heading report, a dirty flag or any other unrelated model change costs while the panel shows. + private func publish() { + settleQuiet() + for step in 0..<20 { measure("publish", step: step) { model.objectWillChange.send() } } + } + + private func tabs() { + settleQuiet() + guard let plan = MarkdownTreeLagBenchTests.alternatingSelection(ids: model.docs.suffix(2).map(\.id), selections: 10) else { + print("repo bench: tab switches skipped, fewer than two tabs") + return + } + for (step, id) in plan.enumerated() { measure("tabs", step: step, Settle(cap: 4)) { model.select(id) } } + // Back on the tab the run started with. + model.select(plan.count % 2 == 0 ? plan[0] : plan[1]) + model.select(model.docs.last?.id) + settleQuiet() + } + + /// A query typed one key at a time (each key measured for at least 150 ms, a typist's pace), then cleared. + private func filter() throws { + guard let field = filterField() else { + print("repo bench: filter skipped, no Filter field in the panel") + return + } + let query = env["MARKDOWN_REPO_BENCH_QUERY"] ?? "analy" + for round in 0..<2 { + settleQuiet() + window.makeFirstResponder(field) + guard let editor = field.currentEditor() as? NSTextView else { + print("repo bench: filter skipped, the field took no editor") + return + } + for (index, character) in query.enumerated() { + measure("filter.key", step: round * query.count + index, Settle(processQuiet: true, minSeconds: 0.15, cap: 5)) { + editor.insertText(String(character), replacementRange: NSRange(location: NSNotFound, length: 0)) + } + } + measure("filter.clear", step: round, Settle(processQuiet: true, cap: 5)) { + editor.selectAll(nil) + editor.deleteBackward(nil) + } + window.makeFirstResponder(nil) + } + } + + /// The sort button's four orders, twice round. + private func sort() { + settleQuiet() + let orders: [GitChangeOrder] = [.name, .folder, .kind, .lastUpdate] + for step in 0..<8 { + let order = orders[step % orders.count] + measure("sort", step: step, Settle(processQuiet: true, cap: 10), extra: ["order": order.rawValue]) { + UserDefaults.standard.set(order.rawValue, forKey: Self.orderKey) + } + } + } + + /// What every event in the git folder does: a status read, a new generation (the status itself unchanged), the + /// panel's dates and lists again. + private func refresh() { + settleQuiet() + for step in 0..<int("MARKDOWN_REPO_BENCH_REFRESHES", 3) { + let generation = repo.generation + measure("refresh", step: step, Settle(processQuiet: true, cap: 60, until: { self.repo.generation > generation })) { + repo.refresh() + } + } + } + + /// The panel scrolled 45 pt at a time to its end and back to the top. + private func scroll(_ phase: String) { + scrollTo(0) + settleQuiet() + guard let scroll = sidebarScroll() else { + print("repo bench: \(phase) skipped, no sidebar scroll view") + return + } + var y: CGFloat = 0 + var step = 0 + func bottom() -> CGFloat { max(0, (scroll.documentView?.frame.height ?? 0) - scroll.contentView.bounds.height) } + while y < bottom(), step < 800 { + y = min(y + 45, bottom()) + let target = y + measure(phase, step: step, Settle(quietSlices: 2, cap: 2), extra: ["y": Double(target), "down": true]) { scrollTo(target) } + step += 1 + } + while y > 0, step < 1600 { + y = max(0, y - 45) + let target = y + measure(phase, step: step, Settle(quietSlices: 2, cap: 2), extra: ["y": Double(target), "down": false]) { scrollTo(target) } + step += 1 + } + print("repo bench: \(phase) \(step) steps over \(Int(scroll.documentView?.frame.height ?? 0)) pt") + } + + /// Attribution by difference: the same unrelated publish, tab switch and status refresh with the git sidebar on + /// Repository, on This file, and closed, interleaved step by step. What Repository costs over This file is the + /// panel's share; the sample cannot name it, because the panel's work is SwiftUI's own layout frames. + private func attribution() { + let ids = MarkdownTreeLagBenchTests.alternatingSelection(ids: model.docs.suffix(2).map(\.id), selections: 2) + func show(_ variant: String) { + model.showGit = variant != "closed" + if variant != "closed" { UserDefaults.standard.set(variant, forKey: Self.tabKey) } + settleQuiet() + } + for step in 0..<10 { + for variant in ["repo", "file", "closed"] { + show(variant) + measure("ab.publish." + variant, step: step) { model.objectWillChange.send() } + // Always a real switch: to whichever of the two tabs is not selected. + if let ids, let target = ids.first(where: { $0 != model.selected?.id }) { + measure("ab.tabs." + variant, step: step, Settle(cap: 4)) { model.select(target) } + } + if step < 2 { + let generation = repo.generation + measure("ab.refresh." + variant, step: step, Settle(processQuiet: true, cap: 60, until: { self.repo.generation > generation })) { + repo.refresh() + } + } + } + } + model.select(model.docs.last?.id) + show("repo") + } + + /// The largest untracked folder opened, several "Show more" pages, a scroll through the long list, and the folder + /// closed and opened again with its pages kept. Ordered by kind, so the largest folder is the first folder row. + /// SwiftUI builds no accessibility tree without a client, so the rows are found by reading the sidebar's picture + /// (Vision) and clicked with posted mouse events, outside the measured window. + private func fold() throws { + guard let entries = repo.status?.entries, + let largest = GitChangeList.foldUntracked(entries).groups.max(by: { $0.files.count < $1.files.count }) else { + print("repo bench: fold skipped, no untracked folder") + return + } + UserDefaults.standard.set(GitChangeOrder.kind.rawValue, forKey: Self.orderKey) + settleQuiet() + let name = (largest.folder as NSString).lastPathComponent + "/" + // The row reads "<chevron> <folder icon> PHP/ Dev 2686 files": the name is one word of the line. + let isFolderRow: (String) -> Bool = { $0.split(separator: " ").contains { $0 == name } } + // A first click on the branch name, which does nothing: the window's first click may only wake it. + if let branch = findText({ $0.contains(repo.status?.branch ?? "\u{0}") }) { click(branch.point) } + guard let row = findText(isFolderRow) else { + print("repo bench: fold skipped, the folder row \(largest.folder) was not found in the sidebar") + if let scroll = sidebarScroll() { + scrollTo(0) + settleQuiet(cap: 3) + print("repo bench: the sidebar reads: " + textLines(in: scroll).map(\.text).joined(separator: " | ")) + } + return + } + measure("fold.open", step: 0, Settle(processQuiet: true, cap: 10), extra: ["files": largest.files.count]) { click(row.point) } + let pages = int("MARKDOWN_REPO_BENCH_SHOW_MORE", 5) + var rows = 100 // the old page size; since the table (2026-10-25) a folder lists every file and has no Show more + for page in 0..<pages { + let remaining = largest.files.count - rows + guard remaining > 0, let more = findText({ $0.contains("Show") && $0.contains("more of \(remaining)") }, from: row.y) else { + print("repo bench: no Show more button for \(remaining) remaining rows") + break + } + rows += 100 + measure("fold.showMore", step: page, Settle(processQuiet: true, cap: 10), extra: ["rows": min(rows, largest.files.count)]) { + click(more.point) + } + } + scroll("scroll.expanded") + for step in 0..<3 { + for phase in ["fold.close", "fold.open"] { + guard let row = findText(isFolderRow) else { break } + measure(phase, step: step + (phase == "fold.open" ? 1 : 0), Settle(processQuiet: true, cap: 10), + extra: ["files": largest.files.count, "rows": min(rows, largest.files.count)]) { click(row.point) } + } + } + UserDefaults.standard.set(GitChangeOrder.lastUpdate.rawValue, forKey: Self.orderKey) + settleQuiet() + } + + /// The first text line in the sidebar that matches, scrolling down from `from` until one is in view (a lazy row + /// exists only on screen): its middle in window coordinates and the scroll offset it was found at. + private func findText(_ match: (String) -> Bool, from: CGFloat = 0) -> (point: NSPoint, y: CGFloat)? { + guard let scroll = sidebarScroll() else { return nil } + var y = from + while true { + scrollTo(y) + settleQuiet(cap: 3) + let lines = textLines(in: scroll) + if let point = lines.filter({ match($0.text) }).max(by: { $0.point.y < $1.point.y })?.point { + return (point, y) + } + if env["MARKDOWN_REPO_BENCH_DEBUG"] == "1" { + print("repo bench: at \(Int(y)) of \(Int(scroll.documentView?.frame.height ?? 0)): " + lines.map(\.text).filter { $0.contains("ore") || $0.contains("/") && !$0.contains(".") }.joined(separator: " | ")) + } + let bottom = (scroll.documentView?.frame.height ?? 0) - scroll.contentView.bounds.height + if y >= bottom { return nil } + y = min(y + scroll.contentView.bounds.height * 0.8, bottom) + } + } + + /// The text lines Vision reads in the scroll view's visible part, each with its middle in window coordinates. + private func textLines(in scroll: NSScrollView) -> [(text: String, point: NSPoint)] { + guard let content = window.contentView else { return [] } + let rect = content.convert(scroll.bounds, from: scroll).integral + guard let bitmap = content.bitmapImageRepForCachingDisplay(in: rect) else { return [] } + content.cacheDisplay(in: rect, to: bitmap) + guard let image = bitmap.cgImage else { return [] } + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + try? VNImageRequestHandler(cgImage: image).perform([request]) + return (request.results ?? []).compactMap { observation in + guard let text = observation.topCandidates(1).first?.string else { return nil } + let box = observation.boundingBox + // Vision's box is normalised with its origin at the bottom left, as the content view's own coordinates. + let inContent = NSPoint(x: rect.minX + box.midX * rect.width, y: rect.minY + box.midY * rect.height) + let local = content.isFlipped ? NSPoint(x: inContent.x, y: rect.maxY - box.midY * rect.height) : inContent + return (text, content.convert(local, to: nil)) + } + } + + /// A click as the reader makes it, handed to this window now. A posted event (`NSApp.postEvent`, then + /// `nextEvent`) landed at the mirrored height in this window and opened a file row instead; `sendEvent` hits the + /// point. The test hooks keep the window from taking focus. + private func click(_ point: NSPoint) { + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + guard let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1) else { continue } + window.sendEvent(event) + } + } + + // MARK: Off the main thread + + /// The work the panel hands to other threads, run here on the test thread one piece at a time: the status read + /// (git's own CPU is the child's), its parse, the file dates, the derived lists per order, the tree's marks. + private func offMain() throws { + settleQuiet() + let provider = GenesisKitGT.provider + let repoRoot = repo.root + func time(_ phase: String, step: Int, extra: [String: Any] = [:], _ work: () throws -> Void) rethrows { + let t0 = CFAbsoluteTimeGetCurrent(), c0 = Self.threadCpuMs(), k0 = Self.processCpuMs(RUSAGE_CHILDREN) + try work() + var record: [String: Any] = [ + "label": label, "run": runID, "repo": root.lastPathComponent, "tabs": model.docs.count, "phase": phase, "step": step, + "cpuMs": Self.threadCpuMs() - c0, "childCpuMs": Self.processCpuMs(RUSAGE_CHILDREN) - k0, + "wallMs": (CFAbsoluteTimeGetCurrent() - t0) * 1000, "load1": Self.load1(), + ] + record.merge(extra) { $1 } + append(record) + } + var status: GitRepoStatus? + for step in 0..<3 { + try time("off.repoStatus", step: step) { status = try provider.repoStatus(repoRoot, commits: 0) } + } + guard let status else { return } + let entries = status.entries + let raw = try provider.git(["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"], + in: repoRoot, input: nil, gitDirectory: nil, environment: nil) + let text = String(decoding: raw.stdout, as: UTF8.self) + for step in 0..<3 { time("off.parseStatus", step: step, extra: ["bytes": raw.stdout.count]) { _ = GitQueries.parseStatus(text) } } + let other = try provider.repoStatus(repoRoot, commits: 0) + for step in 0..<3 { time("off.statusEquality", step: step) { _ = status == other } } + let reader = GitRepoPanelLists.modificationDates + var dates: [String: Date] = [:] + for step in 0..<3 { time("off.readDates", step: step, extra: ["paths": entries.count]) { dates = reader(repoRoot, entries.map(\.path)) } } + var step = 0 + for order in GitChangeOrder.allCases { + for filter in ["", env["MARKDOWN_REPO_BENCH_QUERY"] ?? "analy"] { + let inputs = GitRepoPanelLists.Inputs(root: repoRoot, order: order, filter: filter, untracked: true) + time("off.derive", step: step, extra: ["order": order.rawValue, "filter": filter]) { + _ = GitRepoPanelLists.derive(entries, dates: dates, inputs: inputs) + } + step += 1 + } + } + for step in 0..<2 { time("off.treeMarks", step: step) { _ = status.decorations(under: root) } } + for step in 0..<2 { time("off.numstat", step: step) { _ = GitQueries(provider: provider).numstat(in: repoRoot) } } + } + + // MARK: Sampling + + /// `/usr/bin/sample` on this process while one phase repeats, so the main thread's frames can be ranked. + private func sample(to path: String) throws { + let phase = env["MARKDOWN_REPO_BENCH_SAMPLE_PHASE"] ?? "publish" + let seconds = int("MARKDOWN_REPO_BENCH_SAMPLE_SECS", 8) + // MARKDOWN_REPO_BENCH_SAMPLE_TAB=file samples the same phase with the sidebar on This file, for a difference. + UserDefaults.standard.set(env["MARKDOWN_REPO_BENCH_SAMPLE_TAB"] ?? "repo", forKey: Self.tabKey) + settleQuiet() + let sampler = Process() + sampler.executableURL = URL(fileURLWithPath: "/usr/bin/sample") + sampler.arguments = [String(getpid()), String(seconds), "-file", path] + sampler.standardOutput = FileHandle.nullDevice + sampler.standardError = FileHandle.nullDevice + try sampler.run() + slices(0.5) + let ids = model.docs.suffix(2).map(\.id) + let orders: [GitChangeOrder] = [.name, .folder, .kind, .lastUpdate] + var step = 0 + var y: CGFloat = 0 + var down = true + while sampler.isRunning { + let name = "sample." + phase + switch phase { + case "open": + UserDefaults.standard.set("file", forKey: Self.tabKey) + slices(0.1) + measure(name, step: step, Settle(processQuiet: true, cap: 20)) { UserDefaults.standard.set("repo", forKey: Self.tabKey) } + case "tabs" where ids.count == 2: + measure(name, step: step, Settle(cap: 4)) { model.select(ids[step % 2]) } + case "refresh": + let generation = repo.generation + measure(name, step: step, Settle(processQuiet: true, cap: 60, until: { self.repo.generation > generation })) { repo.refresh() } + case "sort": + let order = orders[step % orders.count] + measure(name, step: step, Settle(processQuiet: true, cap: 10)) { UserDefaults.standard.set(order.rawValue, forKey: Self.orderKey) } + case "scroll": + let bottom = max(0, (sidebarScroll()?.documentView?.frame.height ?? 0) - (sidebarScroll()?.contentView.bounds.height ?? 0)) + if y >= bottom { down = false } else if y <= 0 { down = true } + y = down ? min(y + 45, bottom) : max(0, y - 45) + let target = y + measure(name, step: step, Settle(quietSlices: 2, cap: 2)) { scrollTo(target) } + default: + measure(name, step: step) { model.objectWillChange.send() } + } + step += 1 + } + sampler.waitUntilExit() + UserDefaults.standard.set(GitChangeOrder.lastUpdate.rawValue, forKey: Self.orderKey) + print("repo bench: sampled \(phase) for \(seconds) s (\(step) steps) into \(path)") + } + + // MARK: Report + + private func report(loadBefore: Double, loadAfter: Double) throws { + var lines = ["repo bench \(label) \(runID): \(model.docs.count) tabs, \(repo.status?.entries.count ?? 0) entries, load \(loadBefore) -> \(loadAfter)"] + lines += Self.table(phaseOrder.compactMap { phase in steps[phase].map { (phase, $0) } }) + print(lines.joined(separator: "\n")) + guard let out = env["MARKDOWN_REPO_BENCH_OUT"] else { return } + // Every run in the file, grouped by label, repository and phase. + let all = try String(contentsOfFile: out, encoding: .utf8).split(separator: "\n").compactMap { line in + (try? JSONSerialization.jsonObject(with: Data(line.utf8))) as? [String: Any] + } + var groups: [String: [[String: Any]]] = [:] + var order: [String] = [] + for record in all { + let key = "\(record["label"] ?? "?") \(record["repo"] ?? "?") \(record["phase"] ?? "?")" + if groups[key] == nil { order.append(key) } + groups[key, default: []].append(record) + } + let runs = Set(all.compactMap { $0["run"] as? String }).count + let summary = ["# Repository panel bench summary", "", "\(all.count) steps from \(runs) runs in \(out).", ""] + + Self.table(order.compactMap { key in groups[key].map { (key, $0) } }) + try summary.joined(separator: "\n").write(toFile: out + ".summary.md", atomically: true, encoding: .utf8) + } + + /// One markdown row per group: median / p95 / max of the CPU, busy and longest-pass ms, the settle and first-rows + /// medians, and the median body and publish counts per step. + nonisolated static func table(_ groups: [(String, [[String: Any]])]) -> [String] { + func values(_ records: [[String: Any]], _ key: String) -> [Double] { records.compactMap { ($0[key] as? NSNumber)?.doubleValue } } + func stat(_ v: [Double]) -> String { + v.isEmpty ? "-" : String(format: "%.1f / %.1f / %.1f", percentile(v, 0.5), percentile(v, 0.95), v.max() ?? 0) + } + func median(_ v: [Double]) -> String { v.isEmpty ? "-" : String(format: "%.0f", percentile(v, 0.5)) } + func count(_ records: [[String: Any]], _ name: String) -> [Double] { + records.map { (($0["bodies"] as? [String: Any])?[name] as? NSNumber)?.doubleValue ?? 0 } + } + var rows = [ + "| group | n | cpu ms (med / p95 / max) | busy ms | longest pass ms | settled ms | first rows ms | git.repoPanel | md.gitSidebar | all bodies | publish.docs | publish.repo | wall ms | child cpu ms |", + "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|", + ] + for (name, records) in groups { + let cpu = values(records, "mainCpuMs").isEmpty ? values(records, "cpuMs") : values(records, "mainCpuMs") + let all = records.map { (($0["bodies"] as? [String: Any]) ?? [:]).filter { !$0.key.hasPrefix("publish.") }.values + .compactMap { ($0 as? NSNumber)?.doubleValue }.reduce(0, +) } + rows.append("| \(name) | \(records.count) | \(stat(cpu)) | \(stat(values(records, "busyMs"))) | \(stat(values(records, "longestMs"))) | " + + "\(median(values(records, "settledMs"))) | \(median(values(records, "firstRowsMs"))) | \(median(count(records, "git.repoPanel"))) | " + + "\(median(count(records, "md.gitSidebar"))) | \(median(all)) | \(median(count(records, "publish.docs"))) | " + + "\(median(count(records, "publish.repo"))) | \(median(values(records, "wallMs"))) | \(median(values(records, "childCpuMs"))) |") + } + return rows + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownSettingsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownSettingsTests.swift new file mode 100644 index 0000000000..784ee4dbfd --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownSettingsTests.swift @@ -0,0 +1,44 @@ +import AppKit +@testable import GenesisKit +import SwiftUI +import XCTest + +/// The viewer's Settings window, and the two places agent attribution shows. `MARKDOWN_SHOT_DIR` writes them as PNGs. +@MainActor +final class MarkdownSettingsTests: XCTestCase { + func testTheSettingsMenuOpensTheGitSettingsWithTheAttributionCost() throws { + let menu = MarkdownViewerMenu.build(appName: "Genesis Markdown") + let item = try XCTUnwrap(menu.items.first?.submenu?.items.first { $0.title == "Settings…" }) + XCTAssertEqual(item.keyEquivalent, ",") + XCTAssertTrue(item.target === MarkdownSettingsWindow.shared) + + // Graphite: a test bitmap skips the layers Liquid Glass is made of, so a glass card draws nothing there. + let view = MarkdownSettingsView(configuration: .shared) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, .graphite) + .environment(\.colorScheme, .dark) + let (window, host) = HostedShot.host(view, size: CGSize(width: 520, height: 470)) + defer { window.close() } + RunLoop.main.run(until: Date().addingTimeInterval(0.4)) + try shot(host, "markdown-settings-git.png") + } + + func testTheGutterCardNamesTheSessionThatWroteTheHunk() throws { + let source = AgentBlameSource(provider: "claude", session: "9fae860c-68b8-43fd-b85a-72d538afbedb", turn: "t1", + ts: "2026-10-24T17:40:00Z", prompt: "Fix the blame storm") + let hunk = GitHunk(kind: .modified, newStart: 11, newCount: 2, oldStart: 11, oldLines: ["The old sentence of this note."]) + let view = GitHunkCard(hunk: hunk, source: source, revert: {}) + .background(Color(nsColor: .windowBackgroundColor)).environment(\.colorScheme, .dark) + let (window, host) = HostedShot.host(view, size: CGSize(width: 380, height: 170)) + defer { window.close() } + RunLoop.main.run(until: Date().addingTimeInterval(0.4)) + try shot(host, "markdown-attribution-gutter-card.png") + } + + private func shot(_ host: NSView, _ name: String) throws { + guard let directory = ProcessInfo.processInfo.environment["MARKDOWN_SHOT_DIR"] else { return } + let rep = try XCTUnwrap(HostedShot.bitmap(host)) + try XCTUnwrap(rep.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: directory).appendingPathComponent(name)) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownSidebarKeyboardTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownSidebarKeyboardTests.swift new file mode 100644 index 0000000000..a42722b19a --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownSidebarKeyboardTests.swift @@ -0,0 +1,138 @@ +import AppKit +@testable import GenesisKit +import XCTest + +/// The file sidebar from the keyboard (UX audit 2026-10-25, E3, E4, E8, E9): the tree walks with the arrows and opens +/// with Return, and the search says when it was cut or could not run. +@MainActor +final class MarkdownSidebarKeyboardTests: XCTestCase { + /// notes/ (open) › a.md, b.md; z.md at the root. + private let rows = [ + FileTreeRow(id: "/v/notes", name: "notes", isFolder: true, depth: 0, expanded: true), + FileTreeRow(id: "/v/notes/a.md", name: "a.md", isFolder: false, depth: 1), + FileTreeRow(id: "/v/notes/b.md", name: "b.md", isFolder: false, depth: 1), + FileTreeRow(id: "/v/old", name: "old", isFolder: true, depth: 0), + FileTreeRow(id: "/v/z.md", name: "z.md", isFolder: false, depth: 0), + ] + + private func action(_ key: MarkdownTreeKey, at cursor: Int?) -> MarkdownTreeKeyAction { + MarkdownTreeKeys.action(for: key, rows: rows, cursor: cursor) + } + + func testUpAndDownMoveAndStopAtTheEnds() { + XCTAssertEqual(action(.down, at: nil), .select(0), "the first Down lands on the first row") + XCTAssertEqual(action(.down, at: 1), .select(2)) + XCTAssertEqual(action(.down, at: 4), .select(4)) + XCTAssertEqual(action(.up, at: 0), .select(0)) + XCTAssertEqual(action(.up, at: 3), .select(2)) + } + + func testRightOpensAFolderThenEntersIt() { + XCTAssertEqual(action(.right, at: 3), .toggle(3), "a closed folder opens") + XCTAssertEqual(action(.right, at: 0), .select(1), "an open folder: its first row") + XCTAssertEqual(action(.right, at: 1), MarkdownTreeKeyAction.none, "a file has nothing to the right") + } + + func testLeftClosesAFolderOrGoesToItsParent() { + XCTAssertEqual(action(.left, at: 0), .toggle(0)) + XCTAssertEqual(action(.left, at: 2), .select(0), "a file inside notes: back to notes") + XCTAssertEqual(action(.left, at: 4), MarkdownTreeKeyAction.none, "a row at the root has no parent row") + } + + func testReturnOpensAFileAndTogglesAFolder() { + XCTAssertEqual(action(.open, at: 1), .open(1)) + XCTAssertEqual(action(.open, at: 3), .toggle(3)) + XCTAssertEqual(action(.navigate, at: 3), .navigate(3), "⌘↓ on a folder makes it the root") + XCTAssertEqual(action(.parent, at: nil), .up) + XCTAssertEqual(action(.escape, at: 2), .clear) + XCTAssertEqual(action(.escape, at: nil), MarkdownTreeKeyAction.none, "Esc with no keyboard row goes on to the window") + } + + func testTheTableWalksAndOpensFromTheKeyboard() { + let table = MarkdownTreeTableView() + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("row"))) + let coordinator = MarkdownTreeTable<Int>.Coordinator() + coordinator.rows = rows + coordinator.table = table + table.dataSource = coordinator + table.delegate = coordinator + table.coordinatorKeys = coordinator + table.takesKeyboard = true + table.reloadData() + var opened: [String] = [] + var toggled: [String] = [] + coordinator.keys = MarkdownTreeKeys( + open: { opened.append($0.id) }, toggle: { toggled.append($0.id) }, navigate: { _ in }, up: {}, + start: { rows in rows.firstIndex { $0.id == "/v/notes/a.md" } } + ) + + XCTAssertTrue(table.acceptsFirstResponder) + XCTAssertEqual(table.selectionHighlightStyle, .regular, "the keyboard row is drawn") + coordinator.beginKeyboard(at: nil) + XCTAssertEqual(table.selectedRow, 1, "the keyboard starts on the open file") + XCTAssertTrue(coordinator.handle(.down)) + XCTAssertEqual(table.selectedRow, 2) + XCTAssertTrue(coordinator.handle(.open)) + XCTAssertEqual(opened, ["/v/notes/b.md"]) + XCTAssertTrue(coordinator.handle(.left)) + XCTAssertEqual(table.selectedRow, 0) + XCTAssertTrue(coordinator.handle(.left)) + XCTAssertEqual(toggled, ["/v/notes"]) + + // The folder closed: its rows left, and the keyboard stays on the folder, not on the index it had. + coordinator.rows = [ + FileTreeRow(id: "/v/notes", name: "notes", isFolder: true, depth: 0), + FileTreeRow(id: "/v/old", name: "old", isFolder: true, depth: 0), + ] + table.reloadData() + coordinator.keepCursor() + XCTAssertEqual(coordinator.cursor, 0) + + coordinator.endKeyboard() + XCTAssertEqual(table.selectedRow, -1) + XCTAssertNil(coordinator.cursor) + } + + func testATreeWithoutKeysNeverTakesTheKeyboard() { + let table = MarkdownTreeTableView() + XCTAssertFalse(table.acceptsFirstResponder) + } + + func testTheArrowKeysMapToTreeKeys() throws { + func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags = []) throws -> MarkdownTreeKey? { + let event = try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, + windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", + isARepeat: false, keyCode: code)) + return MarkdownTreeKey(event) + } + XCTAssertEqual(try key(125), .down) + XCTAssertEqual(try key(125, .command), .navigate) + XCTAssertEqual(try key(126, .command), .parent) + XCTAssertEqual(try key(36), .open) + XCTAssertEqual(try key(53), .escape) + XCTAssertNil(try key(0), "a letter is not the tree's") + } + + // MARK: Search and empty states + + func testACutListSaysWhatWasCut() { + XCTAssertEqual(MarkdownFileTreeView.cutNotice(names: true, indexFull: false), "First 200 names shown. Type more of the name") + XCTAssertEqual(MarkdownFileTreeView.cutNotice(names: true, indexFull: true), + "Index stopped at 200000 files. Pick a narrower folder") + XCTAssertEqual(MarkdownFileTreeView.cutNotice(names: false, indexFull: true), "First 300 hits shown") + } + + func testRipgrepFailsOnlyWhenItFoundNothing() { + XCTAssertTrue(FileTreeSearch.ripgrepFailed(status: 2, hits: 0)) + XCTAssertFalse(FileTreeSearch.ripgrepFailed(status: 2, hits: 4), "an unreadable file beside real hits is no failure") + XCTAssertFalse(FileTreeSearch.ripgrepFailed(status: 1, hits: 0), "1 is ripgrep's plain No match") + } + + func testAnEmptyFolderAndAnUnreadableOneAreDifferentStates() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("tree-empty-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: folder) } + XCTAssertEqual(MarkdownTreeEmpty.check(folder.path), .empty) + XCTAssertEqual(MarkdownTreeEmpty.check(folder.appendingPathComponent("gone").path), .unreadable) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTabGroupingTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTabGroupingTests.swift new file mode 100644 index 0000000000..37fb0357bb --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownTabGroupingTests.swift @@ -0,0 +1,305 @@ +import AppKit +import SwiftUI +import XCTest +@testable import GenesisKit + +/// The tab groups: the engine's rules, the reader's own edits, and the model actions the tab bar's drags and menus call. +@MainActor +final class MarkdownTabGroupingTests: XCTestCase { + private func item(_ path: String) -> MarkdownTabGroupItem { + MarkdownTabGroupItem(id: path, path: path, title: ((path as NSString).lastPathComponent as NSString).deletingPathExtension) + } + + func testProjectGroupsByTheTopFolderAndSendsSingleTabsToOther() { + let items = ["/v/Alpha/a.md", "/v/Beta/b.md", "/v/Alpha/deep/c.md", "/v/Beta/d.md", "/v/Gamma/e.md"].map(item) + let groups = MarkdownTabGrouping.groups(items, rule: .smart, state: .init()) + XCTAssertEqual(groups.map(\.title), ["Alpha", "Beta", "Other"], "first appearance order, Other last") + XCTAssertEqual(groups[0].tabIDs, ["/v/Alpha/a.md", "/v/Alpha/deep/c.md"]) + XCTAssertEqual(groups[2].tabIDs, ["/v/Gamma/e.md"], "a one-tab group joins Other") + XCTAssertTrue(MarkdownTabGrouping.groups(items, rule: .off, state: .init()).isEmpty) + } + + func testABigProjectGroupSplitsByItsNextFolder() { + let big = (0 ..< 7).map { "/v/Big/P/p\($0).md" } + (0 ..< 7).map { "/v/Big/Q/q\($0).md" } + let items = (big + ["/v/Small/s1.md", "/v/Small/s2.md"]).map(item) + let groups = MarkdownTabGrouping.groups(items, rule: .smart, state: .init(), splitAbove: 12) + XCTAssertEqual(groups.map(\.title), ["P", "Q", "Small"]) + XCTAssertEqual(groups.first?.detail, "Big", "a split group names where it is") + XCTAssertEqual(MarkdownTabGrouping.groups(items, rule: .smart, state: .init(), splitAbove: 20).map(\.title), + ["Big", "Small"], "under the limit it stays one group") + } + + func testTheReadersMovesNamesOrderAndFoldsWin() { + let items = ["/v/A/1.md", "/v/A/2.md", "/v/B/3.md", "/v/B/4.md"].map(item) + var state = MarkdownTabGroupState() + state.assigned["/v/A/2.md"] = "manual:mine" + state.names["manual:mine"] = "Reading" + state.order = ["dir:/v/B"] + let groups = MarkdownTabGrouping.groups(items, rule: .smart, state: state, minimumSize: 1) + XCTAssertEqual(groups.map(\.title), ["B", "A", "Reading"], "the saved order first, then by first appearance") + XCTAssertEqual(groups.last?.manual, true) + XCTAssertEqual(groups.last?.tabIDs, ["/v/A/2.md"]) + let order = MarkdownTabGrouping.visualOrder(groups, collapsed: ["dir:/v/B"], selected: "/v/B/4.md") + XCTAssertEqual(order, ["/v/B/4.md", "/v/A/1.md", "/v/A/2.md"], "a folded group keeps only the selected tab") + } + + func testDayAndKindRules() throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-10-23T12:00:00Z")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "UTC")) + let items = ["/v/2026-10-23-A.md", "/v/2026-10-22-B.md", "/v/2026-10-19-C.md", "/v/2026-09-01-D.md", + "/v/Notes.md", "/v/setup.log"].map(item) + let days = MarkdownTabGrouping.groups(items, rule: .day, state: .init(), minimumSize: 1, now: now, + calendar: calendar) + XCTAssertEqual(days.map(\.title), ["Today", "Yesterday", "This Week", "Earlier", "Undated"]) + let kinds = MarkdownTabGrouping.groups(items, rule: .kind, state: .init(), minimumSize: 1) + XCTAssertEqual(kinds.map(\.title), ["Notes", "Logs"]) + } + + func testFoldedGroupsNextToEachOtherShareARow() { + func group(_ id: String) -> MarkdownTabGroup { + MarkdownTabGroup(id: id, title: id, detail: nil, tabIDs: [id + "1"], manual: false) + } + let rows = MarkdownTabGroupRows.rows([group("a"), group("b"), group("c"), group("d")], collapsed: ["a", "b", "d"]) + XCTAssertEqual(rows.map { $0.groups.map(\.id) }, [["a", "b"], ["c"], ["d"]]) + } + + func testDragsMoveTabsIntoGroupsAndReorderGroups() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("md-tab-groups-\(UUID().uuidString)") + let paths = ["A/1.md", "A/2.md", "B/3.md", "B/4.md"].map { folder.appendingPathComponent($0) } + for url in paths { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "# \(url.lastPathComponent)".write(to: url, atomically: true, encoding: .utf8) + } + let model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: "md-tab-groups-\(UUID().uuidString)")!) + model.configuration = GenesisKitMarkdownConfiguration( + defaults: UserDefaults(suiteName: "md-tab-groups-config-\(UUID().uuidString)")!) + model.configuration.tabGroupFrom = 0 + model.open(paths) + let ids = model.docs.map(\.id) + XCTAssertEqual(model.tabGroups.map(\.title), ["A", "B"]) + + model.dropTab(ids[0], beside: ids[3], after: false) + XCTAssertEqual(model.group(of: ids[0])?.title, "B", "a tab dropped on a tab joins that tab's group") + XCTAssertEqual(model.docs.map(\.id), [ids[1], ids[2], ids[0], ids[3]], "and sits before it") + + model.dropTab(ids[0], beside: ids[3], after: true) + XCTAssertEqual(model.docs.map(\.id), [ids[1], ids[2], ids[3], ids[0]], "a drop on a tab's right half lands after it") + let groups = model.tabGroups.map(\.id) + let titles = model.tabGroups.map(\.title) + model.moveGroup(groups[0], beside: groups[1], after: true) + XCTAssertEqual(model.tabGroups.map(\.title), [titles[1], titles[0]], "a group dropped on another's right half goes after it") + model.moveGroup(groups[0], beside: groups[1], after: false) + XCTAssertEqual(model.tabGroups.map(\.title), titles, "and on its left half before it") + + model.moveGroup("dir:" + folder.appendingPathComponent("B").path, before: nil) + XCTAssertEqual(model.tabGroups.map(\.title).last, "B", "a group dragged to the end stays there") + + let mine = model.newGroup(with: ids[1]) + model.renameGroup(mine, to: "Reading") + XCTAssertEqual(model.group(of: ids[1])?.title, "Reading") + model.toggleGroup(mine) + XCTAssertFalse(model.visibleTabOrder.contains(ids[1]) && model.selected?.id != ids[1], + "a folded group's tabs leave Next Tab's order unless selected") + model.ungroup(mine) + XCTAssertNotEqual(model.group(of: ids[1])?.title, "Reading", "ungrouping gives the tab back to its rule") + + model.configuration.tabGroupFrom = 10 + XCTAssertTrue(model.tabGroups.isEmpty, "below the threshold the bar stays one flat row") + } + + /// Renders a 72-tab bar of real vault notes (read only) to a PNG, for the eye: + /// MARKDOWN_TAB_GROUPS_RENDER=<out.png> [MARKDOWN_TAB_GROUPS_ROOT=<folder>] swift test --filter MarkdownTabGrouping + func testRenderSeventyTwoTabsGrouped() throws { + let env = ProcessInfo.processInfo.environment + guard let out = env["MARKDOWN_TAB_GROUPS_RENDER"] else { throw XCTSkip("set MARKDOWN_TAB_GROUPS_RENDER") } + let root = URL(fileURLWithPath: env["MARKDOWN_TAB_GROUPS_ROOT"] ?? NSHomeDirectory()) + // Spread over the root's top folders, as a reader's tabs are: at most 3 notes per folder, 12 per top folder. + var notes: [URL] = [] + var perFolder: [String: Int] = [:] + var perTop: [String: Int] = [:] + let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + while let url = walker?.nextObject() as? URL, notes.count < 72 { + let folder = url.deletingLastPathComponent().path + let top = url.pathComponents.dropFirst(root.pathComponents.count).first ?? "" + guard url.pathExtension == "md", perFolder[folder, default: 0] < 3, perTop[top, default: 0] < 12 else { + continue + } + perFolder[folder, default: 0] += 1 + perTop[top, default: 0] += 1 + notes.append(url) + } + let model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: "md-tab-render-\(UUID().uuidString)")!) + model.configuration = GenesisKitMarkdownConfiguration( + defaults: UserDefaults(suiteName: "md-tab-render-config-\(UUID().uuidString)")!) + model.configuration.wrapTabs = env["MARKDOWN_TAB_GROUPS_WRAP"] != "0" + model.open(notes) + if let folded = model.tabGroups.dropFirst(2).first { model.toggleGroup(folded.id) } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1900, height: 1150), + styleMask: [.titled, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = MarkdownDocumentWindowController.contentView(model: model) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { + window.orderOut(nil) + window.close() + } + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.05)) } + let view = try XCTUnwrap(window.contentView) + let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: rep) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: out)) + print("groups:", model.tabGroups.map { "\($0.title)(\($0.tabIDs.count))" }.joined(separator: " ")) + } + + /// A real click on a tab must select it (Martin, 2026-10-23: "the whole panel is not clickable anywhere"). A + /// rendered image passed while no tab took a click; only a click proves a control works. The click lands on one + /// known tab, found by its accessibility frame, in a hidden window: never a scan over points, which pressed the + /// toolbar's Obsidian button and opened dialogs on the user's screen (2026-10-23 16:45). + func testAClickOnATabSelectsIt() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("md-tab-click-\(UUID().uuidString)") + let names = (0 ..< 40).map { "\($0 % 2 == 0 ? "A" : "B")/note\($0).md" } + let paths = names.map { folder.appendingPathComponent($0) } + for url in paths { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "# \(url.lastPathComponent)".write(to: url, atomically: true, encoding: .utf8) + } + FrameProbe.enabled = true + defer { FrameProbe.enabled = false } + var results: [String] = [] + for (grouped, count, files) in [(false, 4, false), (true, 4, false), (false, 40, false), (true, 40, false), + (false, 40, true), (true, 40, true)] { + let model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: "md-tab-click-\(UUID().uuidString)")!) + model.configuration = GenesisKitMarkdownConfiguration( + defaults: UserDefaults(suiteName: "md-tab-click-config-\(UUID().uuidString)")!) + model.configuration.wrapTabs = true + model.configuration.tabGroupFrom = grouped ? 0 : 1000 + model.open(Array(paths.suffix(count))) + model.showFiles = files + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 700), + styleMask: [.titled, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = MarkdownDocumentWindowController.contentView(model: model) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { + window.orderOut(nil) + window.close() + } + FrameProbe.reset() + pump(1.5) + // The page's AppKit frame must not reach into the tab bar: hit-testing ignores layer clips, so a page + // frame over the tabs takes their clicks while they still show through its transparent top. + func webViews(_ view: NSView) -> [NSView] { + (String(describing: type(of: view)).contains("WebView") ? [view] : []) + view.subviews.flatMap(webViews) + } + let tabBottom = FrameProbe.frames.filter { $0.key.hasPrefix("md.tab.") }.map(\.value.maxY).max() ?? 0 + for web in webViews(try XCTUnwrap(window.contentView)) where !web.isHidden { + let frame = web.convert(web.bounds, to: nil) + let top = window.frame.height - frame.maxY + results.append("web \(type(of: web)) top=\(Int(top)) h=\(Int(frame.height)) tabsEnd=\(Int(tabBottom))") + XCTAssertGreaterThanOrEqual(top + 0.5, tabBottom, "the page starts below the last tab row") + } + let target = model.docs[model.docs.count - 2] + let frame = try XCTUnwrap(FrameProbe.frames["md.tab." + target.id], "the tab \(target.title) has a frame") + // SwiftUI's global space has its origin top left; a window event's location is bottom left. + let center = NSPoint(x: frame.midX, y: window.contentLayoutRect.height + (window.frame.height + - window.contentLayoutRect.height) - frame.midY) + click(window, at: NSPoint(x: center.x, y: window.frame.height - frame.midY)) + let hit = model.selected?.id == target.id + results.append("grouped=\(grouped) tabs=\(count) files=\(files) at=\(Int(center.x)),\(Int(center.y)) selected=\(hit)") + XCTAssertTrue(hit, "a click on the tab selects it (grouped \(grouped), \(count) tabs, files \(files))") + } + print("tab clicks:", results.joined(separator: " | ")) + } + + /// The wrapped bar's bottom line is the sidebars' divider laid flat (Martin, 2026-10-23: "have the tab resizeable + /// using the same component we do for resizing the sidebars"). A drag down shows more rows and is saved; a + /// double-click goes back to the row count. + func testDraggingTheTabBarEdgeSetsItsHeight() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent("md-tab-drag-\(UUID().uuidString)") + let paths = (0 ..< 40).map { folder.appendingPathComponent("note\($0).md") } + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + for url in paths { + try "# \(url.lastPathComponent)".write(to: url, atomically: true, encoding: .utf8) + } + FrameProbe.enabled = true + defer { FrameProbe.enabled = false } + let model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: "md-tab-drag-\(UUID().uuidString)")!) + model.configuration = GenesisKitMarkdownConfiguration( + defaults: UserDefaults(suiteName: "md-tab-drag-config-\(UUID().uuidString)")!) + model.configuration.wrapTabs = true + model.configuration.tabGroupFrom = 1000 + model.configuration.wrapTabsMaxRows = 2 + model.open(paths) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 700), + styleMask: [.titled, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = MarkdownDocumentWindowController.contentView(model: model) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { + window.orderOut(nil) + window.close() + } + pump(1.5) + let before = try XCTUnwrap(FrameProbe.frames["md.tabBarDivider"], "the tab bar has its divider") + let point = NSPoint(x: before.midX, y: window.frame.height - before.midY) + func event(_ type: NSEvent.EventType, _ location: NSPoint, clicks: Int = 1) -> NSEvent { + NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: clicks, pressure: 1)! + } + NSApp.postEvent(event(.leftMouseDown, point), atStart: false) + for step in 1 ... 10 { + NSApp.postEvent(event(.leftMouseDragged, NSPoint(x: point.x, y: point.y - CGFloat(step * 10))), atStart: false) + pump(0.03) + } + NSApp.postEvent(event(.leftMouseUp, NSPoint(x: point.x, y: point.y - 100)), atStart: false) + pump(0.5) + let after = try XCTUnwrap(FrameProbe.frames["md.tabBarDivider"]) + print("tab bar drag: divider \(Int(before.midY)) -> \(Int(after.midY)), height \(model.configuration.tabBarHeight)") + XCTAssertEqual(model.configuration.tabBarHeight, Double(2 * 32 + 8) + 100, accuracy: 1, "the drag sets the height") + XCTAssertEqual(after.midY - before.midY, 100, accuracy: 2, "the bar grows with the pointer") + + NSApp.postEvent(event(.leftMouseDown, NSPoint(x: point.x, y: point.y - 100), clicks: 2), atStart: false) + NSApp.postEvent(event(.leftMouseUp, NSPoint(x: point.x, y: point.y - 100), clicks: 2), atStart: false) + pump(0.5) + XCTAssertEqual(model.configuration.tabBarHeight, 0, "a double-click goes back to the row count") + XCTAssertEqual(try XCTUnwrap(FrameProbe.frames["md.tabBarDivider"]).midY, before.midY, accuracy: 1) + } + + private func pump(_ seconds: TimeInterval) { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if let next = NSApp.nextEvent(matching: .any, until: deadline, inMode: .default, dequeue: true) { + NSApp.sendEvent(next) + } + } + } + + /// As a pointer does it: moves onto the point and rests there (tooltips and hover previews arm), then clicks. + private func click(_ window: NSWindow, at location: NSPoint) { + func event(_ type: NSEvent.EventType, at point: NSPoint = location) -> NSEvent { + NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: type == .mouseMoved ? 0 : 1, pressure: 1)! + } + window.acceptsMouseMovedEvents = true + for step in 0 ... 4 { + let point = NSPoint(x: location.x - CGFloat(20 - step * 5), y: location.y) + NSApp.postEvent(event(.mouseMoved, at: point), atStart: false) + pump(0.03) + } + pump(Double(ProcessInfo.processInfo.environment["TAB_CLICK_REST"] ?? "") ?? 0.5) + NSApp.postEvent(event(.leftMouseDown), atStart: false) + NSApp.postEvent(event(.leftMouseUp), atStart: false) + pump(0.4) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift index f647614dde..7742390ee2 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownViewerAppTests.swift @@ -19,9 +19,9 @@ final class MarkdownViewerAppTests: XCTestCase { let closeWindow = file?.items.first { $0.title == "Close Window" } XCTAssertEqual(closeWindow?.keyEquivalent, "w") XCTAssertEqual(closeWindow?.keyEquivalentModifierMask, [.command, .shift]) - // Without an Edit menu ⌘C/⌘V never reach the text views. + // Without an Edit menu ⌘C/⌘V never reach the text views. Find and Find in Files follow them. let edit = menu.items.first { $0.title == "Edit" }?.submenu - XCTAssertEqual(edit?.items.map(\.keyEquivalent).filter { !$0.isEmpty }, ["z", "z", "x", "c", "v", "a"]) + XCTAssertEqual(edit?.items.map(\.keyEquivalent).filter { !$0.isEmpty }, ["z", "z", "x", "c", "v", "a", "f", "f"]) XCTAssertEqual(menu.items.first?.submenu?.items.last?.title, "Quit Viewer") } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownWebSecurityTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownWebSecurityTests.swift index 0859fca4ba..89749b2c17 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownWebSecurityTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownWebSecurityTests.swift @@ -454,7 +454,8 @@ final class MarkdownWebSecurityTests: XCTestCase { let context = MarkdownMedia.Context(folder: v.note.deletingLastPathComponent(), vaultRoot: v.vault) var staleCalls = 0 let first = registry.update(markdown: "![[by-name.png]]", context: context) { staleCalls += 1 } - XCTAssertTrue(first.isEmpty, "the first render returns while the walk is blocked") + XCTAssertEqual(first["by-name.png"]?["kind"], "pending", + "the first render returns while the walk is blocked, with no negative answer yet") await fulfillment(of: [started], timeout: 3) XCTAssertTrue(index.lookup("by-name.png").isEmpty, "lookup reads only the available cache") let ready = expectation(description: "latest document notified") diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownWindowUxTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownWindowUxTests.swift new file mode 100644 index 0000000000..9b794da394 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Markdown/MarkdownWindowUxTests.swift @@ -0,0 +1,346 @@ +import AppKit +@testable import GenesisKit +import SwiftUI +import XCTest + +/// The window and tab bar fixes of the UX audit of 2026-10-25 (W1 to W26, E2, D1): the message slot, saves that fail +/// inside the unsaved-changes prompt, batch closes with one Undo, tab keys and moves, dead links, dropped programs, +/// the menu bar and the tab-drag check. Model-level and menu-level only: no window is shown. +@MainActor +final class MarkdownWindowUxTests: XCTestCase { + private var root: URL! + private var model: MarkdownDocumentsModel! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("md-window-ux-\(UUID().uuidString)") + .standardizedFileURL + try FileManager.default.createDirectory(at: root.appendingPathComponent("locked"), withIntermediateDirectories: true) + for path in ["a.md", "b.md", "c.md", "d.md", "locked/note.md"] { + try "# \(path)".write(to: root.appendingPathComponent(path), atomically: true, encoding: .utf8) + } + let suite = "md-window-ux-\(UUID().uuidString)" + model = MarkdownDocumentsModel(defaults: UserDefaults(suiteName: suite)!) + addTeardownBlock { UserDefaults.standard.removePersistentDomain(forName: suite) } + } + + override func tearDownWithError() throws { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.appendingPathComponent("locked").path) + try? FileManager.default.removeItem(at: root) + } + + private func url(_ path: String) -> URL { root.appendingPathComponent(path).standardizedFileURL } + + // MARK: The message slot (W2, E2, D1) + + func testAnErrorStaysWholeAndASuccessHasALifetime() { + let error = MarkdownNotice("The note could not be saved. Check that the file can be written and the disk has room, then save again.", tone: .error) + XCTAssertNil(error.lifetime, "an error stays until it is closed") + let success = MarkdownNotice("Replaced 3 matches", tone: .success) + XCTAssertEqual(success.lifetime, 3, "a short success reads in the minimum 3 s") + let undo = MarkdownNotice("Closed 4 tabs.", tone: .info, actions: [.button("Reopen 4 Tabs") {}]) + XCTAssertEqual(undo.lifetime, 8, "a note with a button stays long enough to reach it") + XCTAssertEqual(MarkdownNotice("x", tone: .info).tone.pill, .success) + XCTAssertEqual(MarkdownNotice("x", tone: .warning).tone.pill, .warning) + } + + func testANewNoteReplacesTheOldAndAStaleDismissLeavesTheNewOne() { + model.post(MarkdownNotice("first", tone: .success)) + let first = try? XCTUnwrap(model.notice?.id) + model.post(MarkdownNotice("second", tone: .error)) + model.dismissNotice(first) + XCTAssertEqual(model.notice?.text, "second", "the first note's timer must not close the second") + XCTAssertEqual(model.exportError, "second") + model.exportError = nil + XCTAssertNil(model.notice) + } + + func testReplaceAllPostsASuccessNotAnError() throws { + model.open([url("a.md")]) + model.setMode(.source) + model.openFind() + model.findQuery = "a" + model.replaceText = "z" + model.replaceAll() + XCTAssertEqual(model.notice?.tone, .success) + XCTAssertNil(model.exportError, "a replace count is not an error") + } + + func testReplaceAllOffersUndoThatPutsTheTextBack() throws { + model.open([url("a.md")]) + model.setMode(.source) + model.openFind() + model.findQuery = "a" + model.replaceText = "z" + let doc = try XCTUnwrap(model.selected) + let before = doc.displayText + model.replaceAll() + XCTAssertNotEqual(doc.displayText, before) + let undo = try XCTUnwrap(model.notice?.actions.first) + XCTAssertEqual(undo.label, "Undo") + guard case .action(_, let perform) = undo.kind else { return XCTFail("no button") } + perform() + XCTAssertEqual(doc.displayText, before) + XCTAssertFalse(doc.isDirty) + } + + func testNextAndPreviousChangeAreSourceKeysThatClashWithNothing() { + for layout in [TableKeyLayout.us] { + let keys = Keymap(library: MarkdownKeymap.library, store: nil, layout: layout) + XCTAssertEqual(keys.chord(for: "markdown.edit.nextChange"), .code(30, [.option, .command])) + XCTAssertEqual(keys.chord(for: "markdown.edit.previousChange"), .code(33, [.option, .command])) + XCTAssertEqual(keys.conflicts(), []) + } + XCTAssertEqual(MarkdownKeymap.library.command("markdown.edit.nextChange")?.scope, MarkdownKeymap.sourceScope) + } + + func testAShareFailureKeepsItsSentenceAndPutsTheCommandsWordsBehindDetails() { + let notice = MarkdownDocumentToolbar.shareNotice("The dev dashboard could not share this note. Details say why.", + detail: "error: ECONNREFUSED\nExit code 1.") + XCTAssertEqual(notice.text, "The dev dashboard could not share this note. Details say why.") + XCTAssertEqual(notice.more, "error: ECONNREFUSED\nExit code 1.") + XCTAssertEqual(notice.tone, .error) + XCTAssertNil(notice.lifetime) + } + + func testMutedExportButtonsSayWhy() { + XCTAssertEqual(MarkdownDocumentToolbar.exportTooltip("Save as PDF (⇧⌘S)", canExport: true, what: "PDF"), "Save as PDF (⇧⌘S)") + let muted = MarkdownDocumentToolbar.exportTooltip("Save as PDF (⇧⌘S)", canExport: false, what: "PDF") + XCTAssertEqual(muted, "Save as PDF (⇧⌘S). PDF needs the reading view with the Web renderer.") + XCTAssertEqual(MarkdownDocumentActions.spokenLabel(muted), "Save as PDF", "VoiceOver still hears the action") + XCTAssertFalse(muted.contains("\u{2014}") || muted.contains("\u{2013}")) + } + + // MARK: Save in the unsaved-changes prompt (W1) + + func testAFailedSaveInTheClosePromptKeepsTheTabSaysWhyAndOffersTryAgain() throws { + model.open([url("locked/note.md"), url("a.md")]) + let note = try XCTUnwrap(model.docs.first { $0.url == url("locked/note.md") }) + note.edit("changed") + // A folder that cannot be written: the atomic save cannot create its temporary file. + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: url("locked").path) + + XCTAssertFalse(model.close(note) { _ in .save }) + + XCTAssertTrue(model.docs.contains { $0.id == note.id }, "the tab stays") + XCTAssertEqual(model.selected?.id, note.id, "the failed tab is the one on screen") + XCTAssertEqual(model.notice?.tone, .error) + XCTAssertEqual(model.notice?.actions.map(\.label), ["Try Again"]) + } + + func testASaveRefusedByADiskChangeInTheWindowPromptSaysSo() throws { + model.open([url("a.md"), url("b.md")]) + let a = try XCTUnwrap(model.docs.first { $0.url == url("a.md") }) + a.edit("mine") + try "theirs".write(to: url("a.md"), atomically: true, encoding: .utf8) + + XCTAssertFalse(model.resolveUnsavedForClose { _ in .save }) + + XCTAssertEqual(model.selected?.id, a.id) + XCTAssertEqual(model.notice?.tone, .error) + XCTAssertTrue(model.notice?.text.contains("changed on disk") == true) + } + + // MARK: Batch close and Undo (W3) + + func testClosingSeveralTabsIsOneUndo() throws { + model.open([url("a.md"), url("b.md"), url("c.md"), url("d.md")]) + let closing = model.docs.filter { $0.url != url("a.md") } + + XCTAssertTrue(model.closeTabs(closing) { _ in .discard }) + + XCTAssertEqual(model.docs.map(\.url), [url("a.md")]) + XCTAssertEqual(model.notice?.text, "Closed 3 tabs.") + XCTAssertEqual(model.notice?.actions.map(\.label), ["Reopen 3 Tabs"]) + model.reopenLastClosed() + XCTAssertEqual(Set(model.docs.map(\.url)), Set([url("a.md"), url("b.md"), url("c.md"), url("d.md")]), + "⇧⌘T brings the whole batch back") + } + + func testCloseOtherTabsIsOneUndoAndNamesTheCount() throws { + model.open([url("a.md"), url("b.md"), url("c.md")]) + let keep = try XCTUnwrap(model.docs.first { $0.url == url("b.md") }) + XCTAssertEqual(model.closeOthersTitle, "Close 2 Other Tabs") + model.closeOthers(keep) { _ in .discard } + XCTAssertEqual(model.docs.map(\.url), [url("b.md")]) + XCTAssertEqual(model.notice?.actions.map(\.label), ["Reopen 2 Tabs"]) + XCTAssertEqual(model.closeOthersTitle, "Close 0 Other Tabs") + model.reopenLastClosed() + XCTAssertEqual(model.docs.count, 3) + model.close(try XCTUnwrap(model.docs.first { $0.url == url("c.md") })) + XCTAssertEqual(model.closeOthersTitle, "Close Other Tab") + } + + func testTheFileMenuOffersCloseOtherTabs() throws { + let keys = Keymap(library: MarkdownKeymap.library, store: nil, layout: TableKeyLayout.us) + let file = try XCTUnwrap(MarkdownViewerMenu.submenu("File", in: MarkdownViewerMenu.build(appName: "Viewer", keymap: keys))) + let titles = file.items.map(\.title) + let close = try XCTUnwrap(titles.firstIndex(of: "Close Tab")) + XCTAssertEqual(titles[close + 1], "Close Other Tabs") + } + + func testADropSaysWhatHappenedInOneSentence() { + let notes = [url("a.md"), url("b.md")] + let pdf = url("paper.pdf") + XCTAssertNil(MarkdownDocumentsModel.dropSummary(opened: notes, sent: [], folder: nil, gone: 0, held: []), + "tabs alone need no note") + XCTAssertEqual(MarkdownDocumentsModel.dropSummary(opened: notes, sent: [("Preview", pdf)], folder: nil, gone: 0, held: []), + "Opened 2 notes and sent \u{201C}paper.pdf\u{201D} to Preview.") + XCTAssertEqual(MarkdownDocumentsModel.dropSummary(opened: [url("a.md")], sent: [("Preview", pdf), ("Preview", url("b.pdf"))], + folder: url("locked"), gone: 1, held: []), + "Opened 1 note, sent 2 files to Preview, showed locked in the sidebar and 1 file was not found.") + XCTAssertEqual(MarkdownDocumentsModel.dropSummary(opened: [], sent: [], folder: nil, gone: 0, held: [url("run.command")]), + "\u{201C}run.command\u{201D} is not a file this viewer shows, and opening it can start a program.") + } + + func testASingleCloseAfterABatchReopensOnlyThatTab() throws { + model.open([url("a.md"), url("b.md"), url("c.md"), url("d.md")]) + model.closeTabs(model.docs.filter { $0.url == url("b.md") || $0.url == url("c.md") }) { _ in .discard } + let d = try XCTUnwrap(model.docs.first { $0.url == url("d.md") }) + model.close(d) + + model.reopenLastClosed() + XCTAssertEqual(Set(model.docs.map(\.url)), [url("a.md"), url("d.md")]) + model.reopenLastClosed() + XCTAssertEqual(model.docs.count, 3, "the stack walks back one entry at a time once the batch is no longer last") + } + + func testTheReopenStackKeepsMoreThanTwentyTabs() { + let urls = (0..<30).map { url("many-\($0).md") } + for file in urls { try? "x".write(to: file, atomically: true, encoding: .utf8) } + model.open(urls) + model.closeTabs(model.docs) { _ in .discard } + XCTAssertEqual(model.recentlyClosed.count, 30) + model.reopenLastClosed() + XCTAssertEqual(model.docs.count, 30) + } + + // MARK: Tab keys and moves (W4, W6) + + func testCommandNumberSelectsByPlaceAndNineIsTheLastTab() { + model.open([url("a.md"), url("b.md"), url("c.md")]) + model.selectTab(number: 1) + XCTAssertEqual(model.selected?.url, url("a.md")) + model.selectTab(number: 2) + XCTAssertEqual(model.selected?.url, url("b.md")) + model.selectTab(number: 9) + XCTAssertEqual(model.selected?.url, url("c.md")) + model.selectTab(number: 5) + XCTAssertEqual(model.selected?.url, url("c.md"), "a number past the tabs does nothing") + } + + func testMoveTabLeftAndRightIsTheKeyboardTwinOfADrag() throws { + model.open([url("a.md"), url("b.md"), url("c.md")]) + let a = try XCTUnwrap(model.docs.first).id + XCTAssertFalse(model.canShiftTab(a, by: -1)) + model.shiftTab(a, by: 1) + XCTAssertEqual(model.docs.map(\.url), [url("b.md"), url("a.md"), url("c.md")]) + model.shiftTab(a, by: 1) + XCTAssertEqual(model.docs.map(\.url), [url("b.md"), url("c.md"), url("a.md")]) + XCTAssertFalse(model.canShiftTab(a, by: 1)) + model.shiftTab(a, by: -1) + XCTAssertEqual(model.docs.map(\.url), [url("b.md"), url("a.md"), url("c.md")]) + } + + func testTheNewKeysAreBoundAndConflictFree() throws { + let keys = Keymap(library: MarkdownKeymap.library, store: nil, layout: TableKeyLayout.us) + XCTAssertEqual(keys.chord(for: "markdown.file.open"), .character("o", .command)) + XCTAssertEqual(keys.chord(for: MarkdownKeymap.tabNumberCommand(1)), .character("1", .command)) + XCTAssertEqual(keys.chord(for: MarkdownKeymap.tabNumberCommand(9)), .character("9", .command)) + XCTAssertEqual(keys.conflicts(), []) + + model.open([url("a.md"), url("b.md")]) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), styleMask: [.titled], + backing: .buffered, defer: true) + let dispatcher = KeymapDispatcher() + let held = MarkdownKeymap.registerWindowCommands(model: model, window: window, keymap: keys, dispatcher: dispatcher) {} + defer { held.forEach { $0.cancel() } } + XCTAssertTrue(dispatcher.handle(KeyStroke(keyCode: 18, character: "1", modifiers: .command), in: window, typing: false)) + XCTAssertEqual(model.selected?.url, url("a.md")) + } + + // MARK: Menu bar (W4, W5) + + func testEveryWindowCommandWithAKeyIsAMenuItem() throws { + let keys = Keymap(library: MarkdownKeymap.library, store: nil, layout: TableKeyLayout.us) + let menu = MarkdownViewerMenu.build(appName: "Viewer", keymap: keys) + func items(_ menu: NSMenu) -> [NSMenuItem] { + menu.items.flatMap { [$0] + ($0.submenu.map(items) ?? []) } + } + let all = items(menu) + let file = try XCTUnwrap(MarkdownViewerMenu.submenu("File", in: menu)) + XCTAssertEqual(file.items.first?.title, "Open…") + XCTAssertEqual(file.items.first?.keyEquivalent, "o") + XCTAssertNotNil(MarkdownViewerMenu.submenu("View", in: menu)) + let commands = all.compactMap { $0.representedObject as? String } + let actions = MarkdownKeymap.windowActions(model: model, window: nil, keymap: keys) {}.map(\.0) + let named: Set<String> = ["markdown.tab.close", "markdown.tab.reopen", "markdown.tab.next", "markdown.tab.previous", + "markdown.history.back", "markdown.history.forward", "markdown.file.save", + "markdown.file.copyPath", "markdown.file.goto", "markdown.window.close", + "markdown.help.shortcuts"] + for id in actions where !named.contains(id) { + XCTAssertTrue(commands.contains(id), "\(id) has a key but no menu item") + } + for item in all where item.action == #selector(MarkdownDocumentWindowController.performCommand(_:)) { + XCTAssertTrue(actions.contains(item.representedObject as? String ?? ""), "\(item.title) runs no command") + } + } + + // MARK: Links and drops (W8, W14) + + func testALinkToNothingSaysSo() throws { + model.open([url("a.md")]) + let doc = try XCTUnwrap(model.selected) + model.follow("[[Nowhere]]", from: doc) + model.follow("Nowhere", from: doc) + XCTAssertEqual(model.notice?.tone, .warning) + XCTAssertTrue(model.notice?.text.contains("Nowhere") == true) + } + + func testADroppedScriptWaitsForOpenAnyway() throws { + let script = url("run.command") + try "#!/bin/sh\necho hi".write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + var opened: [URL] = [] + model.openExternally = { opened.append($0) } + model.defaultApp = { _ in DefaultApp(url: URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"), + name: "Terminal", bundleIdentifier: "com.apple.Terminal", schemes: []) } + + model.acceptDrop([script]) + + XCTAssertEqual(opened, [], "a stray drop does not start a program") + let notice = try XCTUnwrap(model.notice) + XCTAssertEqual(notice.tone, .warning) + guard case .action(_, let perform) = try XCTUnwrap(notice.actions.first).kind else { return XCTFail("no button") } + perform() + XCTAssertEqual(opened, [script]) + } + + // MARK: Tab drags (W13) + + func testPlainTextFromElsewhereIsNoTabDrag() { + let board = NSPasteboard(name: NSPasteboard.Name("md-window-ux-\(UUID().uuidString)")) + defer { board.releaseGlobally() } + MarkdownTabDrag.active = .tab("left over from a cancelled drag") + board.clearContents() + board.setString("some words from a page", forType: .string) + XCTAssertNil(MarkdownTabDrag.inFlight(pasteboard: board)) + board.clearContents() + board.setString(MarkdownTabDrag.tab("x"), forType: .string) + XCTAssertEqual(MarkdownTabDrag.inFlight(pasteboard: board), .tab("x")) + MarkdownTabDrag.active = nil + } + + // MARK: Settings (W7) + + func testTheSettingsCardsBindTheTabAndPreviewKeys() { + let configuration = GenesisKitMarkdownConfiguration(defaults: UserDefaults(suiteName: "md-window-ux-settings-\(UUID().uuidString)")!) + XCTAssertTrue(configuration.previewTabs, "defaults stay as they were") + XCTAssertTrue(configuration.hoverPreview) + XCTAssertEqual(configuration.hoverPreviewDelay, 1.0) + let hosting = NSHostingView(rootView: MarkdownSettingsView(configuration: configuration)) + hosting.frame = NSRect(x: 0, y: 0, width: 560, height: 1400) + hosting.layoutSubtreeIfNeeded() + XCTAssertGreaterThan(hosting.fittingSize.height, 0) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift index 2f8ea19034..27d7758c1c 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift @@ -715,7 +715,7 @@ final class WidgetCaptureFeedbackTests: XCTestCase { }, onCaptureStaged: { reopened += 1 }) await complete(store) { store.capture() } XCTAssertEqual(reopened, 1) - XCTAssertEqual(store.notice, "Screenshot staged. Choose an inbox when you are ready.") + XCTAssertEqual(store.notice, "Screenshot added. Choose an inbox when you are ready.") response = Data(#"{"cancelled":true,"reason":"user"}"#.utf8) await complete(store) { store.capture() } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/NavigationHistoryTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/NavigationHistoryTests.swift new file mode 100644 index 0000000000..07bf7ea8ef --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/NavigationHistoryTests.swift @@ -0,0 +1,298 @@ +import AppKit +import SwiftUI +import XCTest + +@testable import GenesisKitBase +@testable import GenesisKit + +/// The navigation engine (`NavigationHistory`): visits never replace, Back and Forward walk, a hop reaches any entry, +/// provisional places leave no stop, the cap drops the oldest, and an agent's visit keeps its note. +final class NavigationHistoryTests: XCTestCase { + private typealias History = NavigationHistory<String> + + private func titles(_ history: History, _ ids: [String]) -> [String] { + ids.compactMap { history.entry($0)?.title } + } + + private func walk(_ places: [String], limit: Int = 200) -> History { + var history = History(limit: limit) + for place in places { + history.visit(place, title: place) + } + return history + } + + func testVisitsStackAndBackAndForwardWalkThem() { + var history = walk(["a", "b", "c"]) + XCTAssertEqual(history.current?.target, "c") + XCTAssertEqual(history.goBack()?.target, "b") + XCTAssertEqual(history.goBack()?.target, "a") + XCTAssertNil(history.goBack(), "nothing before the first place") + XCTAssertEqual(history.goForward()?.target, "b") + XCTAssertEqual(history.goForward()?.target, "c") + XCTAssertNil(history.goForward()) + XCTAssertEqual(history.entries.count, 3, "walking never adds entries") + } + + func testAVisitAfterGoingBackClearsForwardButTheLogKeepsEveryPlace() { + var history = walk(["a", "b", "c"]) + _ = history.goBack() + _ = history.goBack() + history.visit("d", title: "d") + XCTAssertFalse(history.canGoForward) + XCTAssertEqual(titles(history, history.backIDs), ["a"]) + XCTAssertEqual(history.newestFirst.map(\.target), ["d", "c", "b", "a"], "b and c are still in the list") + // A hop reaches a place that left the forward stack, and Back returns from it. + let c = try! XCTUnwrap(history.entries.first { $0.target == "c" }) + XCTAssertNil(history.offset(of: c.id), "only the log holds it") + XCTAssertEqual(history.hop(to: c.id)?.target, "c") + XCTAssertEqual(history.goBack()?.target, "d") + } + + func testTheSamePlaceTwiceIsOneStop() { + var history = walk(["a"]) + XCTAssertNil(history.visit("a", title: "a")) + XCTAssertFalse(history.canGoBack) + XCTAssertEqual(history.entries.count, 1) + } + + func testHopBackwardsKeepsThePassedPlacesAheadInOrder() throws { + var history = walk(["a", "b", "c", "d"]) + let a = try XCTUnwrap(history.entries.first) + XCTAssertEqual(history.offset(of: a.id), -3) + XCTAssertEqual(history.hop(to: a.id)?.target, "a") + XCTAssertFalse(history.canGoBack) + XCTAssertEqual(history.goForward()?.target, "b") + XCTAssertEqual(history.goForward()?.target, "c") + XCTAssertEqual(history.goForward()?.target, "d") + } + + func testHopForwardsKeepsThePassedPlacesBehindInOrder() throws { + var history = walk(["a", "b", "c", "d"]) + let d = try XCTUnwrap(history.entries.last) + _ = history.hop(to: try XCTUnwrap(history.entries.first).id) + XCTAssertEqual(history.offset(of: d.id), 3) + XCTAssertEqual(history.hop(to: d.id)?.target, "d") + XCTAssertFalse(history.canGoForward) + XCTAssertEqual(history.goBack()?.target, "c") + XCTAssertEqual(history.goBack()?.target, "b") + XCTAssertEqual(history.goBack()?.target, "a") + XCTAssertNil(history.hop(to: "no-such-id")) + } + + func testAProvisionalPlaceIsNeverAStop() { + var history = History() + history.visit("sessions:-", title: "Sessions", provisional: true) + history.visit("prs:-", title: "PRs", provisional: true) + XCTAssertFalse(history.canGoBack, "the launch's empty mode is no place to go back to") + XCTAssertTrue(history.entries.isEmpty) + + history.visit("pr1", title: "pr1") + history.visit("worktrees:-", title: "Worktrees", provisional: true) + history.visit("a", title: "a") + XCTAssertEqual(titles(history, history.backIDs), ["pr1"], "a mode left while its list loaded is not a stop") + + // Out to an empty mode and straight back: the place before it, not a copy of it. + history.visit("worktrees:-", title: "Worktrees", provisional: true) + history.visit("a", title: "a") + XCTAssertEqual(titles(history, history.backIDs), ["pr1"]) + XCTAssertEqual(history.current?.target, "a") + XCTAssertEqual(history.entries.count, 2) + } + + func testAnAgentVisitCarriesItsWhoAndNoteAndKeepsTheCLIsID() { + var history = walk(["a"]) + let made = history.visit("pr563", title: "PR #563", actor: .agent, agentSession: "abc", note: "the failing CI run", + id: "cli-id") + XCTAssertEqual(made?.id, "cli-id") + XCTAssertEqual(history.current?.actor, .agent) + XCTAssertEqual(history.current?.note, "the failing CI run") + XCTAssertEqual(history.backEntry?.target, "a") + } + + func testAnAgentOpeningWhatIsShownAnnotatesItInsteadOfAddingACopy() { + var history = walk(["a", "b"]) + let annotated = history.visit("b", title: "", actor: .agent, note: "look here", id: "cli-id") + XCTAssertEqual(annotated?.id, "cli-id") + XCTAssertEqual(annotated?.title, "b", "an empty title keeps the one it had") + XCTAssertEqual(history.entries.count, 2) + XCTAssertEqual(history.current?.note, "look here") + XCTAssertEqual(history.backEntry?.target, "a") + // A user's repeat of the same place is nothing at all. + XCTAssertNil(history.visit("b", title: "b")) + } + + func testRecordAddsToTheListWithoutMovingTheCursor() throws { + var history = walk(["a", "b"]) + let added = history.record("pr9", title: "PR #9", actor: .agent, note: "later") + XCTAssertEqual(history.current?.target, "b") + XCTAssertNil(history.offset(of: added.id)) + XCTAssertEqual(history.newestFirst.first?.target, "pr9") + XCTAssertEqual(history.hop(to: added.id)?.target, "pr9") + XCTAssertEqual(history.goBack()?.target, "b") + } + + func testTheCapDropsTheOldestButNeverThePlaceOnScreen() { + var history = walk((0..<30).map { "p\($0)" }, limit: 10) + XCTAssertEqual(history.entries.count, 10) + XCTAssertEqual(history.entries.first?.target, "p20") + XCTAssertEqual(history.backIDs.count, 9) + for _ in 0..<9 { _ = history.goBack() } + history.setLimit(3) + XCTAssertEqual(history.current?.target, "p20", "the place on screen survives a smaller cap") + XCTAssertTrue(history.entries.contains { $0.target == "p20" }) + XCTAssertLessThanOrEqual(history.entries.count, 3) + } + + func testALogReadBackPutsEveryEntryBehindTheNewest() { + let now = Date() + let entries = [ + NavigationEntry(id: "2", at: now, target: "b", title: "b"), + NavigationEntry(id: "1", at: now.addingTimeInterval(-60), target: "a", title: "a", actor: .agent, note: "why"), + ] + var history = History(entries: entries) + XCTAssertEqual(history.current?.target, "b") + XCTAssertEqual(history.goBack()?.note, "why") + // The launch's own place after a read-back pushes the newest entry behind it. + var launched = History(entries: entries) + launched.visit("c", title: "c") + XCTAssertEqual(launched.backEntry?.target, "b") + } + + func testRetargetMakesTheShownPlaceEqualToItsEntry() throws { + var history = walk(["a"]) + history.visit("pr=563", title: "PR #563", actor: .agent, note: "why") + let id = try XCTUnwrap(history.currentID) + XCTAssertEqual(history.retarget(id, to: "prs:github.com/acme/app/pull/563", title: "#563 Fix it")?.title, "#563 Fix it") + XCTAssertNil(history.visit("prs:github.com/acme/app/pull/563", title: "#563 Fix it"), "the shown place is the entry") + XCTAssertEqual(history.current?.note, "why") + XCTAssertNil(history.retarget(id, to: "prs:github.com/acme/app/pull/563")) + } + + func testAnOlderLogGoesBehindWhatTheWindowDidSinceItOpened() { + let now = Date() + var history = History() + history.visit("launch", title: "launch", at: now) + history.adoptOlder([ + NavigationEntry(id: "o2", at: now.addingTimeInterval(-10), target: "older2", title: "older2"), + NavigationEntry(id: "o1", at: now.addingTimeInterval(-20), target: "older1", title: "older1"), + ]) + XCTAssertEqual(history.current?.target, "launch") + XCTAssertEqual(history.goBack()?.target, "older2") + XCTAssertEqual(history.goBack()?.target, "older1") + // A relaunch on the place the log ended with: one entry, the log's, with its who and note. + var relaunched = History() + relaunched.visit("pr1", title: "pr1", id: "window-id", at: now) + relaunched.visit("pr2", title: "pr2", at: now.addingTimeInterval(1)) + let merged = relaunched.adoptOlder([ + NavigationEntry(id: "log-a", at: now.addingTimeInterval(-30), target: "a", title: "a"), + NavigationEntry(id: "log-pr1", at: now.addingTimeInterval(-20), target: "pr1", title: "pr1", actor: .agent, note: "n"), + ]) + XCTAssertEqual(merged, ["window-id"]) + XCTAssertEqual(relaunched.entries.map(\.id).prefix(2), ["log-a", "log-pr1"]) + XCTAssertEqual(relaunched.entries.count, 3) + XCTAssertEqual(relaunched.goBack()?.note, "n") + XCTAssertEqual(relaunched.goBack()?.target, "a") + // Nothing on screen yet: the newest adopted entry is the current place. + var empty = History() + empty.adoptOlder([NavigationEntry(id: "x", at: now, target: "x", title: "x")]) + XCTAssertEqual(empty.current?.target, "x") + XCTAssertFalse(empty.canGoBack) + } + + func testRetitleChangesOnlyTheNamedEntry() throws { + var history = walk(["a", "b"]) + let a = try XCTUnwrap(history.entries.first) + XCTAssertEqual(history.retitle(a.id, "PR #1 Fix it")?.title, "PR #1 Fix it") + XCTAssertNil(history.retitle(a.id, "PR #1 Fix it"), "the same title again writes nothing") + XCTAssertEqual(history.entries.last?.title, "b") + } + + func testEntriesRoundTripThroughJSON() throws { + let entry = NavigationEntry(id: "x", at: Date(timeIntervalSince1970: 1_700_000_000), target: ["pr": "563"], + title: "PR #563", actor: .link, agentSession: nil, note: nil) + let data = try JSONEncoder().encode(entry) + XCTAssertEqual(try JSONDecoder().decode(NavigationEntry<[String: String]>.self, from: data), entry) + } + + /// The store protocol the host implements: a memory store reads back what it was given, newest kept. + func testAStoreKeepsTheNewestEntriesAndReplacesByID() throws { + final class MemoryStore: NavigationHistoryStore { + var rows: [NavigationEntry<String>] = [] + func load() throws -> [NavigationEntry<String>] { rows } + func record(_ entry: NavigationEntry<String>, keep: Int) throws { + rows.removeAll { $0.id == entry.id } + rows.append(entry) + rows = Array(rows.sorted { $0.at < $1.at }.suffix(keep)) + } + } + let store = MemoryStore() + var history = History(limit: 2) + let start = Date() + for (index, place) in ["a", "b", "c"].enumerated() { + if let made = history.visit(place, title: place, at: start.addingTimeInterval(Double(index))) { + try store.record(made, keep: history.limit) + } + } + if let annotated = history.visit("c", title: "c", actor: .agent, note: "n", at: start.addingTimeInterval(9)) { + try store.record(annotated, keep: history.limit) + } + let reread = History(entries: try store.load(), limit: 2) + XCTAssertEqual(reread.entries.map(\.target), ["b", "c"]) + XCTAssertEqual(reread.current?.note, "n") + } +} + +/// The history list as the hub's popover draws it, in the dark settings look. +@MainActor +final class NavigationHistoryListShotTests: XCTestCase { + func testTheListDrawsEveryActorTheNoteAndTheCurrentPlace() throws { + let rep = try KitShots.render(NavigationHistoryGallery(), size: CGSize(width: 380, height: 430), name: "navigation-history", + settle: 0.6) + // The agent badge's violet and the link badge's blue must both be on the picture. + var violet = 0 + var blue = 0 + for y in stride(from: 0, to: rep.pixelsHigh, by: 2) { + for x in stride(from: 0, to: rep.pixelsWide / 2, by: 2) { + guard let c = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB), c.saturationComponent > 0.3, + c.brightnessComponent > 0.5 else { continue } + let hue = c.hueComponent + if (0.70...0.80).contains(hue) { violet += 1 } + if (0.58...0.66).contains(hue) { blue += 1 } + } + } + XCTAssertGreaterThan(violet, 8, "the Agent badge") + XCTAssertGreaterThan(blue, 8, "the Link badge") + XCTAssertGreaterThan(KitShots.brightness(rep, in: CGRect(x: 40, y: 30, width: 300, height: 360), viewWidth: 380), 0.08, + "row titles must be drawn") + } +} + +/// Five places: the person's, two an agent opened with notes, one from a link, and the place on screen. +struct NavigationHistoryGallery: View { + static var sample: NavigationHistory<String> { + let now = Date() + var history = NavigationHistory<String>() + history.visit("s1", title: "session Fix the login redirect", at: now.addingTimeInterval(-3600)) + history.visit("pr563", title: "#563 hub: navigation history", actor: .agent, agentSession: "2f320c19-ff19", + note: "The PR with the failing Swift suite; the second commit adds the store.", + at: now.addingTimeInterval(-1500)) + history.visit("c1", title: "commit 0751d8a971 in GenesisTools", actor: .agent, agentSession: "2f320c19-ff19", + note: "The commit that moved the cache chip focus.", at: now.addingTimeInterval(-600)) + history.visit("wt", title: "worktree GenesisTools feat/2026-10-25-hub-navigation-history", actor: .link, + at: now.addingTimeInterval(-240)) + history.visit("s2", title: "session Review the hub history popover", at: now.addingTimeInterval(-30)) + _ = history.goBack() + return history + } + + var body: some View { + NavigationHistoryList(history: Self.sample) { _ in } + .frame(width: 380, alignment: .top) + .frame(maxHeight: .infinity, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, .graphite) + .environment(\.colorScheme, .dark) + .preferredColorScheme(.dark) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationEngineTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationEngineTests.swift new file mode 100644 index 0000000000..d6536dd56f --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationEngineTests.swift @@ -0,0 +1,128 @@ +import UserNotifications +import XCTest +@testable import GenesisKit +@testable import GenesisKitBase + +private final class FakeCenter: NotificationCenterPort { + var added: [UNNotificationRequest] = [] + var removed: [String] = [] + var categorySet: Set<UNNotificationCategory> = [] + var deliveredNotes: [DeliveredNotification] = [] + var failNextAdd = false + + func add(_ request: UNNotificationRequest, completion: @escaping (Error?) -> Void) { + if failNextAdd { + failNextAdd = false + completion(NSError(domain: "fake", code: 1, userInfo: [NSLocalizedDescriptionKey: "denied"])) + return + } + added.append(request) + deliveredNotes.append(DeliveredNotification(identifier: request.identifier, + category: request.content.categoryIdentifier, + threadId: request.content.threadIdentifier, + userInfo: request.content.userInfo)) + completion(nil) + } + + func delivered(_ completion: @escaping ([DeliveredNotification]) -> Void) { completion(deliveredNotes) } + + func removeDelivered(identifiers: [String]) { + removed += identifiers + deliveredNotes.removeAll { identifiers.contains($0.identifier) } + } + + func categories(_ completion: @escaping (Set<UNNotificationCategory>) -> Void) { completion(categorySet) } + func setCategories(_ categories: Set<UNNotificationCategory>) { categorySet = categories } +} + +final class NotificationEngineTests: XCTestCase { + private var center: FakeCenter! + private var engine: NotificationEngine! + private var ledger: String! + + override func setUpWithError() throws { + center = FakeCenter() + ledger = FileManager.default.temporaryDirectory.appendingPathComponent("engine-ledger-\(UUID()).jsonl").path + let fake = center! + engine = NotificationEngine(centerProvider: { fake }, ledgerFile: ledger) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(atPath: ledger) + } + + private func ledgerLines() throws -> [[String: Any]] { + NotificationLedger.flush() + guard FileManager.default.fileExists(atPath: ledger) else { return [] } + return try String(contentsOfFile: ledger, encoding: .utf8).split(separator: "\n") + .map { try XCTUnwrap(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } + } + + func testAReplaceKeyLeavesOneCardPerKey() throws { + let first = engine.post(EngineNotification(source: "monitor.cache", title: "10 min left", body: "s1", + replaceKey: "s1")) + engine.post(EngineNotification(source: "monitor.cache", title: "other session", body: "s2", replaceKey: "s2")) + let third = engine.post(EngineNotification(source: "monitor.cache", title: "5 min left", body: "s1", + replaceKey: "s1")) + guard case .posted(let firstId) = first, case .posted(let thirdId) = third else { return XCTFail("posted") } + XCTAssertEqual(center.removed, [firstId], "only the older card of the same key goes") + XCTAssertEqual(Set(center.deliveredNotes.map(\.identifier)).contains(thirdId), true) + XCTAssertEqual(center.deliveredNotes.count, 2) + XCTAssertEqual(center.added.last?.content.threadIdentifier, "s1", "the key threads the cards") + } + + func testAQuietRuleHoldsTheCardBackAndTheLedgerSaysWhy() throws { + engine.addQuietRule("focus") { $0.source == "server.notify" ? "focus session active" : nil } + XCTAssertEqual(engine.post(EngineNotification(source: "server.notify", title: "t", body: "b")), + .suppressed(reason: "focus: focus session active")) + if case .posted = engine.post(EngineNotification(source: "disk", title: "t", body: "b")) {} else { + XCTFail("another source still posts") + } + XCTAssertEqual(center.added.count, 1) + let lines = try ledgerLines() + XCTAssertEqual(lines.map { $0["outcome"] as? String }, ["suppressed", "posted"]) + XCTAssertEqual(lines[0]["reason"] as? String, "focus: focus session active") + } + + func testCategoriesAreAddedToTheRegisteredSetNeverReplacingIt() { + center.categorySet = [UNNotificationCategory(identifier: "someone.else", actions: [], intentIdentifiers: [])] + engine.register(categories: [UNNotificationCategory(identifier: "genesis.disk", actions: [], intentIdentifiers: [])]) + engine.post(EngineNotification(source: "tools", title: "t", body: "b", identifier: "card-1", + actions: [UNNotificationAction(identifier: "ok", title: "OK")])) + XCTAssertEqual(Set(center.categorySet.map(\.identifier)), ["someone.else", "genesis.disk", "card-1"]) + XCTAssertEqual(center.added.first?.content.categoryIdentifier, "card-1", "ad-hoc buttons use the card's id") + } + + func testAFailedAddIsRecordedAsFailed() throws { + center.failNextAdd = true + let failed = expectation(description: "completion") + engine.post(EngineNotification(source: "hang", title: "t", body: "b", ledgerFields: ["stallSeconds": 21])) { + XCTAssertNotNil($0) + failed.fulfill() + } + wait(for: [failed], timeout: 1) + let lines = try ledgerLines() + XCTAssertEqual(lines.map { $0["outcome"] as? String }, ["posted", "failed"]) + XCTAssertEqual(lines[1]["reason"] as? String, "denied") + XCTAssertEqual(lines[0]["stallSeconds"] as? Int, 21) + } + + func testWithdrawByKeyAndCountByCategory() { + engine.post(EngineNotification(source: "disk", title: "full", body: "b", replaceKey: "disk.full", + category: "genesis.disk")) + engine.post(EngineNotification(source: "monitor.cache", title: "t", body: "b", category: "genesis.monitor.session")) + var counted: [String] = [] + engine.delivered(where: { $0.category == "genesis.monitor.session" }) { counted = $0 } + XCTAssertEqual(counted.count, 1) + var withdrawn: [String] = [] + engine.withdraw(replaceKey: "disk.full") { withdrawn = $0 } + XCTAssertEqual(withdrawn.count, 1) + XCTAssertEqual(center.deliveredNotes.map(\.category), ["genesis.monitor.session"]) + } + + func testNoCenterMeansNothingPostedAndNothingRecorded() throws { + let quiet = NotificationEngine(centerProvider: { nil }, ledgerFile: ledger) + XCTAssertEqual(quiet.post(EngineNotification(source: "x", title: "t", body: "b")), .unavailable) + XCTAssertTrue(try ledgerLines().isEmpty) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationLedgerTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationLedgerTests.swift new file mode 100644 index 0000000000..ef47c606ab --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/NotificationLedgerTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import GenesisKit + +final class NotificationLedgerTests: XCTestCase { + private var directory: URL! + private var file: String { directory.appendingPathComponent("ledger.jsonl").path } + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent("ledger-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + } + + private func lines() throws -> [[String: Any]] { + NotificationLedger.flush() + return try String(contentsOfFile: file, encoding: .utf8) + .split(separator: "\n") + .map { try XCTUnwrap(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) } + } + + func testEachRecordIsOneLineWithItsFieldsAndWriter() throws { + NotificationLedger.record(source: "monitor.cache", channel: "macos", title: "Cache cooling — 10 min left", + body: "0f61ecfa · col\n~/x", fields: ["sessionId": "0f61ecfa", "ctx": 169_282], file: file) + NotificationLedger.record(source: "monitor.cache", channel: "macos", outcome: .suppressed, + reason: "process exited", fields: ["bad": Date()], file: file) + let written = try lines() + XCTAssertEqual(written.count, 2) + XCTAssertEqual(written[0]["source"] as? String, "monitor.cache") + XCTAssertEqual(written[0]["outcome"] as? String, "posted") + XCTAssertEqual(written[0]["ctx"] as? Int, 169_282) + XCTAssertNotNil(written[0]["at"]) + XCTAssertNotNil((written[0]["writer"] as? [String: Any])?["pid"]) + XCTAssertEqual(written[1]["reason"] as? String, "process exited") + XCTAssertNil(written[1]["bad"], "a non-JSON value is dropped, the line is still written") + } + + func testAFullFileMovesAsideBeforeTheNextLine() throws { + try Data(count: Int(NotificationLedger.maxBytes) + 1).write(to: URL(fileURLWithPath: file)) + NotificationLedger.record(source: "disk", channel: "macos", file: file) + XCTAssertEqual(try lines().count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: file + ".1")) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift index 71997ae101..ebb75c7f6f 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift @@ -1092,3 +1092,65 @@ final class PermissionTests: XCTestCase { XCTAssertEqual(answer, .denied) } } + +/// The drag helper over System Settings: where it sits, which lists take a dragged app, and its preference. +@MainActor +final class PermissionSettingsGuideTests: XCTestCase { + override func tearDown() { + UserDefaults.standard.removeObject(forKey: PermissionSettingsGuide.enabledKey) + super.tearDown() + } + + func testTheGuideSitsAtTheBottomOfThePrivacyPaneNotTheSidebar() { + let origin = PermissionSettingsGuide.origin(size: CGSize(width: 180, height: 170), + settingsWindow: CGRect(x: 100, y: 50, width: 1000, height: 800)) + XCTAssertEqual(origin, CGPoint(x: 640, y: 78), "centred over the list right of a 260 pt sidebar, 28 pt up") + } + + func testOnlyTheListsWithAPlusButtonTakeADraggedApp() { + XCTAssertEqual(Set(PermissionKind.allCases.filter(\.addsByDrag)), + [.accessibility, .inputMonitoring, .screenRecording, .fullDiskAccess]) + } + + func testTheGuideFollowsItsPreferenceAndClosesForItsOwnKindOnly() { + let guide = PermissionSettingsGuide() + UserDefaults.standard.set(false, forKey: PermissionSettingsGuide.enabledKey) + guide.show(for: .accessibility) + XCTAssertNil(guide.kind, "off: only the pane opens") + + UserDefaults.standard.removeObject(forKey: PermissionSettingsGuide.enabledKey) + guide.show(for: .accessibility) + XCTAssertEqual(guide.kind, .accessibility) + guide.dismiss(.screenRecording) + XCTAssertEqual(guide.kind, .accessibility, "another kind's grant leaves it up") + guide.dismiss(.accessibility) + XCTAssertNil(guide.kind) + } + + func testTheHelperDrawsTheIconAndTheLine() throws { + let view = PermissionGuideView(kind: .screenRecording, appName: "GenesisTools", + appURL: URL(fileURLWithPath: "/System/Applications/Calculator.app"), + onClose: {}) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.cgImage) + XCTAssertGreaterThan(image.width, 200) + XCTAssertGreaterThan(image.height, 250) + if let directory = ProcessInfo.processInfo.environment["PERMISSION_SNAPSHOT_DIR"] { + let rep = NSBitmapImageRep(cgImage: image) + try XCTUnwrap(rep.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: directory).appendingPathComponent("permission-settings-guide.png")) + } + } +} + +/// An app activation rechecks the answered probes at most every 10 s, in the permission center and the settings window. +final class ActivationRecheckLimitTests: XCTestCase { + func testTheRecheckIsDueFirstAndThenAfter10Seconds() { + let last = Date(timeIntervalSince1970: 1_000) + XCTAssertEqual(PermissionAccess.activationRecheckInterval, 10) + XCTAssertTrue(PermissionAccess.activationRecheckDue(last: nil, now: last)) + XCTAssertFalse(PermissionAccess.activationRecheckDue(last: last, now: last.addingTimeInterval(9.9))) + XCTAssertTrue(PermissionAccess.activationRecheckDue(last: last, now: last.addingTimeInterval(10))) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift new file mode 100644 index 0000000000..9458da352b --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift @@ -0,0 +1,590 @@ +import AppKit +@testable import GenesisKit +import SwiftUI +import XCTest + +/// Fixture targets: invented apps and windows, no real machine state. +private enum RecordingFixture { + static let targets = RecordingTargets( + windows: [ + RecordingWindow(id: 501, pid: 4101, app: "Editor", title: "notes.txt", + bounds: .init(x: 200, y: 160, width: 900, height: 600)), + RecordingWindow(id: 502, pid: 4101, app: "Editor", title: "", + bounds: .init(x: 260, y: 220, width: 700, height: 500)), + RecordingWindow(id: 610, pid: 4202, app: "Viewer", title: "Quarterly chart", + bounds: .init(x: 1300, y: 300, width: 640, height: 480)), + ], + apps: [RecordingApp(pid: 4101, name: "Editor", windowCount: 2), + RecordingApp(pid: 4202, name: "Viewer", windowCount: 1)], + displays: [RecordingDisplay(index: 0, name: "Built-in Display", isPrimary: true, width: 1728, height: 1117), + RecordingDisplay(index: 1, name: "Studio Display", isPrimary: false, width: 2560, height: 1440)], + frontmostPid: 4202) +} + +@MainActor +private final class FakeChannel: RecordingCommandChannel { + var sent: [String] = [] + var stopped = false + func send(_ line: String) throws { sent.append(line.trimmingCharacters(in: .newlines)) } + func finishInput() {} + func stop() { stopped = true } +} + +@MainActor +final class RecordingSessionControllerTests: XCTestCase { + private func controller() -> (RecordingSessionController, FakeChannel, () -> [String], + (Int32, String) -> Void, ([String]) -> Void) { + let channel = FakeChannel() + var arguments: [String] = [] + var exit: ((Int32, String) -> Void)? + var lines: (([String]) -> Void)? + let controller = RecordingSessionController { args, onLines, onExit in + arguments = args + lines = onLines + exit = onExit + return channel + } + return (controller, channel, { arguments }, { exit?($0, $1) }, { lines?($0) }) + } + + func testStartPassesTheTargetTheControlsToLeaveOutAndTheSettings() throws { + let (controller, _, arguments, _, _) = controller() + try controller.start(target: .display(index: 1, name: "Studio Display"), excludeWindowIDs: [9001], + frameRate: 24, showsBorder: false) + XCTAssertEqual(controller.phase, .starting) + XCTAssertEqual(arguments(), [ + "capture", "session", "--json", "--stop-on-stdin", + "--target", #"{"kind":"display","name":"Studio Display","screenIndex":1}"#, + "--fps", "24", "--exclude-window-ids", "9001", "--no-indicator", + ]) + } + + func testCommandsGoOutAsJSONLinesAndEventsDriveThePhase() throws { + let (controller, channel, _, _, receive) = controller() + try controller.start(target: RecordingFixture.targets.windows[0].target, excludeWindowIDs: [], frameRate: 15, + showsBorder: true) + receive([#"{"event":"state","state":"recording","reason":"user","target":{"kind":"window","windowIds":[501],"app":"Editor","title":"notes.txt"},"elapsedMs":0,"segments":0,"limitMs":600000}"#]) + XCTAssertEqual(controller.phase, .recording) + receive([#"{"event":"elapsed","elapsedMs":4200,"limitMs":600000}"#]) + XCTAssertEqual(controller.elapsedMs, 4200) + controller.pause() + controller.switchTo(RecordingFixture.targets.apps[1].target) + controller.restart() + controller.resume() + XCTAssertEqual(channel.sent, [ + #"{"cmd":"pause"}"#, + #"{"cmd":"switch","target":{"app":"4202","kind":"app","name":"Viewer"}}"#, + #"{"cmd":"restart"}"#, + #"{"cmd":"resume"}"#, + ]) + receive([#"{"event":"state","state":"paused","reason":"limit","target":{"kind":"app","app":"4202","name":"Viewer"},"elapsedMs":600000,"segments":3,"limitMs":600000}"#, + #"{"event":"error","message":"The recording reached its 10 min limit.","code":"limit","fatal":false}"#]) + XCTAssertEqual(controller.phase, .paused) + XCTAssertEqual(controller.pauseReason, "limit") + XCTAssertEqual(controller.target, .app(app: "4202", name: "Viewer")) + XCTAssertEqual(controller.notice, "The recording reached its 10 min limit.") + } + + /// The channel takes 512 bytes a line: a window with a long title still switches, by its ids, without the title. + func testASwitchToAWindowWithALongTitleFitsTheChannel() throws { + let (controller, channel, _, _, _) = controller() + try controller.start(target: .display(index: 0, name: nil), excludeWindowIDs: [], frameRate: 15, showsBorder: true) + let title = String(repeating: "Ž", count: 400) + controller.switchTo(.window(ids: [501], app: "Editor", title: title, pid: 42)) + controller.switchTo(.window(ids: [502], app: "Editor", title: "notes.txt", pid: 42)) + XCTAssertEqual(channel.sent, [ + #"{"cmd":"switch","target":{"app":"Editor","kind":"window","pid":42,"windowIds":[501]}}"#, + #"{"cmd":"switch","target":{"app":"Editor","kind":"window","pid":42,"title":"notes.txt","windowIds":[502]}}"#, + ]) + XCTAssertNil(controller.notice) + } + + func testPreparingSaysWhyTheStartIsSlowUntilTheFirstFrame() throws { + let (controller, channel, _, _, receive) = controller() + try controller.start(target: .display(index: 0, name: nil), excludeWindowIDs: [], frameRate: 15, showsBorder: true) + receive([#"{"event":"state","state":"starting","reason":"user","target":{"kind":"display","screenIndex":0},"elapsedMs":0,"segments":0,"limitMs":600000}"#, + #"{"event":"preparing","message":"Building the recorder after an update."}"#]) + XCTAssertEqual(controller.phase, .starting) + XCTAssertEqual(controller.preparing, "Building the recorder after an update.") + controller.discard() + XCTAssertEqual(channel.sent, [#"{"cmd":"discard"}"#], "discard goes out while the recorder prepares") + receive([#"{"event":"state","state":"recording","reason":"user","target":{"kind":"display","screenIndex":0},"elapsedMs":0,"segments":0,"limitMs":600000}"#]) + XCTAssertNil(controller.preparing) + } + + func testDoneHandsTheMovieOverOnceAndLaterCommandsAreDropped() throws { + let (controller, channel, _, exit, receive) = controller() + var done: [URL] = [] + var failures = 0 + controller.onDone = { url, _ in done.append(url) } + controller.onFailed = { _, _ in failures += 1 } + try controller.start(target: .app(app: "4101", name: "Editor"), excludeWindowIDs: [], frameRate: 15, showsBorder: true) + controller.stop() + receive([#"{"event":"state","state":"finishing","reason":"user","target":{"kind":"app","app":"4101"},"elapsedMs":3000,"segments":1,"limitMs":600000}"#, + #"{"event":"done","path":"/tmp/recording-fixture.mp4","durationMs":3000,"segments":1}"#]) + exit(0, "") + controller.pause() + XCTAssertEqual(done, [URL(fileURLWithPath: "/tmp/recording-fixture.mp4")]) + XCTAssertEqual(failures, 0, "a clean exit after done is not a failure") + XCTAssertEqual(channel.sent, [#"{"cmd":"stop"}"#], "nothing is sent after the session ended") + } + + func testAFatalRefusalAndAnUnexpectedExitBothFail() throws { + let (refused, _, _, _, receive) = controller() + var codes: [String?] = [] + refused.onFailed = { _, code in codes.append(code) } + try refused.start(target: .display(index: 0, name: nil), excludeWindowIDs: [], frameRate: 15, showsBorder: true) + receive([#"{"event":"error","message":"[screen-recording-denied] Screen Recording is not granted","code":"screen-recording-denied","fatal":true}"#]) + XCTAssertEqual(refused.phase, .failed) + XCTAssertEqual(codes, ["screen-recording-denied"]) + + let (crashed, _, _, exit, _) = controller() + var messages: [String] = [] + crashed.onFailed = { message, _ in messages.append(message) } + try crashed.start(target: .display(index: 0, name: nil), excludeWindowIDs: [], frameRate: 15, showsBorder: true) + exit(9, "bun crashed\n") + // One sentence on screen; the stderr tail goes to the log (C6). + XCTAssertEqual(messages, ["The recorder stopped unexpectedly. The details are in the log."]) + } +} + +final class RecordingModelTests: XCTestCase { + func testTargetsDecodeTheCLIShapeAndTheDefaultIsTheFrontAppsWindow() throws { + let json = #""" + {"windows":[{"id":501,"pid":4101,"app":"Editor","title":"notes.txt","bounds":{"x":1,"y":2,"width":300,"height":200}}], + "apps":[{"pid":4101,"name":"Editor","windowCount":1}], + "displays":[{"index":0,"name":"Built-in Display","isPrimary":true,"width":1728,"height":1117,"scaleFactor":2}], + "frontmostPid":4101} + """# + let targets = try JSONDecoder().decode(RecordingTargets.self, from: Data(json.utf8)) + XCTAssertEqual(targets.windows.first?.title, "notes.txt") + XCTAssertEqual(targets.defaultTarget(), .window(ids: [501], app: "Editor", title: "notes.txt", pid: 4101)) + XCTAssertEqual(RecordingFixture.targets.defaultTarget(preferredPid: 4101), + .window(ids: [501], app: "Editor", title: "notes.txt", pid: 4101)) + XCTAssertEqual(RecordingFixture.targets.defaultTarget(), + .window(ids: [610], app: "Viewer", title: "Quarterly chart", pid: 4202), "else the frontmost app") + XCTAssertEqual(RecordingTargets(windows: [], apps: [], displays: RecordingFixture.targets.displays).defaultTarget(), + .display(index: 0, name: "Built-in Display"), "no window: the main display") + let own = RecordingFixture.targets.excluding(pids: [4101]) + XCTAssertEqual(own.windows.map(\.id), [610]) + XCTAssertEqual(own.apps.map(\.pid), [4202]) + } + + func testATargetSurvivesItsWireForm() throws { + for target in [RecordingTarget.window(ids: [7, 8], app: "Editor", title: "a", pid: 4101), + .app(app: "4202", name: "Viewer"), .display(index: 2, name: nil)] { + XCTAssertEqual(try JSONDecoder().decode(RecordingTarget.self, from: Data(target.json.utf8)), target) + } + } + + func testThePickerGroupsWindowsUnderTheirAppAndFilters() { + let windows = RecordingPickerRow.rows(RecordingFixture.targets, tab: .windows, query: "") + XCTAssertEqual(windows.map(\.title), ["Editor", "notes.txt", "Untitled window", "Viewer", "Quarterly chart"]) + XCTAssertEqual(windows.map { $0.target == nil }, [true, false, false, true, false]) + XCTAssertEqual(windows[0].detail, "2 windows") + XCTAssertEqual(RecordingPickerRow.rows(RecordingFixture.targets, tab: .windows, query: "chart").map(\.title), + ["Viewer", "Quarterly chart"]) + XCTAssertEqual(RecordingPickerRow.rows(RecordingFixture.targets, tab: .windows, query: "viewer").map(\.title), + ["Viewer", "Quarterly chart"], "the app's name matches its windows") + let apps = RecordingPickerRow.rows(RecordingFixture.targets, tab: .apps, query: "") + XCTAssertEqual(apps.map(\.title), ["Editor", "Viewer"]) + XCTAssertEqual(apps.map(\.target), [.app(app: "4101", name: "Editor"), .app(app: "4202", name: "Viewer")]) + let displays = RecordingPickerRow.rows(RecordingFixture.targets, tab: .displays, query: "") + XCTAssertEqual(displays.map(\.badge), ["Main", nil]) + XCTAssertEqual(displays.map(\.detail), ["1728 × 1117", "2560 × 1440"]) + } + + func testTheClockReadsMinutesAndHours() { + XCTAssertEqual(RecordingClock.format(0), "0:00") + XCTAssertEqual(RecordingClock.format(65_400), "1:05") + XCTAssertEqual(RecordingClock.format(3_725_000), "1:02:05") + } +} + +final class RecordingPanelLayoutTests: XCTestCase { + // Two displays: a 1728 x 1117 primary with a 25 pt menu bar and a 70 pt Dock, and one to its right. + private let screens = [ + RecordingPanelLayout.Screen(frame: CGRect(x: 0, y: 0, width: 1728, height: 1117), + visible: CGRect(x: 0, y: 70, width: 1728, height: 1022)), + RecordingPanelLayout.Screen(frame: CGRect(x: 1728, y: 0, width: 2560, height: 1440), + visible: CGRect(x: 1728, y: 0, width: 2560, height: 1415)), + ] + private let size = CGSize(width: 480, height: 44) + + func testNearTargetSitsCentredUnderTheWindow() { + // CG y 160..760 is Cocoa y 357..957 on the 1117 pt primary. + let frame = RecordingPanelLayout.frame(size: size, placement: .nearTarget, + targetCG: CGRect(x: 200, y: 160, width: 900, height: 600), + displayIndex: nil, screens: screens) + XCTAssertEqual(frame, CGRect(x: 410, y: 357 - 12 - 44, width: 480, height: 44)) + } + + func testNearTargetGoesInsideTheBottomEdgeWhenTheScreenHasNoRoomBelow() { + let frame = RecordingPanelLayout.frame(size: size, placement: .nearTarget, + targetCG: CGRect(x: 0, y: 25, width: 1728, height: 1022), + displayIndex: nil, screens: screens) + XCTAssertEqual(frame.minY, 70 + 16, "inside the window's bottom edge, the screen margin above the Dock") + XCTAssertEqual(frame.midX, 864) + } + + func testADisplayGetsTheControlsAtItsBottomCentreAndCornersStayInside() { + let display = RecordingPanelLayout.frame(size: size, placement: .nearTarget, targetCG: nil, displayIndex: 1, + screens: screens) + XCTAssertEqual(display, CGRect(x: 1728 + 1280 - 240, y: 32, width: 480, height: 44)) + let corner = RecordingPanelLayout.frame(size: size, placement: .topRight, + targetCG: CGRect(x: 1800, y: 100, width: 400, height: 300), + displayIndex: nil, screens: screens) + XCTAssertEqual(corner, CGRect(x: 1728 + 2560 - 16 - 480, y: 1415 - 16 - 44, width: 480, height: 44), + "the corner of the display the window is on") + let offScreen = RecordingPanelLayout.frame(size: size, placement: .nearTarget, + targetCG: CGRect(x: -900, y: 160, width: 600, height: 300), + displayIndex: nil, screens: screens) + XCTAssertGreaterThanOrEqual(offScreen.minX, 16, "clamped inside the visible frame") + } +} + +/// The controls fire their callbacks through real clicks: mouse events posted at probed frames in a hidden window, +/// hosted the way the controls panel hosts them (first click accepted). Never in a visible window. +@MainActor +final class RecordingControlsClickTests: XCTestCase { + private var fired: [String] = [] + + private func actions() -> RecordingControlsActions { + RecordingControlsActions( + pause: { self.fired.append("pause") }, resume: { self.fired.append("resume") }, + switchTarget: { self.fired.append("switch") }, restart: { self.fired.append("restart") }, + use: { self.fired.append("use") }, discard: { self.fired.append("discard") }, + dismissNotice: { self.fired.append("dismiss") }, moved: { self.fired.append("moved") }) + } + + private func host(_ view: some View, size: NSSize) -> NSWindow { + _ = NSApplication.shared + let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = FirstMouseHostingView(rootView: AnyView( + ZStack { Color(white: 0.11); view } + .environment(\.nativeSettingsReduceTransparency, true) + .frame(width: size.width, height: size.height))) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + return window + } + + private func pump(_ seconds: TimeInterval) { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if let next = NSApp.nextEvent(matching: .any, until: deadline, inMode: .default, dequeue: true) { + NSApp.sendEvent(next) + } + } + } + + private func post(_ type: NSEvent.EventType, _ window: NSWindow, at location: NSPoint) { + let event = NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: 1)! + NSApp.postEvent(event, atStart: false) + } + + private func click(_ name: String, in window: NSWindow) throws { + let frame = try XCTUnwrap(FrameProbe.frames[name], "\(name) has a frame") + let point = NSPoint(x: frame.midX, y: window.frame.height - frame.midY) + post(.leftMouseDown, window, at: point) + post(.leftMouseUp, window, at: point) + pump(0.4) + } + + override func setUp() { + super.setUp() + fired = [] + FrameProbe.enabled = true + FrameProbe.reset() + } + + override func tearDown() { + FrameProbe.enabled = false + super.tearDown() + } + + func testEveryControlFiresItsActionAndDiscardAsksFirst() throws { + let state = RecordingControlsState(phase: .recording, elapsedMs: 12_000, + target: .window(ids: [501], app: "Editor", title: "notes.txt", pid: nil), + useTitle: "Stop and paste") + let window = host(RecordingControlsView(state: state, actions: actions()), size: NSSize(width: 640, height: 60)) + defer { window.orderOut(nil); window.close() } + pump(0.8) + try click("recording.use", in: window) + XCTAssertEqual(fired, ["use"], "harness control: the primary button fires on one posted click") + try click("recording.pause", in: window) + try click("recording.switch", in: window) + XCTAssertEqual(fired, ["use", "pause", "switch"]) + try click("recording.discard", in: window) + XCTAssertEqual(fired.last, "switch", "with something recorded, discard asks before it acts") + if let directory = ProcessInfo.processInfo.environment["RECORDING_SNAPSHOT_DIR"], let view = window.contentView, + let rep = HostedShot.bitmap(view) { + let url = URL(fileURLWithPath: directory).appendingPathComponent("recording-controls-confirm-discard.png") + try rep.representation(using: .png, properties: [:])?.write(to: url) + print("shot \(url.path)") + } + try click("recording.keep", in: window) + XCTAssertEqual(fired.last, "switch", "Keep cancels") + try click("recording.discard", in: window) + try click("recording.confirm", in: window) + XCTAssertEqual(fired.last, "discard") + try click("recording.restart", in: window) + try click("recording.confirm", in: window) + XCTAssertEqual(fired.last, "restart") + } + + func testAnOpenDiscardQuestionSurvivesTheClockTicking() throws { + let model = RecordingControlsModel(state: RecordingControlsState( + phase: .recording, elapsedMs: 4_000, target: .display(index: 0, name: nil), useTitle: "Stop and paste")) + let window = host(RecordingControlsHost(model: model, actions: actions()), size: NSSize(width: 640, height: 60)) + defer { window.orderOut(nil); window.close() } + pump(0.8) + try click("recording.discard", in: window) + model.state.elapsedMs = 5_000 + pump(0.3) + model.state.elapsedMs = 6_000 + pump(0.3) + try click("recording.confirm", in: window) + XCTAssertEqual(fired, ["discard"], "the question was still open after two ticks") + } + + func testPausedShowsResumeAndDiscardWithoutTheQuestionWhenAsked() throws { + let state = RecordingControlsState(phase: .paused, elapsedMs: 30_000, target: .display(index: 0, name: "Built-in"), + pauseReason: "user", useTitle: "Stop and save", confirmsDiscard: false) + let window = host(RecordingControlsView(state: state, actions: actions()), size: NSSize(width: 640, height: 60)) + defer { window.orderOut(nil); window.close() } + pump(0.8) + XCTAssertNil(FrameProbe.frames["recording.pause"], "paused shows Resume, not Pause") + try click("recording.resume", in: window) + try click("recording.discard", in: window) + XCTAssertEqual(fired, ["resume", "discard"]) + } + + func testDraggingTheStatusMovesTheWindow() throws { + let state = RecordingControlsState(phase: .recording, elapsedMs: 5_000, target: .display(index: 0, name: nil), + useTitle: "Stop and paste") + let window = host(RecordingControlsView(state: state, actions: actions()), size: NSSize(width: 640, height: 60)) + defer { window.orderOut(nil); window.close() } + window.setFrameOrigin(NSPoint(x: 100, y: 100)) + pump(0.8) + let frame = try XCTUnwrap(FrameProbe.frames["recording.status"]) + let start = NSPoint(x: frame.midX, y: window.frame.height - frame.midY) + post(.leftMouseDown, window, at: start) + post(.leftMouseDragged, window, at: NSPoint(x: start.x + 40, y: start.y - 30)) + post(.leftMouseUp, window, at: NSPoint(x: start.x + 40, y: start.y - 30)) + pump(0.4) + XCTAssertTrue(fired.contains("moved"), "the drag reached the drag area: \(fired)") + XCTAssertNotEqual(window.frame.origin, NSPoint(x: 100, y: 100), "the window moved") + XCTAssertFalse(fired.contains("pause") || fired.contains("use"), "a drag presses nothing") + } +} + +/// The controls and the picker rendered for review: `RECORDING_SNAPSHOT_DIR=<dir>` writes PNGs there, flattened onto +/// an opaque dark background. The picker's rows are also read back with Vision. +@MainActor +final class RecordingRenderTests: XCTestCase { + private func render(_ view: some View, size: CGSize, name: String) throws -> NSView { + let framed = ZStack { + Color(white: 0.11) + view + } + .environment(\.nativeSettingsReduceTransparency, true) + let (window, host) = HostedShot.host(framed, size: size) + addTeardownBlock { window.close() } + RunLoop.main.run(until: Date().addingTimeInterval(0.6)) + if let directory = ProcessInfo.processInfo.environment["RECORDING_SNAPSHOT_DIR"] { + let rep = try XCTUnwrap(HostedShot.bitmap(host)) + let url = URL(fileURLWithPath: directory).appendingPathComponent(name + ".png") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: url) + print("shot \(url.path)") + } + return host + } + + func testThePickerListsTheFixtureTargets() throws { + let picker = RecordingTargetPicker(load: .loaded(RecordingFixture.targets), + initial: RecordingFixture.targets.windows[0].target, + onChoose: { _ in }, onCancel: {}) + let host = try render(picker.padding(20), size: CGSize(width: 440, height: 420), name: "recording-picker-windows") + let text = TreeDrawnText.lines(of: host).joined(separator: "\n") + for expected in ["Choose what to record", "notes.txt", "Untitled window", "Quarterly chart"] { + XCTAssertTrue(text.contains(expected), "\(expected) is drawn; read: \(text)") + } + } + + func testThePickerOpensOnTheTabOfItsInitialTarget() throws { + for (initial, name, expected) in [ + (RecordingFixture.targets.apps[1].target, "apps", ["Editor", "Viewer", "2 windows"]), + (RecordingFixture.targets.displays[1].target, "displays", ["Built-in Display", "Studio Display", "Main"]), + ] { + let picker = RecordingTargetPicker(load: .loaded(RecordingFixture.targets), title: "Switch to", + initial: initial, onChoose: { _ in }, onCancel: {}) + let host = try render(picker.padding(20), size: CGSize(width: 440, height: 360), name: "recording-picker-\(name)") + let text = TreeDrawnText.lines(of: host).joined(separator: "\n") + for line in expected { + XCTAssertTrue(text.contains(line), "\(name): \(line) is drawn; read: \(text)") + } + } + } + + func testRenderTheControlsInEachPhase() throws { + let target = RecordingTarget.window(ids: [501], app: "Editor", title: "notes.txt — Project plan", pid: nil) + let states: [(String, RecordingControlsState)] = [ + ("starting", .init(phase: .starting, target: target, useTitle: "Stop and paste")), + ("preparing", .init(phase: .starting, target: target, + preparing: "Building the recorder after an update. This happens once.", + useTitle: "Stop and paste")), + ("recording", .init(phase: .recording, elapsedMs: 83_000, target: target, useTitle: "Stop and paste")), + ("paused", .init(phase: .paused, elapsedMs: 83_000, target: .display(index: 0, name: "Built-in Display"), + pauseReason: "user", useTitle: "Stop and save")), + ("limit", .init(phase: .paused, elapsedMs: 600_000, target: target, pauseReason: "limit", + notice: "The recording reached its 10 min limit. Stop to keep it, or restart.", + useTitle: "Stop and paste")), + ("finishing", .init(phase: .finishing, elapsedMs: 83_000, target: target, useTitle: "Stop and paste")), + ] + for (name, state) in states { + let host = try render( + RecordingControlsView(state: state, actions: RecordingControlsActions( + pause: {}, resume: {}, switchTarget: {}, restart: {}, use: {}, discard: {})).padding(16), + size: CGSize(width: 700, height: name == "limit" || name == "preparing" ? 110 : 80), + name: "recording-controls-\(name)") + let text = TreeDrawnText.lines(of: host).joined(separator: " ") + if let preparing = state.preparing { + XCTAssertTrue(text.contains("Starting") && text.contains(preparing), "\(name): \(text)") + } + XCTAssertTrue(text.contains(state.phase == .finishing ? "Saving" : state.useTitle.components(separatedBy: " ").last ?? ""), + "\(name): \(text)") + } + } +} + +/// What the two hosts do with a finished movie: the widget imports it into the session's draft as a video, the +/// Capture shelf stages it as a capture. Each removes the recorder's file only once its own copy exists. +@MainActor +final class RecordingHostTests: XCTestCase { + private var suites: [String] = [] + + override func tearDown() { + for suite in suites { UserDefaults.standard.removePersistentDomain(forName: suite) } + super.tearDown() + } + + private func movie() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("recording-\(UUID().uuidString).mp4") + try Data("fixture movie".utf8).write(to: url) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + + private func model() -> WidgetModel { + let suite = "genesiskit-tests-recording-" + UUID().uuidString + suites.append(suite) + let defaults = UserDefaults(suiteName: suite)! + return WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, + defaults: defaults, appearance: NativeSettingsAppearance( + defaults: defaults, notificationNamespace: suite, observeExternalChanges: false)) + } + + func testStopAndPasteImportsTheMovieIntoTheDraftAsAVideo() async throws { + let model = model() + defer { model.stop() } + var requests: [WidgetJSON] = [] + model.actionRunner = { request in + requests.append(request) + return ["ok": true] + } + let file = try movie() + try await model.importRecording(file, to: "local:codex:fixture:") + XCTAssertEqual(requests, [[ + "action": "import", "key": "local:codex:fixture:", "input": .string(file.path), "type": "video", + ]]) + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path), "the draft holds its copy; the recorder's file goes") + XCTAssertEqual(model.importing, 0) + } + + func testAFailedImportKeepsTheMovieAndThrows() async throws { + let model = model() + defer { model.stop() } + model.actionRunner = { _ in throw ToolsBridgeError.refused("Video exceeds the 10-minute limit") } + let file = try movie() + do { + try await model.importRecording(file, to: "local:codex:fixture:") + XCTFail("a refused import throws") + } catch { + XCTAssertTrue(error.localizedDescription.contains("10-minute")) + } + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path), "the movie stays where the recorder put it") + } + + func testStopAndSaveStagesTheMovieAsACaptureOnTheShelf() async throws { + var calls: [[String]] = [] + var staged = 0 + let file = try movie() + let captured = #"{"id":"rec-1","kind":"capture","name":"Screen recording.mp4","path":"/fixture/assets/rec.mp4","sha256":"h","bytes":13,"createdAt":1,"assetId":"a1"}"# + let store = WidgetShelfStore(request: { args, _ in + calls.append(args) + return Data((args.first == "video" ? captured : #"{"revision":0,"items":[],"statePath":"/fixture/state.json"}"#).utf8) + }, onCaptureStaged: { staged += 1 }) + defer { store.stop() } + try await store.stageRecording(file) + let video = try XCTUnwrap(calls.first { $0.first == "video" }) + XCTAssertEqual(Array(video.prefix(3)), ["video", file.path, "--name"]) + XCTAssertTrue(video[3].hasPrefix("Screen recording ")) + XCTAssertEqual(staged, 1, "the shelf reopens to show the new capture") + XCTAssertEqual(store.notice, "Recording added. Choose an inbox when you are ready.") + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + XCTAssertFalse(store.canRecord, "a store made without a recorder offers no Record screen button") + } +} + +/// The picker panel takes its content's size every time it opens (Martin, 2026-10-23: a second recording from the +/// widget opened a picker of 0x0, so the recorder waited for a choice nobody could see and the video button stuck on +/// Stop). +@MainActor +final class RecordingTargetOutlineTests: XCTestCase { + func testEachDisplayDrawsItsOwnRectsAndHidesWithoutAny() { + let outline = RecordingTargetOutline(screens: [CGRect(x: 0, y: 0, width: 1000, height: 800), + CGRect(x: 1000, y: 0, width: 1000, height: 800)]) + defer { outline.close() } + XCTAssertEqual(outline.windowIDs.count, 2, "one panel per display, whatever the window list holds") + XCTAssertTrue(outline.windowIDs.allSatisfy { $0 > 0 }, "the ids exist before the recorder is told to leave them out") + let panels = outline.windowIDs.compactMap { id in NSApp.windows.first { $0.windowNumber == id } } + outline.show([CGRect(x: 100, y: 100, width: 400, height: 300)]) + XCTAssertEqual(outline.drawn, [[CGRect(x: 97, y: 97, width: 406, height: 306)], []]) + XCTAssertEqual(panels.map(\.isVisible), [true, false]) + XCTAssertTrue(panels.allSatisfy { $0.ignoresMouseEvents && $0.sharingType == .none }) + outline.show([CGRect(x: 1100, y: 50, width: 200, height: 100)]) + XCTAssertEqual(outline.drawn, [[], [CGRect(x: 97, y: 47, width: 206, height: 106)]]) + outline.hide() + XCTAssertTrue(panels.allSatisfy { !$0.isVisible }) + } +} + +@MainActor +final class RecordingPickerPanelTests: XCTestCase { + func testThePickerHasItsContentSizeEachTimeItOpens() async throws { + _ = NSApplication.shared + let panel = RecordingPickerPanel() + panel.window.alphaValue = 0 + defer { panel.dismiss() } + let anchor = CGPoint(x: (NSScreen.screens.first?.frame.midX ?? 400), y: (NSScreen.screens.first?.frame.midY ?? 400)) + for round in 1...3 { + let picker = RecordingTargetPicker(load: .loaded(RecordingFixture.targets), + initial: RecordingFixture.targets.windows[0].target, + onChoose: { _ in }, onCancel: {}) + panel.present(picker, near: anchor) {} + try await Task.sleep(for: .milliseconds(300)) + let size = panel.window.frame.size + XCTAssertGreaterThan(size.width, 200, "round \(round): the picker is \(size)") + XCTAssertGreaterThan(size.height, 200, "round \(round): the picker is \(size)") + panel.dismiss() + try await Task.sleep(for: .milliseconds(100)) + } + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/RenderProbeTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/RenderProbeTests.swift index 157da9f19e..11a094bbf7 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/RenderProbeTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/RenderProbeTests.swift @@ -39,3 +39,37 @@ final class RenderProbeTests: XCTestCase { XCTAssertEqual(RenderProbe.summary(since: start), "") } } + +/// A main thread that never idles is a spin even though every ping is served on time (the widget, 2026-10-23). +final class SpinTrackerTests: XCTestCase { + func testFiveBusySecondsAreASpinReportedOnceThenItsEnd() { + var tracker = HangWatch.SpinTracker() + var events: [HangWatch.SpinTracker.Event] = [] + for tick in 0 ... 30 { + if let event = tracker.step(share: 0.99, now: Double(tick) * 0.25) { events.append(event) } + } + if let event = tracker.step(share: 0.1, now: 8) { events.append(event) } + XCTAssertEqual(events, [.began(seconds: 5), .ended(seconds: 8)]) + } + + func testABusyBurstShorterThanTheThresholdIsNoSpin() { + var tracker = HangWatch.SpinTracker() + for tick in 0 ..< 12 { XCTAssertNil(tracker.step(share: 1, now: Double(tick) * 0.25)) } + XCTAssertNil(tracker.step(share: 0.2, now: 3), "an unreported burst ends silently") + XCTAssertNil(tracker.step(share: 1, now: 3.25), "a new burst starts its own clock") + } + + func testTheThreadCPUClockAdvancesWithWork() throws { + let thread = mach_thread_self() + let before = try XCTUnwrap(HangWatch.cpuTime(of: thread)) + // Spin until the CPU clock itself passes the bar: a loaded machine preempts the thread, so wall time proves nothing. + let deadline = Date().addingTimeInterval(2) + var spins = 0 + var after = before + while Date() < deadline, after - before <= 0.05 { + for _ in 0..<10_000 { spins &+= 1 } + after = try XCTUnwrap(HangWatch.cpuTime(of: thread)) + } + XCTAssertGreaterThan(after - before, 0.05, "busy work shows as CPU time (\(spins) spins)") + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift index 30f3c16fda..f71d6465db 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift @@ -483,4 +483,57 @@ final class SettingsControlsTests: XCTestCase { XCTAssertEqual(FocusForgetRange.lastHour.start(now: now), now.addingTimeInterval(-3_600)) XCTAssertEqual(FocusForgetRange.everything.start(now: now), Date(timeIntervalSince1970: 0)) } + + // MARK: - Settings search (H4) + + /// The search finds a page by its words, a card or row by its title, detail or card, ranks a title that starts + /// with the query first, and points each result at its page and card or row. + func testTheSettingsSearchFindsCardsAndRowsAcrossPagesAndRanksTitlesFirst() { + let pages = [ + NativeSettingsSearchPage(id: "general", title: "General", subtitle: "Theme and motion", section: "App"), + NativeSettingsSearchPage(id: "notifications.prActivity", title: "PR activity", subtitle: "Banners", section: "Notifications"), + ] + let entries: [String: [NativeSettingsSearchEntry]] = [ + "general": [ + NativeSettingsSearchEntry(kind: .card, title: "Accessibility"), + NativeSettingsSearchEntry(kind: .row, title: "Reduce motion", detail: "Stops the slides.", card: "Accessibility"), + ], + "notifications.prActivity": [ + NativeSettingsSearchEntry(kind: .card, title: "Defaults", detail: "A project follows these."), + NativeSettingsSearchEntry(kind: .row, title: "Cadence", detail: "How often a project is polled.", card: "Defaults"), + NativeSettingsSearchEntry(kind: .row, title: "Only my PRs", detail: "Changes on PRs someone else opened stay silent.", card: "Defaults"), + ], + ] + let cadence = NativeSettingsSearch.results("cadence", pages: pages, entries: entries) + XCTAssertEqual(cadence.map(\.title), ["Cadence"]) + XCTAssertEqual(cadence.first?.context, "PR activity · Defaults") + let target = cadence.first?.target ?? "" + XCTAssertEqual(NativeSettingsHighlightCenter.split(target).page, "notifications.prActivity") + XCTAssertEqual( + NativeSettingsHighlightCenter.split(target).anchor, + NativeSettingsSearch.anchor(page: "notifications.prActivity", key: "row:Defaults/Cadence")) + + // One row title in two cards (a Schedule row per background task): two results, each with its own target. + let tasks = ["general": [ + NativeSettingsSearchEntry(kind: .card, title: "Backup"), + NativeSettingsSearchEntry(kind: .row, title: "Schedule", card: "Backup"), + NativeSettingsSearchEntry(kind: .card, title: "Cleanup"), + NativeSettingsSearchEntry(kind: .row, title: "Schedule", card: "Cleanup"), + ]] + let schedules = NativeSettingsSearch.results("schedule", pages: pages, entries: tasks) + XCTAssertEqual(schedules.map(\.context), ["General · Backup", "General · Cleanup"]) + XCTAssertEqual(Set(schedules.map(\.target)).count, 2) + + // A detail match and a case- and accent-blind match; every word must be found somewhere. + XCTAssertEqual(NativeSettingsSearch.results("SILENT", pages: pages, entries: entries).map(\.title), ["Only my PRs"]) + XCTAssertEqual(NativeSettingsSearch.results("reduce slides", pages: pages, entries: entries).map(\.title), ["Reduce motion"]) + XCTAssertTrue(NativeSettingsSearch.results("reduce cadence", pages: pages, entries: entries).isEmpty) + + // A page result opens the page itself; a title that starts with the query comes before one that only holds it. + let motion = NativeSettingsSearch.results("motion", pages: pages, entries: entries) + XCTAssertEqual(motion.map(\.title), ["Reduce motion", "General"]) + XCTAssertEqual(motion.last?.target, "general") + XCTAssertEqual(NativeSettingsSearch.results("Pr", pages: pages, entries: entries).first?.title, "PR activity") + XCTAssertTrue(NativeSettingsSearch.results(" ", pages: pages, entries: entries).isEmpty) + } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/TestFocusTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/TestFocusTests.swift new file mode 100644 index 0000000000..0531583ca1 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/TestFocusTests.swift @@ -0,0 +1,22 @@ +import AppKit +import GenesisKitTestHooks +import XCTest + +/// A test run never takes the user's keyboard focus: GenesisKitTestHooks turns a request for a key window into +/// "order front" and refuses app activation from the moment the bundle loads. +@MainActor +final class TestFocusTests: XCTestCase { + func testAWindowAskedToBecomeKeyIsOnlyOrderedFront() { + _ = NSApplication.shared + let window = NSWindow(contentRect: NSRect(x: -2000, y: -2000, width: 40, height: 40), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let before = GenesisKitTestHooksRefusedFocus() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + XCTAssertFalse(window.isKeyWindow, "a test window must not take the keyboard") + XCTAssertFalse(NSApp.isActive, "a test run must not activate") + XCTAssertEqual(GenesisKitTestHooksRefusedFocus() - before, 2) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift index ac1b20f5f6..aa19e9457f 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift @@ -428,12 +428,66 @@ final class WidgetVoiceNotesStoreTests: XCTestCase { acquire: @escaping () async throws -> any VoiceRecordingLease = { FixtureVoiceLease() }, presentPermission: @escaping @MainActor (PermissionNeed) -> Void = { _ in }, recipients: [WidgetSession]? = nil, finish: @escaping () -> Void = {}, - launcher: String = "/fixture/Preview.app/Contents/MacOS/launcher") -> WidgetVoiceNotesStore { + launcher: String = "/fixture/Preview.app/Contents/MacOS/launcher", + undoSeconds: Double = 10) -> WidgetVoiceNotesStore { WidgetVoiceNotesStore(micLauncher: launcher, request: request, execute: execute, finishCapture: finish, cancelCommand: {}, acquireAudio: acquire, settings: { WidgetVoiceNoteSettings(provider: "fixture", model: "test-model", language: "en") }, sessions: { recipients ?? [self.recipient] }, attachDraft: attach, readMicrophonePermission: permission, - requestMicrophonePermission: prompt, activateForMicrophone: activate, presentMicrophonePermission: presentPermission) + requestMicrophonePermission: prompt, activateForMicrophone: activate, presentMicrophonePermission: presentPermission, + undoSeconds: { undoSeconds }) + } + + /// Discard takes the note off the list at once and deletes it only after the Undo time; Undo brings it back + /// (ux catalogue P4). + func testDiscardWaitsBehindUndoAndUndoBringsTheNoteBack() async throws { + var calls: [[String]] = [] + let store = make(request: { args in + calls.append(args) + return try self.data(["revision": 0, "notes": [self.note()], "statePath": "/fixture/widget/voice-notes/notes.json"]) + }, undoSeconds: 0.1) + defer { store.stop() } + store.refresh() + await store.waitForRefresh() + XCTAssertEqual(store.selectedID, noteID) + + store.discard() + XCTAssertTrue(store.notes.isEmpty, "the note leaves the list at once") + XCTAssertEqual(store.discarded?.id, noteID) + store.undoDiscard() + XCTAssertEqual(store.notes.map(\.id), [noteID]) + XCTAssertEqual(store.selectedID, noteID) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertFalse(calls.contains { $0.first == "discard" }, "Undo in time deletes nothing") + + store.discard() + store.refresh(force: true) + await store.waitForRefresh() + XCTAssertTrue(store.notes.isEmpty, "a list read during the Undo time does not bring it back") + try await Task.sleep(for: .milliseconds(200)) + await store.waitForDiscard() + XCTAssertEqual(calls.last { $0.first == "discard" }, ["discard", noteID, "--revision", "1"]) + XCTAssertNil(store.discarded) + } + + /// A delete the CLI refuses brings the note back with the transcript edits it had. + func testARefusedDiscardKeepsTheEditedTranscript() async throws { + let store = make(request: { args in + if args.first == "discard" { throw ToolsBridgeError.refused("the note changed") } + return try self.data(["revision": 0, "notes": [self.note()], "statePath": "/fixture/widget/voice-notes/notes.json"]) + }, undoSeconds: 0) + defer { store.stop() } + store.refresh() + await store.waitForRefresh() + store.text = "Edited words" + + store.discard() + store.confirmDiscard() + await store.waitForDiscard() + await store.waitForRefresh() + XCTAssertEqual(store.notes.map(\.id), [noteID], "the refused delete brings the note back") + store.selectedID = noteID + XCTAssertEqual(store.text, "Edited words") } func testOpeningVoiceNotesDoesNotBuildHundredsOfRecipientMenuItems() async throws { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 410015e7ee..7c02406bbc 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -594,6 +594,189 @@ final class WidgetInboxPolishTests: XCTestCase { XCTAssertEqual(roster.active, 2, "952 sessions, 2 of them active: the preview shows 2, not 952") } + func testTheHoverListShowsEverySessionThatNeedsYouAndActiveOnesOnlyWhenAsked() { + let target = WidgetTarget(hostId: "local", provider: "claude", sessionId: "s", sourceHome: "", cwd: "/fixture") + func session(_ key: String, _ status: String, at: Double, project: String = "Fixture", visible: Bool = true) + -> WidgetSession { + WidgetSession(key: key, target: target, title: key, project: project, activityAt: at, + status: status, pinned: true, visible: visible, hiddenByFilter: false) + } + let sessions = [session("asks", "recent", at: 1, project: "Beta"), session("waits", "waiting", at: 2), + session("hidden-asks", "recent", at: 3, visible: false), session("works", "working", at: 9), + session("quiet", "finished", at: 8)] + let inbox = ["asks": WidgetInboxSession(key: "asks", unread: 0, needsAnswer: 2), + "hidden-asks": WidgetInboxSession(key: "hidden-asks", unread: 0, needsAnswer: 1)] + let needs = WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "needs-you", sort: "attention") + XCTAssertEqual(needs.map(\.key), ["hidden-asks", "waits", "asks"], + "every session that needs you, also one the rail does not show, newest first") + let active = WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "active", sort: "attention") + XCTAssertEqual(active.map(\.key), ["hidden-asks", "waits", "asks", "works"], "working ones join after them") + XCTAssertEqual(WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "active", sort: "recent") + .map(\.key), ["works", "hidden-asks", "waits", "asks"]) + XCTAssertEqual(WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "needs-you", sort: "project") + .first?.key, "asks", "Beta before Fixture") + } + + func testAnAlertPlaysItsKindsSoundAndFlashesForTheConfiguredTime() throws { + let decision = WidgetInboxItem(id: "decision:d_1_s", sourceId: "d_1_s", kind: "decision", key: "k", at: 1, + needsAnswer: true) + let answer = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 2, needsAnswer: false) + func prefs(_ json: String) throws -> WidgetPreferences { + let base = #"{"excludedKeys":[],"projects":[],"sessions":[],"showChanges":true,"placement":"side","side":"right","quietSeconds":15,"voiceProvider":"xai","voiceLanguage":"","# + return try JSONDecoder().decode(WidgetPreferences.self, from: Data((base + json.dropFirst()).utf8)) + } + let split = try prefs(#"{"alertSound":"Ping","alertAnswerSound":"Pop","alertFlashSeconds":10}"#) + XCTAssertEqual(WidgetAlerts.sound(for: [answer], preferences: split), "Pop") + XCTAssertEqual(WidgetAlerts.sound(for: [answer, decision], preferences: split), "Ping", + "a burst with a question in it plays the decisions' sound") + XCTAssertEqual(WidgetAlerts.sound(for: [answer], preferences: try prefs(#"{"alertSound":"Ping","alertAnswerSound":"same"}"#)), + "Ping", "\"same\" follows the decisions' sound") + XCTAssertNil(WidgetAlerts.sound(for: [answer], preferences: nil), "no preferences: the default sound") + XCTAssertEqual(WidgetAlerts.flashSeconds(split), 10) + XCTAssertEqual(WidgetAlerts.flashSeconds(nil), 5, "flashing is on by default") + XCTAssertEqual(WidgetAlerts.flashSeconds(try prefs(#"{"alertFlashSeconds":0}"#)), 0) + } + + /// The popup says "Decision 4", not "New decision · DECISION 4"; VoiceOver hears what arrived and from where; the + /// popup's time and a note's time are settings (ux catalogue R4, R6, R10, R3). + func testAlertWordsAndTheTimesTheySettle() throws { + func prefs(_ json: String) throws -> WidgetPreferences { + let base = #"{"excludedKeys":[],"projects":[],"sessions":[],"showChanges":true,"placement":"side","side":"right","quietSeconds":15,"voiceProvider":"xai","voiceLanguage":"","# + return try JSONDecoder().decode(WidgetPreferences.self, from: Data((base + json.dropFirst()).utf8)) + } + let decision = WidgetInboxItem(id: "decision:d_4_s", sourceId: "d_4_s", kind: "decision", key: "k", at: 1, + needsAnswer: true, title: "Keep the cache?", number: 4) + XCTAssertEqual(WidgetAlerts.kicker(decision), "Decision 4") + XCTAssertEqual(WidgetAlerts.announcement(decision, session: "Fixture session"), + "Decision 4, Keep the cache?, from Fixture session") + let answer = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 2, needsAnswer: false) + XCTAssertEqual(WidgetAlerts.announcement(answer, session: nil), "New answer") + XCTAssertEqual(WidgetAlerts.popupSeconds(nil), 7, "the popup stays 7 s by default") + XCTAssertEqual(WidgetAlerts.popupSeconds(try prefs(#"{"alertPopupSeconds":15}"#)), 15) + XCTAssertEqual(WidgetModel.noteSeconds(nil, hasAction: false), 6, "a note fades after 6 s by default") + XCTAssertEqual(WidgetModel.noteSeconds(nil, hasAction: true), 10, "a note with Undo or Send again stays 10 s") + XCTAssertEqual(WidgetModel.noteSeconds(try prefs(#"{"noteSeconds":20}"#), hasAction: true), 20) + XCTAssertNil(WidgetModel.noteSeconds(try prefs(#"{"noteSeconds":0}"#), hasAction: false), "0 keeps it") + XCTAssertEqual(try prefs(#"{"alertSound":"Glass"}"#).openShortcutChord, .defaultOpen, + "the shortcut is on, ⌃⌥⌘W, by default") + XCTAssertNil(try prefs(#"{"openShortcutEnabled":false}"#).openShortcutChord) + XCTAssertEqual(try prefs(#"{"openShortcut":{"keyCode":2,"modifiers":6400}}"#).openShortcutChord, + WidgetShortcutChord(keyCode: 2, modifiers: 6400)) + XCTAssertEqual(try prefs(#"{"openShortcut":{"keyCode":13,"modifiers":512}}"#).openShortcutChord, .defaultOpen, + "a chord with only Shift would take a plain key from every app") + } + + /// The rail badge counts what waits for an answer, the hub's Inbox count; unread answers do not (R1). A lost + /// connection says one sentence and keeps the stderr for Details; a cleared or new error drops it (R11). + @MainActor + func testTheBadgeCountsWaitingItemsAndAnErrorKeepsItsDetailApart() { + let defaults = UserDefaults(suiteName: "widget.badge.\(UUID().uuidString)")! + let model = WidgetModel(binaryPath: "/fixture/no-process", defaults: defaults) + model.updateInbox(WidgetInboxSummary(unread: 120, needsAnswer: 3, complete: true, truncated: false, sessions: [])) + XCTAssertEqual(model.waitingCount, 3) + XCTAssertEqual(model.inboxCount, 123, "Mark all read still sees every unread item") + model.fail(WidgetModel.connectionLostMessage, detail: " error: watch exited 1\n") + XCTAssertEqual(model.error, WidgetModel.connectionLostMessage) + XCTAssertEqual(model.errorDetail, "error: watch exited 1") + model.error = "Another failure." + XCTAssertNil(model.errorDetail, "a new error does not show the old one's details") + model.fail("Third.", detail: "raw") + model.error = nil + XCTAssertNil(model.errorDetail) + model.postNote("Saved.", action: WidgetNoticeAction(title: "Undo") {}) + XCTAssertEqual(model.notice, "Saved.") + XCTAssertEqual(model.noticeAction?.title, "Undo") + model.postNote("Next note.") + XCTAssertNil(model.noticeAction, "a new note drops the old note's button") + } + + func testListKeysStopAtTheEnds() { + XCTAssertEqual(WidgetModel.listTarget(current: nil, step: 1, count: 3), 0) + XCTAssertEqual(WidgetModel.listTarget(current: nil, step: -1, count: 3), 2) + XCTAssertEqual(WidgetModel.listTarget(current: 2, step: 1, count: 3), 2) + XCTAssertEqual(WidgetModel.listTarget(current: 0, step: -1, count: 3), 0) + XCTAssertEqual(WidgetModel.listTarget(current: 1, step: 1, count: 3), 2) + XCTAssertNil(WidgetModel.listTarget(current: nil, step: 1, count: 0)) + } + + @MainActor + func testFlashingOneSessionLeavesEveryOtherSessionsTriggerAlone() { + let defaults = UserDefaults(suiteName: "widget.attention.\(UUID().uuidString)")! + let model = WidgetModel(binaryPath: "/fixture/no-process", defaults: defaults) + model.flashAttention("a", seconds: 5) + let other = model.attentionTrigger("b") + model.flashAttention("a", seconds: 5) + XCTAssertEqual(model.attentionTrigger("a"), 2) + XCTAssertEqual(model.attentionTrigger("b"), other, "an unchanged trigger never replays another session's flash") + model.flashAttention("b", seconds: 0) + XCTAssertEqual(model.attentionTrigger("b"), other, "0 seconds is off") + } + + func testTheHeaderReadsTheLastCallsContextAndRowsTheirNewestEntry() { + func turn(_ role: String, _ usage: TranscriptUsage?) -> TranscriptTurn { + TranscriptTurn(id: UUID().uuidString, role: role, at: nil, text: "", tools: [], usage: usage) + } + XCTAssertEqual(WidgetModel.contextTokens([ + turn("assistant", TranscriptUsage(inputTokens: 10, cacheReadTokens: 90)), + turn("assistant", TranscriptUsage(inputTokens: 5, cacheReadTokens: 120_000)), turn("user", nil), + ]), 120_005, "the newest call that reports usage, input plus cache reads") + XCTAssertNil(WidgetModel.contextTokens([turn("user", nil)])) + + let asked = WidgetInboxItem(id: "form:f", sourceId: "f", kind: "form", key: "k", at: 3_000, needsAnswer: true) + let answered = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 9_000, + needsAnswer: false) + let both = WidgetInboxSession(key: "k", unread: 1, needsAnswer: 1, unreadItem: answered, pendingItem: asked) + XCTAssertEqual(WidgetSessionTimes.latest(both)?.verb, "asked", "a waiting question wins over a newer answer") + let read = WidgetInboxSession(key: "k", unread: 1, needsAnswer: 0, unreadItem: answered) + XCTAssertEqual(WidgetSessionTimes.latest(read)?.verb, "answered") + XCTAssertNil(WidgetSessionTimes.latest(nil)) + } + + func testTheSessionInboxFilterSplitsInboxEntriesFromDecisions() { + let cards = ["answer", "result", "form", "decision", "todo"].map { kind in + WidgetCard(id: kind, kind: kind, sessionKey: "k", sourceId: kind, at: 1, title: kind, body: "", + status: "open", choices: [], attachments: [], refs: [], read: true) + } + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "inbox").map(\.kind), ["answer", "result", "form"]) + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "decisions").map(\.kind), ["decision", "todo"]) + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "all").count, 5) + } + + /// Each ledger state reads as one phrase in every tab (P20), a decision event as words without its row id (P21), + /// and the Undo note names what changed (P3). + @MainActor + func testStatesEventsAndUndoNotesAreWordsNotRawValues() { + XCTAssertEqual(LiveWidgetView.statusPhrase("answered", kind: "decision"), "Waiting to send") + XCTAssertEqual(LiveWidgetView.statusPhrase("answered", kind: "form"), "Answered") + XCTAssertEqual(LiveWidgetView.statusPhrase("implemented", kind: "todo"), "Completed") + XCTAssertEqual(LiveWidgetView.statusPhrase("waiting-route", kind: "other"), "Waiting route", + "an unknown state is sentence case, never Waiting-Route") + let form = WidgetCard(id: "form:f1", kind: "form", sessionKey: "k", sourceId: "f1", at: 1, title: "Q", body: "", + status: "answered", choices: [], attachments: [], refs: [], read: true) + XCTAssertEqual(LiveWidgetView.kindLabel(form), "Question · Answered") + + let delivered = WidgetActivityEvent(id: "e1", sourceId: "d_4_s", at: 1, title: "Decision delivery", + body: "sent · cmux") + XCTAssertEqual(LiveWidgetView.activityLine(delivered, number: 4, provider: "claude"), + .init(title: "Answer delivered", detail: "Decision 4 · Sent · through cmux")) + XCTAssertEqual(LiveWidgetView.activityLine(delivered, number: nil, provider: "claude").detail, + "Claude · Sent · through cmux", "without the card, the provider's name, never d_4_s") + let failed = WidgetActivityEvent(id: "e2", sourceId: "d_4_s", at: 1, title: "Decision send_failed", + body: "answered · something-new") + XCTAssertEqual(LiveWidgetView.activityLine(failed, number: 4, provider: "codex"), + .init(title: "Answer not delivered", detail: "Decision 4 · Waiting to send"), + "a value the widget has no words for is left out") + + let decision = WidgetCard(id: "decision:d_4_s", kind: "decision", sessionKey: "k", sourceId: "d_4_s", at: 1, + title: "T", body: "", status: "open", revision: 1, number: 4, choices: [], + attachments: [], refs: [], read: true) + XCTAssertEqual(WidgetLedgerUndo.note(decision, state: "dismissed"), "Decision 4 dismissed.") + let task = WidgetCard(id: "decision:t_2_s", kind: "todo", sessionKey: "k", sourceId: "t_2_s", at: 1, + title: "T", body: "", status: "open", revision: 1, number: 2, choices: [], + attachments: [], refs: [], read: true) + XCTAssertEqual(WidgetLedgerUndo.note(task, state: "implemented"), "Task 2 completed.") + } + @MainActor func testASnapshotDecodedForAStoppedWatchIsDroppedAfterARestart() async throws { let decoder = WidgetSnapshotDecoder() @@ -763,6 +946,55 @@ final class WidgetAnswerTargetTests: XCTestCase { XCTAssertEqual(key("j", keyCode: 0x26, answering: card), .previous) XCTAssertEqual(key("", keyCode: 53, answering: card), .collapse) } + + /// Dismiss shows at once and offers Undo; Undo sends nothing and keeps the decision open; the change reaches the + /// decision log only when its note goes away, with the session and revision of the click (P3). + func testDismissWaitsForItsUndoNoteAndUndoKeepsTheDecisionOpen() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("widget-undo-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent("native-submissions"), + withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let defaults = try XCTUnwrap(UserDefaults(suiteName: "widget.undo." + UUID().uuidString)) + let model = WidgetModel(binaryPath: "/fixture/no-process", stateRoot: root.path, defaults: defaults, + appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "undo." + UUID().uuidString, observeExternalChanges: false)) + defer { model.stop() } + final class Sent { var requests: [WidgetJSON] = [] } + let sent = Sent() + // Only the decision log's moves: opening the card also marks it read. + model.actionRunner = { value in + if case .object(let fields) = value, fields["action"] == .string("ledger") { sent.requests.append(value) } + return .null + } + let card = decision() + model.receive([try snapshot(card)]) + if model.selectedKey != key { model.select(key) } + model.openCard(card) + let undo = WidgetLedgerUndo() + + undo.hold(card, to: "dismissed", model: model, reduceMotion: true) + XCTAssertEqual(undo.applied(card).status, "dismissed", "the card shows the change at once") + XCTAssertFalse(undo.applied(card).needsAnswer) + XCTAssertNil(model.answerCard, "folded, the number keys no longer answer it") + XCTAssertEqual(model.notice, "Decision 1 dismissed.") + XCTAssertEqual(model.noticeAction?.title, "Undo") + model.noticeAction?.run() + XCTAssertEqual(undo.applied(card).status, "open") + XCTAssertEqual(model.answerCard?.id, card.id, "Undo opens the card again") + XCTAssertEqual(model.notice, "Undone.") + try await Task.sleep(for: .milliseconds(50)) + XCTAssertTrue(sent.requests.isEmpty, "an undone change never reaches the log") + + undo.hold(card, to: "dismissed", model: model, reduceMotion: true) + XCTAssertTrue(sent.requests.isEmpty, "nothing is sent while the note can still undo it") + model.notice = nil + let deadline = Date().addingTimeInterval(2) + while sent.requests.isEmpty, Date() < deadline { try await Task.sleep(for: .milliseconds(10)) } + XCTAssertEqual(sent.requests, [.object([ + "action": "ledger", "id": "d_1_fixture", "sessionId": "fixture", "expectedRevision": .number(1), + "state": "dismissed", + ])]) + } } /// The camera button when macOS refuses the screenshot. @@ -835,3 +1067,724 @@ final class WarmPaneVisibilityTests: XCTestCase { XCTAssertEqual(shown.enabled.last, true) } } + +/// One session with one open decision, as the widget's snapshot carries it. +@MainActor +private enum StagingFixture { + static let key = "local:codex:fixture-staged:" + + /// `stagedOnly`: the decision is not among the session's listed items (past its eight), only in the staged list. + static func snapshot(draftOption: String?, decisionClick: String = "stage", stagedOnly: Bool = false) throws -> String { + var item: [String: Any] = ["id": "decision:d1", "sourceId": "d1", "kind": "decision", "key": key, "at": 1, + "needsAnswer": true, "title": "Keep it?", "number": 7, "options": ["keep", "drop"], + "revision": 1] + var card: [String: Any] = ["id": "decision:d1", "kind": "decision", "sourceId": "d1", "sessionKey": key, "at": 1, + "title": "Keep it?", "body": "", "status": draftOption == nil ? "open" : "drafted", + "revision": 1, "number": 7, + "choices": [["id": "a", "title": "keep", "recommended": true], + ["id": "b", "title": "drop", "recommended": false]], + "attachments": [], "refs": [], "read": true] + if let draftOption { item["draftOption"] = draftOption; card["draftOption"] = draftOption } + let session: [String: Any] = ["key": key, "target": ["hostId": "local", "provider": "codex", + "sessionId": "fixture-staged", "sourceHome": "", "cwd": "/fixture"], "title": "Staged fixture", + "project": "Fixture", "activityAt": 0, "status": "waiting", "pinned": true, "visible": true, "hiddenByFilter": false] + var notifications: [String: Any] = ["unread": 0, "needsAnswer": 1, "complete": true, "truncated": false, + "sessions": [["key": key, "unread": 0, "needsAnswer": 1, "pendingItem": item, + "items": stagedOnly ? [] : [item]]]] + if stagedOnly { notifications["staged"] = [item] } + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "state": ["version": 1, "revision": 0, + "selectedKey": key, "preferences": ["excludedKeys": [], "projects": [], "sessions": [], "showChanges": false, + "placement": "both", "side": "right", "quietSeconds": 15, "voiceProvider": "openai", "voiceLanguage": "en", + "decisionClick": decisionClick], + "assets": [:], "drafts": [:], "outgoing": []], "sessions": [session], "cards": [card], + "manifests": [:], "errors": [], "selectedKey": key, "notifications": notifications]) + return String(decoding: data, as: UTF8.self) + } + + static func model() -> WidgetModel { + let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! + return WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, + defaults: defaults, appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "staging." + UUID().uuidString, observeExternalChanges: false)) + } +} + +/// Staged decisions (Martin, 2026-10-23): a click stages the pick, closing with staged ones asks first, and the +/// answer to that question sends, keeps or discards them. +@MainActor +final class WidgetStagingTests: XCTestCase { + private let key = StagingFixture.key + + private func snapshot(draftOption: String?, decisionClick: String = "stage", stagedOnly: Bool = false) throws -> String { + try StagingFixture.snapshot(draftOption: draftOption, decisionClick: decisionClick, stagedOnly: stagedOnly) + } + + private func names(_ actions: [WidgetJSON]) -> [String] { + actions.compactMap { value in + guard case .object(let fields) = value, case .string(let name) = fields["action"] else { return nil } + return name + } + } + + func testAPickStagesAndClosingAsksThenSendsOneBatch() async throws { + let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! + let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, + defaults: defaults, appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "staging." + UUID().uuidString, observeExternalChanges: false)) + defer { model.stop() } + var actions: [WidgetJSON] = [] + model.actionRunner = { value in + actions.append(value) + return ["sent": 1, "failed": 0] + } + model.receive([try snapshot(draftOption: nil)]) + model.select(key) + let card = try XCTUnwrap(model.cards.first) + + model.choose(card, option: "a") + XCTAssertEqual(model.stagedOption("d1", stored: nil), "a", "the pick shows at once, before the hub answers") + try await Task.sleep(for: .milliseconds(200)) + let ledger = try XCTUnwrap(actions.last) + guard case .object(let fields) = ledger else { return XCTFail("not an object") } + XCTAssertEqual(fields["action"], .string("ledger")) + XCTAssertEqual(fields["state"], .string("drafted")) + XCTAssertEqual(fields["draftOption"], .string("a")) + XCTAssertFalse(names(actions).contains("submit"), "a staged pick sends nothing") + + model.receive([try snapshot(draftOption: "a")]) + XCTAssertEqual(model.stagedItems.map(\.sourceId), ["d1"]) + XCTAssertTrue(model.pendingStage.isEmpty, "the snapshot now shows the pick") + + model.expanded = .right + model.requestCollapse() + XCTAssertTrue(model.stagedLeavePrompt, "closing with a staged decision asks first") + XCTAssertEqual(model.expanded, .right, "and keeps the panel open while it asks") + + model.resolveStagedLeave("send") + XCTAssertEqual(model.expanded, .right, "the panel stays open until the hub answers") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.expanded, .right, "the delivered note stays readable before the panel folds") + try await Task.sleep(for: .milliseconds(2600)) + XCTAssertNil(model.expanded, "and closes once every answer was delivered") + XCTAssertEqual(names(actions).last, "send-staged") + XCTAssertEqual(WidgetModel.stagedSummary(model.stagedItems), "1 decision will be sent to 1 session") + } + + func testASendNoSessionTookSaysWhyAndSendAgainDeliversIt() async throws { + let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! + let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, + defaults: defaults, appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "staging." + UUID().uuidString, observeExternalChanges: false)) + defer { model.stop() } + var actions: [WidgetJSON] = [] + let reason = "2 Claude terminals run this session (pid 41, pid 42), active within 10 minutes of each other; none was picked." + model.actionRunner = { value in + actions.append(value) + guard case .object(let fields) = value, fields["action"] == .string("send-staged") else { + return ["sent": 1, "queued": 0, "failed": 0, "sessions": [["session": "fixture-staged", + "numbers": [7], "result": ["delivered": true, "detail": "codex worker w1"]]]] + } + return ["sent": 0, "queued": 1, "failed": 0, "sessions": [["session": "fixture-staged", "numbers": [7], + "result": ["delivered": false, "detail": .string(reason)]]]] + } + model.receive([try snapshot(draftOption: "a")]) + model.select(key) + + model.sendStaged(["d1"]) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.notice, "Not delivered to Staged fixture: Several Claude terminals run this session, so none " + + "was picked. Close the extra one, then send again. The answers wait for its next prompt.") + let again = try XCTUnwrap(model.noticeAction, "a send nobody took offers Send again") + XCTAssertEqual(again.title, "Send again") + + again.run() + try await Task.sleep(for: .milliseconds(200)) + guard case .object(let resend) = try XCTUnwrap(actions.last) else { return XCTFail("not an object") } + XCTAssertEqual(resend["action"], .string("resend-answers")) + XCTAssertEqual(resend["ids"], .array(["d1"])) + XCTAssertEqual(model.notice, "1 decision delivered.") + XCTAssertNil(model.noticeAction, "a delivered send leaves no button") + } + + /// Send now in the leave question: a send no session took keeps the panel open with the reason and Send again + /// (ux catalogue P1), and the next close does not ask about the same decisions again. + func testSendNowOnTheWayOutStaysOpenWhenNoSessionTookIt() async throws { + _ = NSApplication.shared + let model = StagingFixture.model() + defer { model.stop() } + model.actionRunner = { value in + guard case .object(let fields) = value, fields["action"] == .string("send-staged") else { return ["ok": true] } + return ["sent": 0, "queued": 1, "failed": 0, "sessions": [["session": "fixture-staged", "numbers": [7], + "result": ["delivered": false, "detail": "no cmux pane runs this session"]]]] + } + model.receive([try snapshot(draftOption: "a")]) + model.select(key) + model.expanded = .right + model.requestCollapse() + XCTAssertTrue(model.stagedLeavePrompt) + + model.resolveStagedLeave("send") + XCTAssertFalse(model.stagedLeavePrompt) + XCTAssertEqual(model.expanded, .right, "the panel waits for the hub's answer") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.expanded, .right, "a send no session took stays where the user can read it") + XCTAssertEqual(model.notice, "Not delivered to Staged fixture: No cmux pane runs this session. " + + "The answers wait for its next prompt.") + XCTAssertEqual(model.noticeAction?.title, "Send again") + + model.requestCollapse() + XCTAssertFalse(model.stagedLeavePrompt, "these decisions were already sent; closing does not ask again") + XCTAssertNil(model.expanded) + } + + /// A send that fails outright keeps the panel open with one sentence and the raw text behind Details, and the + /// decisions, still staged, are asked about again at the next close. + func testAFailedSendOnTheWayOutStaysOpenAndAsksAgainNextTime() async throws { + _ = NSApplication.shared + let model = StagingFixture.model() + defer { model.stop() } + model.actionRunner = { value in + guard case .object(let fields) = value, fields["action"] == .string("send-staged") else { return ["ok": true] } + throw ToolsBridgeError.refused("hub call exited 1: fixture stderr") + } + model.receive([try snapshot(draftOption: "a")]) + model.select(key) + model.expanded = .right + model.requestCollapse() + model.resolveStagedLeave("send") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.expanded, .right) + XCTAssertEqual(model.error, "The decisions could not be sent. They stay staged, so you can send them again.") + XCTAssertEqual(model.errorDetail, "hub call exited 1: fixture stderr") + XCTAssertNil(model.notice, "the Sending note does not stay beside the error") + + model.requestCollapse() + XCTAssertTrue(model.stagedLeavePrompt, "still staged, so the next close asks again") + } + + /// Every Undo row (Remove from shelf, Discard of a voice note) lasts as long as a note with a button; notes that + /// stay until closed hold the item 60 s at most (judge round F4). + func testUndoRowsLastAsLongAsANoteWithAButton() throws { + func prefs(_ json: String) throws -> WidgetPreferences { + let base = #"{"excludedKeys":[],"projects":[],"sessions":[],"showChanges":true,"placement":"side","side":"right","quietSeconds":15,"voiceProvider":"xai","voiceLanguage":"","# + return try JSONDecoder().decode(WidgetPreferences.self, from: Data((base + json.dropFirst()).utf8)) + } + XCTAssertEqual(WidgetModel.undoSeconds(nil), 10, "the default note time is 6 s, so a note with Undo stays 10 s") + XCTAssertEqual(WidgetModel.undoSeconds(try prefs(#"{"noteSeconds":3}"#)), 10) + XCTAssertEqual(WidgetModel.undoSeconds(try prefs(#"{"noteSeconds":20}"#)), 20) + XCTAssertEqual(WidgetModel.undoSeconds(try prefs(#"{"noteSeconds":0}"#)), 60, "until closed, at most 60 s") + for json in [#"{"noteSeconds":6}"#, #"{"noteSeconds":3}"#, #"{"noteSeconds":20}"#] { + let preferences = try prefs(json) + XCTAssertEqual(WidgetModel.undoSeconds(preferences), WidgetModel.noteSeconds(preferences, hasAction: true), + "the same time as the note's own Undo or Send again") + } + } + + /// The hub's reasons for a send no session took read as sentences; the raw text stays for Details (P24). + func testTheHubsReasonsReadAsSentences() { + XCTAssertEqual(WidgetDecisionSendOutcome.sentence( + for: "The Claude worker is busy; the answer remains queued for a later turn."), + "Its worker is busy with another turn.") + XCTAssertEqual(WidgetDecisionSendOutcome.sentence( + for: "no cmux pane runs this session Saved for this exact session; awaiting a consumer acknowledgement."), + "No cmux pane runs this session.") + XCTAssertEqual(WidgetDecisionSendOutcome.sentence(for: "a reason the widget has not seen"), + "No running agent took them.") + XCTAssertEqual(WidgetDecisionSendOutcome.sentence(for: nil), "No running agent took them.") + + let outcome = WidgetDecisionSendOutcome(["sent": 0, "queued": 2, "failed": 0, "sessions": [ + ["session": "s1", "numbers": [3], "result": ["delivered": false, "detail": "cmux is not running"]], + ["session": "s2", "numbers": [4], "result": ["delivered": false, "detail": "cmux is not running"]], + ]], titles: ["s1": "First", "s2": "Second"]) + XCTAssertFalse(outcome.delivered) + XCTAssertEqual(outcome.details, "First: cmux is not running\nSecond: cmux is not running") + XCTAssertEqual(outcome.notice(items: [], staged: true), "Not delivered to 2 sessions: cmux is not running. " + + "The answers wait for each session's next prompt.") + } + + /// The decision writes and sends among `actions`, without the selection and read marks around them. + private func decisionActions(_ actions: [WidgetJSON]) -> [String] { + names(actions).filter { ["ledger", "send-staged"].contains($0) } + } + + func testKeepingStagedDecisionsDoesNotAskAgainForTheSameOnes() throws { + let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! + let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, + defaults: defaults, appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "staging." + UUID().uuidString, observeExternalChanges: false)) + defer { model.stop() } + model.actionRunner = { _ in ["ok": true] } + model.receive([try snapshot(draftOption: "b")]) + model.expanded = .right + model.requestCollapse() + XCTAssertTrue(model.stagedLeavePrompt) + model.resolveStagedLeave("keep") + XCTAssertNil(model.expanded) + model.expanded = .right + model.requestCollapse() + XCTAssertFalse(model.stagedLeavePrompt, "the same staged decisions were kept a moment ago") + XCTAssertNil(model.expanded) + } + + func testTheSendSettingAnswersAtOnce() async throws { + let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! + let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, + defaults: defaults, appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "staging." + UUID().uuidString, observeExternalChanges: false)) + defer { model.stop() } + var actions: [WidgetJSON] = [] + model.actionRunner = { value in actions.append(value); return ["ok": true] } + model.receive([try snapshot(draftOption: nil, decisionClick: "send")]) + model.select(key) + model.choose(try XCTUnwrap(model.cards.first), option: "b") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertFalse(names(actions).contains("ledger"), "the send setting does not stage") + XCTAssertNil(model.pendingStage["d1"]) + } + + /// Several decision rows can be open at once: a click on one row's option answers that row's decision, not the + /// one selected last. + func testTheSendSettingAnswersTheClickedDecisionNotTheSelectedOne() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("staging-send-\(UUID())") + let journal = root.appendingPathComponent("native-submissions") + try FileManager.default.createDirectory(at: journal, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! + let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: root.path, defaults: defaults, + appearance: NativeSettingsAppearance(defaults: defaults, notificationNamespace: "staging." + UUID().uuidString, + observeExternalChanges: false)) + defer { model.stop() } + model.actionRunner = { _ in ["ok": true] } + var fixture = try XCTUnwrap(JSONSerialization.jsonObject( + with: Data(try snapshot(draftOption: nil, decisionClick: "send").utf8)) as? [String: Any]) + var cards = try XCTUnwrap(fixture["cards"] as? [[String: Any]]) + var other = cards[0] + other["id"] = "decision:d2" + other["sourceId"] = "d2" + other["number"] = 8 + cards.append(other) + fixture["cards"] = cards + model.receive([String(decoding: try JSONSerialization.data(withJSONObject: fixture), as: UTF8.self)]) + model.select(key) + model.selectedCardID = "decision:d2" + + model.choose(try XCTUnwrap(model.cards.first { $0.sourceId == "d1" }), option: "b") + let file = try XCTUnwrap(try FileManager.default.contentsOfDirectory(at: journal, includingPropertiesForKeys: nil) + .first { $0.pathExtension == "json" }, "the answer is queued") + let request = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]) + let payload = try XCTUnwrap(request["payload"] as? [String: Any]) + XCTAssertEqual(payload["id"] as? String, "d1") + XCTAssertEqual(payload["option"] as? String, "b") + } + + func testTheSendSettingAnswersAtOnceFromTheInboxOfEverySession() async throws { + let model = StagingFixture.model() + defer { model.stop() } + var actions: [WidgetJSON] = [] + model.actionRunner = { value in actions.append(value); return ["sent": 1, "queued": 0, "failed": 0] } + model.receive([try snapshot(draftOption: nil, decisionClick: "send")]) + let item = try XCTUnwrap(model.inboxOverviewItems.first) + model.choose(item, option: "b") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(decisionActions(actions), ["ledger", "send-staged"], "the pick is written, then sent") + guard case .object(let send) = try XCTUnwrap(actions.last) else { return XCTFail("not an object") } + XCTAssertEqual(send["ids"], .array(["d1"])) + } + + func testAStagedDecisionPastTheListedEightIsStillSent() async throws { + let model = StagingFixture.model() + defer { model.stop() } + var actions: [WidgetJSON] = [] + model.actionRunner = { value in actions.append(value); return ["sent": 1, "queued": 0, "failed": 0] } + model.receive([try snapshot(draftOption: "a", stagedOnly: true)]) + XCTAssertTrue(model.inboxOverviewItems.isEmpty, "the session lists other items") + XCTAssertEqual(model.stagedItems.map(\.sourceId), ["d1"]) + model.sendStaged(["d1"]) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(decisionActions(actions), ["send-staged"]) + } + + func testASendWaitsForTheLedgerWriteBeforeIt() async throws { + let model = StagingFixture.model() + defer { model.stop() } + var events: [String] = [] + model.actionRunner = { value in + guard case .object(let fields) = value, case .string(let name) = fields["action"], + ["ledger", "send-staged"].contains(name) else { return ["ok": true] } + events.append(name + " start") + if name == "ledger" { try await Task.sleep(for: .milliseconds(100)) } + events.append(name + " end") + return ["sent": 1, "queued": 0, "failed": 0] + } + model.receive([try snapshot(draftOption: "b")]) + model.select(key) + model.choose(try XCTUnwrap(model.cards.first), option: "a") + model.sendStaged(["d1"]) + try await Task.sleep(for: .milliseconds(400)) + XCTAssertEqual(events, ["ledger start", "ledger end", "send-staged start", "send-staged end"]) + } + + func testSaveDraftClearsTheComposerOnlyOnceTheNoteIsSaved() async throws { + let model = StagingFixture.model() + defer { model.stop() } + var refuse = true + model.actionRunner = { value in + if refuse, case .object(let fields) = value, fields["action"] == .string("ledger") { + throw ToolsBridgeError.refused("revision changed") + } + return ["ok": true] + } + model.receive([try snapshot(draftOption: nil)]) + model.select(key) + let card = try XCTUnwrap(model.cards.first) + model.setText("only if it is cheap") + model.saveDraft(card) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.draft.text, "only if it is cheap", "a refused save keeps the note in the composer") + XCTAssertNil(model.draftSavedAt[card.id]) + + refuse = false + model.saveDraft(card) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.draft.text, "") + XCTAssertNotNil(model.draftSavedAt[card.id]) + } +} + +/// The widget panel is key while it is expanded (`EdgePanelController` makes it key on expand), so a click reaches +/// its controls at once. A hidden test window is never key, and becoming key would take the user's keyboard; without +/// this AppKit drops the first click on any SwiftUI control in it (a plain `Button` did not fire either). +private final class FirstClickHost: NSHostingView<AnyView> { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} + +/// The reply box is one click target (Martin asked for it on 2026-10-23): a click beside the one-line field, in the +/// box's padding, starts typing. Real mouse events at a probed frame, in a hidden window. +@MainActor +final class WidgetComposerClickTests: XCTestCase { + func testAClickInTheBoxBesideTheFieldStartsTyping() throws { + _ = NSApplication.shared + let model = StagingFixture.model() + defer { model.stop() } + model.actionRunner = { _ in ["ok": true] } + model.receive([try StagingFixture.snapshot(draftOption: nil)]) + model.select(StagingFixture.key) + FrameProbe.enabled = true + FrameProbe.reset() + defer { FrameProbe.enabled = false } + let size = NSSize(width: 520, height: 700) + let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = FirstClickHost(rootView: AnyView(LiveWidgetView(model: model, edge: .right, embedded: true) + .frame(width: size.width, height: size.height))) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { window.orderOut(nil); window.close() } + pump(1.0) + let box = try XCTUnwrap(FrameProbe.frames["widget.composer"], "the reply box has a frame") + let field = try XCTUnwrap(FrameProbe.frames["widget.composer.field"], "the reply field has a frame") + XCTAssertFalse(window.firstResponder is NSTextView, "nothing is being typed before the click") + // The box's left padding at the field's height: inside the box, outside the field and every button. + let point = NSPoint(x: box.minX + 4, y: size.height - field.midY) + XCTAssertLessThan(box.minX + 4, field.minX, "the click lands beside the field, not on it") + click(window, at: point) + XCTAssertTrue(window.firstResponder is NSTextView, "a click in the box's padding focuses the reply field") + } + + private func pump(_ seconds: TimeInterval) { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if let next = NSApp.nextEvent(matching: .any, until: deadline, inMode: .default, dequeue: true) { + NSApp.sendEvent(next) + } + } + } + + private func click(_ window: NSWindow, at location: NSPoint) { + func event(_ type: NSEvent.EventType) -> NSEvent { + NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: 1)! + } + NSApp.postEvent(event(.leftMouseDown), atStart: false) + NSApp.postEvent(event(.leftMouseUp), atStart: false) + pump(0.5) + } +} + +/// A rail bubble takes clicks whether or not its attention ring shows (Martin, 2026-10-23: "clicking the circle with the +/// count on the right does nothing when first click opened it - neither the hover shows correctly"). Its count sits +/// under `.allowsHitTesting(false)` and its only background was a fill at opacity 0 once the ring faded, which SwiftUI +/// does not hit-test, so opening the session (which ends the attention) left a bubble no click or hover could reach. +@MainActor +final class WidgetBubbleHitTests: XCTestCase { + final class Taps: ObservableObject { @Published var count = 0 } + struct Bubble: View { + @ObservedObject var taps: Taps + var body: some View { + Button { taps.count += 1 } label: { + Text("4").allowsHitTesting(false) + .frame(width: 38, height: 34) + .modifier(WidgetBubbleChrome(trigger: 0, attention: .borderFlash(color: .orange, cornerRadius: 17), + reduceMotion: true)) + } + .buttonStyle(.plain) + .frameProbe("bubble") + .frame(width: 120, height: 120) + } + } + + func testABubbleWithoutItsRingStillTakesAClick() throws { + _ = NSApplication.shared + FrameProbe.enabled = true + FrameProbe.reset() + defer { FrameProbe.enabled = false } + let taps = Taps() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 120, height: 120), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = FirstClickHost(rootView: AnyView(Bubble(taps: taps))) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { window.orderOut(nil); window.close() } + pump(0.6) + let frame = try XCTUnwrap(FrameProbe.frames["bubble"]) + // Off the count's glyph: the bubble's edge, where only the chrome is. + let point = NSPoint(x: frame.midX - 12, y: 120 - frame.midY) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + NSApp.postEvent(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)!, atStart: false) + } + pump(0.5) + XCTAssertEqual(taps.count, 1, "a click on a bubble with no ring opens its session") + } + + /// Two rail bubbles as the side rail stacks them: no spacing, half the gap as each one's hit padding. + struct RailPair: View { + @ObservedObject var upper: Taps + @ObservedObject var lower: Taps + var body: some View { + VStack(spacing: 0) { + bubble(upper) + bubble(lower) + } + .padding(.vertical, -WidgetSideStripMetrics.sessionSpacing / 2) + .frameProbe("rail") + .frame(width: 120, height: 120) + } + + private func bubble(_ taps: Taps) -> some View { + Button { taps.count += 1 } label: { + Text("4").allowsHitTesting(false) + .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) + .modifier(WidgetBubbleChrome(trigger: 0, attention: .borderFlash(color: .orange, cornerRadius: 17), + reduceMotion: true, hitPadding: WidgetSideStripMetrics.sessionHitPadding)) + } + .buttonStyle(.plain) + } + } + + /// The 6 pt strip between two rail bubbles was dead (ux catalogue R2): its upper half now opens the upper session, + /// its lower half the lower one. + func testTheGapBetweenTwoRailBubblesBelongsToThem() throws { + _ = NSApplication.shared + FrameProbe.enabled = true + FrameProbe.reset() + defer { FrameProbe.enabled = false } + let upper = Taps(), lower = Taps() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 120, height: 120), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = FirstClickHost(rootView: AnyView(RailPair(upper: upper, lower: lower))) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { window.orderOut(nil); window.close() } + pump(0.6) + let frame = try XCTUnwrap(FrameProbe.frames["rail"]) + XCTAssertEqual(frame.height, WidgetSideStripMetrics.sessionHeight * 2 + WidgetSideStripMetrics.sessionSpacing, + accuracy: 0.5, "the stack keeps the height the rail metrics count") + // The drawn gap is the middle 6 pt of the stack: 1 pt above its centre is the upper half, 1 pt below the lower. + func click(_ topY: CGFloat) { + let point = NSPoint(x: frame.midX, y: 120 - topY) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + NSApp.postEvent(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)!, atStart: false) + } + pump(0.4) + } + click(frame.midY - 1.5) + XCTAssertEqual(upper.count, 1, "the gap's upper half opens the upper bubble") + click(frame.midY + 1.5) + XCTAssertEqual(lower.count, 1, "the gap's lower half opens the lower bubble") + } + + /// Two rows on a glass panel with a gap between them, as the widget's hover list draws them. + struct GlassRows: View { + @ObservedObject var taps: Taps + var body: some View { + VStack(spacing: 24) { + RoundedRectangle(cornerRadius: 10).fill(.white.opacity(0.025)).frame(height: 40) + RoundedRectangle(cornerRadius: 10).fill(.white.opacity(0.025)).frame(height: 40) + } + .padding(16) + .frame(width: 200, height: 160, alignment: .top) + .nativeGlassSurface(in: RoundedRectangle(cornerRadius: 16), opaqueColor: Color(white: 0.09)) + .environment(\.nativeSettingsTheme, .glass) + .onTapGesture { taps.count += 1 } + } + } + + /// SwiftUI does not hit-test Liquid Glass itself, so a gap between rows on a glass panel was outside it: hover there + /// read as "the pointer left", and the widget's hover list closed under a scrolling pointer (Martin, 2026-10-24: + /// "it looks like the space between the session items is transparent for the focus"). The solid theme never had it. + func testTheGapBetweenRowsOnAGlassPanelIsPartOfThePanel() { + _ = NSApplication.shared + let taps = Taps() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 160), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.isOpaque = false + window.backgroundColor = .clear + window.contentView = FirstClickHost(rootView: AnyView(GlassRows(taps: taps))) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { window.orderOut(nil); window.close() } + pump(0.6) + // The gap: rows at 16...56 and 80...120 from the top, so 68 from the top is between them. + let point = NSPoint(x: 100, y: 160 - 68) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + NSApp.postEvent(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)!, atStart: false) + } + pump(0.5) + XCTAssertEqual(taps.count, 1, "a click between two rows of a glass panel lands on the panel") + } + + private func pump(_ seconds: TimeInterval) { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if let next = NSApp.nextEvent(matching: .any, until: deadline, inMode: .default, dequeue: true) { NSApp.sendEvent(next) } + } + } +} + +/// A click on another GenesisTools window keeps the panel open: the vault key window belongs to the app process, the +/// panel to the widget process (Martin, 2026-10-23: "allow with touch id does literally hide the widget"). +@MainActor +final class WidgetOwnWindowClickTests: XCTestCase { + func testAPointOverOneOfOurWindowsCountsAsOurs() async throws { + _ = NSApplication.shared + let screen = try XCTUnwrap(NSScreen.screens.first) + let frame = NSRect(x: screen.frame.minX + 40, y: screen.frame.minY + 40, width: 60, height: 60) + let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.alphaValue = 0.02 + window.ignoresMouseEvents = true + window.level = .popUpMenu + window.orderFrontRegardless() + defer { window.orderOut(nil); window.close() } + try await Task.sleep(for: .milliseconds(300)) + let point = NSPoint(x: frame.midX, y: frame.midY) + XCTAssertTrue(WidgetCoordinator.ownWindowOwnsPoint(point), "our window at the point") + window.orderOut(nil) + try await Task.sleep(for: .milliseconds(300)) + XCTAssertFalse(WidgetCoordinator.ownWindowOwnsPoint(point), "another app's window, or none, once ours is gone") + } +} + +/// Switching sessions shows cards at once (Martin, 2026-10-24: "why is it loading every time? can't it just be cached +/// and be prepared at all times"). A snapshot carries the sessions that need you beside the selected one (`warmKeys`), +/// and every session seen once stays cached until a snapshot names it again. +@MainActor +final class WidgetSessionSwitchTests: XCTestCase { + private func card(_ session: String, _ number: Int) -> WidgetCard { + WidgetCard(id: "decision:d_\(number)_\(session)", kind: "decision", sessionKey: key(session), + sourceId: "d_\(number)_\(session)", at: Double(number), title: "Decision \(number) in \(session)", + body: "", status: "open", revision: 1, number: number, choices: [], attachments: [], refs: [], + read: false) + } + + private func key(_ session: String) -> String { "local:codex:\(session):" } + + private func snapshot(selected: String, warm: [String], cards: [WidgetCard], + activity: [WidgetActivityEvent] = []) throws -> String { + let sessions: [[String: Any]] = ["alpha", "beta", "gamma"].map { name in + ["key": key(name), "target": ["hostId": "local", "provider": "codex", "sessionId": name, "sourceHome": "", + "cwd": "/fixture"], + "title": name, "project": "Fixture", "activityAt": 0, "status": "waiting", "pinned": true, + "visible": true, "hiddenByFilter": false] + } + let data = try JSONSerialization.data(withJSONObject: [ + "version": 1, + "state": ["version": 1, "revision": 0, "selectedKey": key(selected), + "preferences": ["excludedKeys": [], "projects": [], "sessions": [], "showChanges": false, + "placement": "both", "side": "right", "quietSeconds": 15, + "voiceProvider": "openai", "voiceLanguage": "en"], + "assets": [:], "drafts": [:], "outgoing": []], + "sessions": sessions, + "cards": try JSONSerialization.jsonObject(with: JSONEncoder().encode(cards)), + "activity": try JSONSerialization.jsonObject(with: JSONEncoder().encode(activity)), + "manifests": [:], "errors": [], "selectedKey": key(selected), "warmKeys": warm.map(key), + "notifications": ["unread": 0, "needsAnswer": 2, "complete": true, "truncated": false, "sessions": []]]) + return String(decoding: data, as: UTF8.self) + } + + private func model() throws -> (WidgetModel, URL) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("widget-switch-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent("native-submissions"), + withIntermediateDirectories: true) + let defaults = try XCTUnwrap(UserDefaults(suiteName: "widget.switch." + UUID().uuidString)) + let model = WidgetModel(binaryPath: "/fixture/no-process", stateRoot: root.path, defaults: defaults, + appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "switch." + UUID().uuidString, observeExternalChanges: false)) + return (model, root) + } + + func testAWarmOrSeenSessionOpensWithItsCardsAndAnUnseenOneLoads() throws { + let (model, root) = try model() + defer { model.stop(); try? FileManager.default.removeItem(at: root) } + let asked = WidgetActivityEvent(id: "e1", sourceId: "d_1_alpha", at: 1, title: "Asked", body: "") + model.receive([try snapshot(selected: "alpha", warm: ["beta"], + cards: [card("alpha", 1), card("beta", 2)], activity: [asked])]) + if model.selectedKey != key("alpha") { model.select(key("alpha")) } + XCTAssertEqual(model.cards.map(\.id), ["decision:d_1_alpha"]) + + model.select(key("beta")) + XCTAssertFalse(model.inboxLoading, "the warm session's cards came with the last snapshot") + XCTAssertEqual(model.cards.map(\.id), ["decision:d_2_beta"]) + XCTAssertEqual(model.sessionActivity, [], "the previous session's activity is not shown as this one's") + + model.select(key("gamma")) + XCTAssertTrue(model.inboxLoading, "a session no snapshot carried yet still says it loads") + XCTAssertEqual(model.cards, []) + + // The snapshot that names beta brings a new decision: it just appears. + model.receive([try snapshot(selected: "beta", warm: [], cards: [card("beta", 2), card("beta", 3)])]) + model.select(key("alpha")) + XCTAssertFalse(model.inboxLoading, "a session seen once opens from the cache") + XCTAssertEqual(model.cards.map(\.id), ["decision:d_1_alpha"]) + XCTAssertEqual(model.sessionActivity, [asked], "with the activity last seen for it") + model.select(key("beta")) + XCTAssertEqual(model.cards.map(\.id), ["decision:d_2_beta", "decision:d_3_beta"]) + } + + func testTheCacheKeepsTheSessionsRefreshedLast() throws { + var cache = WidgetSessionCache() + for index in 0..<(WidgetSessionCache.limit + 2) { + let name = "s\(index)" + let json = try snapshot(selected: name, warm: [], cards: [card(name, index)]) + cache.remember(try JSONDecoder().decode(WidgetSnapshot.self, from: Data(json.utf8))) + } + XCTAssertEqual(cache.count, WidgetSessionCache.limit) + XCTAssertNil(cache[key("s0")], "the session refreshed longest ago goes first") + XCTAssertEqual(cache[key("s\(WidgetSessionCache.limit + 1)")]?.cards.count, 1) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift index 01dfccb376..9e4e420e17 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift @@ -55,6 +55,54 @@ final class WidgetKeymapTests: XCTestCase { XCTAssertEqual(press(0x14, "š", answering: answering), .choose("ask")) } + /// Up, Down and Return move in the list on screen (ux catalogue R7), never while typing and never without a list. + func testArrowsAndReturnMoveInTheListOnScreen() { + func press(_ keyCode: UInt16, list: Bool, editingText: Bool = false) -> WidgetCoordinator.PanelKey { + WidgetCoordinator.panelKey(keyCode: keyCode, characters: "", context: .init( + previewOpen: false, agents: true, editingText: editingText, answering: nil, list: list), keymap: keys()) + } + XCTAssertEqual(press(126, list: true), .list(.up)) + XCTAssertEqual(press(125, list: true), .list(.down)) + XCTAssertEqual(press(36, list: true), .list(.open)) + XCTAssertEqual(press(125, list: false), .pass, "no list on screen: the scroll view keeps the arrow") + XCTAssertEqual(press(126, list: true, editingText: true), .pass, "the caret keeps the arrows in a field") + XCTAssertEqual(press(36, list: true, editingText: true), .pass, "Return in the composer is the composer's") + } + + /// With a decision card open, Up and Down still reach the list (which walks the card's options first), and the + /// digits still answer (judge round, R7). + func testArrowsWalkAnOpenDecisionsOptionsAndDigitsStillAnswer() { + let open = card() + let context = WidgetCoordinator.PanelKeyContext(previewOpen: false, agents: true, editingText: false, + answering: open, list: true) + func press(_ keyCode: UInt16, _ characters: String = "") -> WidgetCoordinator.PanelKey { + WidgetCoordinator.panelKey(keyCode: keyCode, characters: characters, context: context, keymap: keys()) + } + XCTAssertEqual(press(125), .list(.down), "Down goes to the model, which moves among the options") + XCTAssertEqual(press(126), .list(.up)) + XCTAssertEqual(press(0x13, "ě"), .choose("drop"), "the number row still answers option 2") + + // The model's step through three options: in from the card row, down to the last, then out of the card. + XCTAssertEqual(WidgetModel.optionMove(focused: nil, step: 1, count: 3), .option(0)) + XCTAssertEqual(WidgetModel.optionMove(focused: 0, step: 1, count: 3), .option(1)) + XCTAssertEqual(WidgetModel.optionMove(focused: 2, step: 1, count: 3), .leave, "past the last option: next row") + XCTAssertEqual(WidgetModel.optionMove(focused: 0, step: -1, count: 3), .cardRow, "up from the first: the card") + XCTAssertEqual(WidgetModel.optionMove(focused: nil, step: -1, count: 3), .leave, "up from the card: row above") + XCTAssertEqual(WidgetModel.optionMove(focused: nil, step: 1, count: 0), .leave, "no options: the rows move") + } + + /// ⌘H hides an app on every Mac; Open Hub moved to ⌘O, and J and K say what they move (R8, R9). + func testOpenHubLeavesCommandHToHideAndJAndKNameTheSession() throws { + let keys = Keymap(library: WidgetKeymap.library, store: nil, layout: TableKeyLayout.us) + let commandH = KeyStroke(keyCode: 4, character: "h", modifiers: .command) + XCTAssertEqual(keys.commands(for: commandH, in: [.app, .window]).map(\.id), []) + let commandO = KeyStroke(keyCode: 31, character: "o", modifiers: .command) + XCTAssertEqual(keys.commands(for: commandO, in: [.app]).map(\.id), [WidgetKeymap.hub]) + let titles = Dictionary(uniqueKeysWithValues: WidgetKeymap.library.commands.map { ($0.id, $0.title) }) + XCTAssertEqual(titles[WidgetKeymap.previous], "Previous Session") + XCTAssertEqual(titles[WidgetKeymap.next], "Next Session") + } + func testJAndKDoNothingWhileTyping() { XCTAssertEqual(press(38, "j", editingText: true), .pass) XCTAssertEqual(press(40, "k", editingText: true), .pass) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetPolishShotsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetPolishShotsTests.swift new file mode 100644 index 0000000000..ec4f1d48ae --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetPolishShotsTests.swift @@ -0,0 +1,99 @@ +import AppKit +import SwiftUI +import XCTest + +@testable import GenesisKit + +/// The widget's Panel background setting and the inbox header's sort label (Martin, 2026-10-25), checked as values and +/// rendered for the report with `WIDGET_POLISH_SHOTS=<folder>`. +@MainActor +final class WidgetPolishShotsTests: XCTestCase { + private let key = "local:claude:fixture-polish:" + + private func model(panelBackground: String?) throws -> WidgetModel { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "widget.polish." + UUID().uuidString)) + let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, + defaults: defaults, appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "polish." + UUID().uuidString, observeExternalChanges: false)) + var preferences: [String: Any] = ["excludedKeys": [], "projects": [], "sessions": [], "showChanges": false, + "placement": "both", "side": "right", "quietSeconds": 15, "voiceProvider": "openai", "voiceLanguage": "en", + "previewSort": "recent"] + if let panelBackground { preferences["panelBackground"] = panelBackground } + let sessions: [[String: Any]] = ["Kalendora hotfix research", "gt-native-ux", "gt-fixes"].enumerated().map { index, title in + ["key": key + "\(index)", "target": ["hostId": "local", "provider": "claude", "sessionId": "fixture-\(index)", + "sourceHome": "", "cwd": "/fixture"], "title": title, "project": "Fixture", + "activityAt": Date().timeIntervalSince1970 * 1000 - Double(index) * 60_000, + "status": index == 0 ? "working" : "idle", "pinned": false, "visible": true, "hiddenByFilter": false] + } + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "state": ["version": 1, "revision": 0, + "selectedKey": key + "0", "preferences": preferences, "assets": [:], "drafts": [:], "outgoing": []], + "sessions": sessions, "cards": [], "manifests": [:], "errors": [], "selectedKey": key + "0", + "notifications": ["unread": 0, "needsAnswer": 1, "complete": true, "truncated": false, + "sessions": [["key": key + "0", "unread": 0, "needsAnswer": 1, "pendingItem": Self.pending(key + "0"), + "items": [Self.pending(key + "0")]]]]]) + model.receive([String(decoding: data, as: UTF8.self)]) + return model + } + + static func pending(_ key: String) -> [String: Any] { + ["id": "decision:d10", "sourceId": "d10", "kind": "decision", "key": key, "at": Date().timeIntervalSince1970 * 1000, + "needsAnswer": true, "number": 10, "title": "P8 scope: where \"no automatic failover, show the error\" applies", + "options": ["a) only the fleet publisher", "b) every remote call", "c) the installer as well"], "recommended": "b", + "revision": 1] + } + + func testTheRecommendedOptionIsFoundByLetterOrText() { + XCTAssertTrue(WidgetPeekOptions.isRecommended("b) every remote call", "b")) + XCTAssertTrue(WidgetPeekOptions.isRecommended("keep", "keep")) + XCTAssertFalse(WidgetPeekOptions.isRecommended("a) only the fleet publisher", "b")) + XCTAssertFalse(WidgetPeekOptions.isRecommended("bigger", "b")) + } + + func testThePanelIsDarkUnlessGlassOrTheAppThemeIsPicked() throws { + XCTAssertEqual(try model(panelBackground: nil).panelTheme, .graphite, "an older snapshot reads as dark") + XCTAssertEqual(try model(panelBackground: "dark").panelTheme, .graphite) + XCTAssertEqual(try model(panelBackground: "glass").panelTheme, .glass) + let themed = try model(panelBackground: "theme") + XCTAssertEqual(themed.panelTheme, themed.appearance.theme) + } + + func testTheSortButtonNamesTheOrderInOneWord() { + XCTAssertEqual(WidgetPreviewChoices.shortTitle(sort: "attention"), "Waiting") + XCTAssertEqual(WidgetPreviewChoices.shortTitle(sort: "recent"), "Newest") + XCTAssertEqual(WidgetPreviewChoices.shortTitle(sort: "project"), "Project") + } + + func testRendersTheSettingAndTheHeader() throws { + guard let folder = ProcessInfo.processInfo.environment["WIDGET_POLISH_SHOTS"] else { return } + let model = try model(panelBackground: "dark") + defer { model.stop() } + let general = try XCTUnwrap(WidgetFeatureSettings.sections(model: model, modules: WidgetModuleChoice.builtins, + openSession: { _ in }).flatMap(\.pages).first { $0.id == "widgets.general" }) + let page = ScrollView { general.content().padding(24) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, .graphite) + .environment(\.colorScheme, .dark) + let pageShot = try KitShots.render(page, size: CGSize(width: 760, height: 1500), name: "widget-general", settle: 0.8) + try XCTUnwrap(pageShot.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: folder).appendingPathComponent("widget-general-settings.png")) + let preview = AgentWidgetPreview(model: model, surface: WidgetSurfaceID(edge: .right)).padding(10) + .frame(width: 420, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, model.panelTheme) + .environment(\.colorScheme, .dark) + let previewShot = try KitShots.render(preview, size: CGSize(width: 420, height: 320), name: "widget-preview", settle: 0.6) + try XCTUnwrap(previewShot.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: folder).appendingPathComponent("widget-inbox-sort-label.png")) + // The alert card at the size of Martin's screenshot (2026-10-25 18:47): the pane is as tall as the rail. + let item = try XCTUnwrap(model.inboxFor(key + "0")?.pendingItem) + let peek = WidgetPeekView(model: model, item: item).padding(WidgetPeekView.inset) + .frame(width: 310, height: 290, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, model.panelTheme) + .environment(\.colorScheme, .dark) + let peekShot = try KitShots.render(peek, size: CGSize(width: 310, height: 290), name: "widget-peek", settle: 0.6) + try XCTUnwrap(peekShot.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: folder).appendingPathComponent("widget-alert-card.png")) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetScrollReplayTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetScrollReplayTests.swift new file mode 100644 index 0000000000..9c8650bac1 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetScrollReplayTests.swift @@ -0,0 +1,218 @@ +import AppKit +import SwiftUI +import XCTest +@testable import GenesisKit + +/// Replays a recorded widget snapshot in the real `LiveWidgetView`, scrolls its list, and measures the idle main +/// thread afterwards. A panel that keeps re-laying itself out after the scroll stopped is the widget hang Martin hit +/// twice on 2026-10-23 ("totally hanged when i scrolled a bit through the inbox"). Local only: it needs a snapshot +/// from `tools hub widget snapshot --selected <key> --json`, so it skips unless these are set: +/// `GENESIS_WIDGET_REPLAY` (the snapshot file), `GENESIS_WIDGET_REPLAY_KEY` (the session key) and optionally +/// `GENESIS_WIDGET_REPLAY_OPEN` (the decision number whose card is open) and `GENESIS_WIDGET_REPLAY_OVERVIEW=1`. +@MainActor +final class WidgetScrollReplayTests: XCTestCase { + func testScrollingTheInboxLeavesTheMainThreadIdle() async throws { + let environment = ProcessInfo.processInfo.environment + guard let snapshotPath = environment["GENESIS_WIDGET_REPLAY"], let key = environment["GENESIS_WIDGET_REPLAY_KEY"] + else { throw XCTSkip("set GENESIS_WIDGET_REPLAY and GENESIS_WIDGET_REPLAY_KEY to replay a recorded snapshot") } + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("widget-replay-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + // The fake `tools` is made once and kept: macOS checks a new executable in syspolicyd after it starts, and a check + // that finds the file gone crashed it (2026-10-14). It reads the snapshot beside it; only that copy is replaced. + let fixtures = try XCTUnwrap(FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first) + .appendingPathComponent("GenesisKitTests/widget-replay-v1") + try FileManager.default.createDirectory(at: fixtures, withIntermediateDirectories: true) + let snapshot = fixtures.appendingPathComponent("snapshot.json") + try? FileManager.default.removeItem(at: snapshot) + try FileManager.default.copyItem(atPath: snapshotPath, toPath: snapshot.path) + defer { try? FileManager.default.removeItem(at: snapshot) } + let script = fixtures.appendingPathComponent("tools") + if !FileManager.default.isExecutableFile(atPath: script.path) { + try """ + #!/bin/sh + snapshot="$(dirname "$0")/snapshot.json" + case "$*" in + *watch*) cat "$snapshot"; printf '\\n'; cat > /dev/null; exit 0 ;; + *snapshot*) cat "$snapshot"; exit 0 ;; + *"voice listen"*) + text=""; i=0 + for word in Ship the composer waves. While I talk the words appear here; do + i=$((i + 1)); text="$text $word" + printf '{"kind":"level","rms":0.0%d}\\n' $(( (i * 37) % 8 + 2 )) + printf '{"kind":"partial","text":"%s"}\\n' "${text# }" + case "$word" in *.) printf '{"kind":"final","text":"%s"}\\n' "${text# }"; text="" ;; esac + sleep 0.05 + done + sleep 4; exit 0 ;; + esac + printf '{}' + """.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + } + let domain = "widget-replay." + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: domain)) + defer { UserDefaults(suiteName: domain)?.removePersistentDomain(forName: domain) } + let model = WidgetModel( + binaryPath: script.path, stateRoot: directory.path, defaults: defaults, + appearance: NativeSettingsAppearance(defaults: defaults, notificationNamespace: domain, observeExternalChanges: false)) + defer { model.stop() } + model.startSettings() + let deadline = ContinuousClock.now + .seconds(20) + while model.snapshot == nil && ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(100)) } + XCTAssertNotNil(model.snapshot, "the recorded snapshot loads") + model.select(key) + model.section = "Inbox" + // `GENESIS_WIDGET_REPLAY_OVERVIEW=1`: the inbox of every session (the inbox button), not the one session. + if environment["GENESIS_WIDGET_REPLAY_OVERVIEW"] == "1" { model.inboxOverview = true } + // `GENESIS_WIDGET_REPLAY_FILTER=decisions`: the inbox filter ("all", "inbox", "decisions"). + if let filter = environment["GENESIS_WIDGET_REPLAY_FILTER"] { model.inboxFilter = filter } + if let number = environment["GENESIS_WIDGET_REPLAY_OPEN"].flatMap(Int.init), + let card = model.cards.first(where: { $0.number == number }) { + model.openCard(card) + } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 914, height: 900), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .darkAqua) + window.backgroundColor = .black + window.contentView = NSHostingView(rootView: LiveWidgetView(model: model, edge: .right, embedded: true) + .frame(width: 914, height: 900).background(Color(white: 0.11)).environment(\.colorScheme, .dark)) + // `GENESIS_WIDGET_REPLAY_VISIBLE=1`: an almost transparent window above the others that ignores the mouse. A window + // below the desktop is occluded, and AppKit skips the display passes of an occluded window. + if environment["GENESIS_WIDGET_REPLAY_VISIBLE"] == "1" || environment["GENESIS_WIDGET_REPLAY_PNG"] != nil { + window.alphaValue = 0.02 + window.ignoresMouseEvents = true + window.level = .floating + window.setFrameOrigin(NSPoint(x: 0, y: 0)) + window.orderFrontRegardless() + } else { + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + } + defer { window.orderOut(nil); window.close() } + try await Task.sleep(for: .seconds(2)) + print("replay: window visible to the user: \(window.occlusionState.contains(.visible))") + // `GENESIS_WIDGET_REPLAY_PNG=<path>`: a picture of the panel (it shows the window at 0.02 alpha for the capture), with `GENESIS_WIDGET_REPLAY_VIEW` = "staged" (the + // staged list), "leave" (the question before closing) or "voice" (a dictation streaming words) set first. + if let png = environment["GENESIS_WIDGET_REPLAY_PNG"] { + switch environment["GENESIS_WIDGET_REPLAY_VIEW"] { + case "staged": model.stagedView = "session" + case "leave": model.stagedLeavePrompt = true + case "voice": model.startVoice(.dictate) + default: break + } + try await Task.sleep(for: .seconds(1)) + // The window server's picture of the window: cacheDisplay leaves out SwiftUI's materials and layer-drawn + // backgrounds, and the panel's white text came out on white. + let capture = Process() + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), png] + try capture.run() + capture.waitUntilExit() + XCTAssertEqual(capture.terminationStatus, 0, "screencapture of the replay window") + // The capture keeps the window's 0.02 alpha with the colours stored unassociated, so a viewer shows it + // almost white. Dropping the alpha channel gives the panel as it looks on screen. + let url = URL(fileURLWithPath: png) + let source = try XCTUnwrap(CGImageSourceCreateWithURL(url as CFURL, nil)) + let image = try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil)) + let skip: CGImageAlphaInfo = [.first, .premultipliedFirst].contains(image.alphaInfo) ? .noneSkipFirst : .noneSkipLast + let info = CGBitmapInfo(rawValue: image.bitmapInfo.rawValue & ~CGBitmapInfo.alphaInfoMask.rawValue | skip.rawValue) + let opaque = try XCTUnwrap(CGImage(width: image.width, height: image.height, + bitsPerComponent: image.bitsPerComponent, bitsPerPixel: image.bitsPerPixel, bytesPerRow: image.bytesPerRow, + space: try XCTUnwrap(image.colorSpace), bitmapInfo: info, provider: try XCTUnwrap(image.dataProvider), + decode: nil, shouldInterpolate: false, intent: .defaultIntent)) + let destination = try XCTUnwrap(CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil)) + CGImageDestinationAddImage(destination, opaque, nil) + XCTAssertTrue(CGImageDestinationFinalize(destination), "the opaque picture is written") + print("replay: picture \(png)") + return + } + let scrollView = try XCTUnwrap(Self.scrollViews(in: window.contentView!).max { + ($0.documentView?.frame.height ?? 0) < ($1.documentView?.frame.height ?? 0) + }, "the inbox list is a scroll view") + RenderProbe.enabled = true + defer { RenderProbe.enabled = false } + let quiet = try await measure(seconds: 2) + var offset: CGFloat = 0 + let document = scrollView.documentView?.frame.height ?? 0 + let viewport = scrollView.contentView.bounds.height + print("replay: document \(Int(document)) viewport \(Int(viewport)) cards \(model.cards.count)") + print("replay: before scroll \(quiet.text)") + var worst: Sample? + // Down to the end in small steps, then stops at several points near the end, where a lazy list's + // estimated height and the clamped offset can chase each other. + // `GENESIS_WIDGET_REPLAY_STOPS=630,1140`: only these offsets, for a quick check of one spot. + let only = environment["GENESIS_WIDGET_REPLAY_STOPS"]?.split(separator: ",").compactMap { Double($0) }.map { CGFloat($0) } + let stops = only ?? (Array(stride(from: CGFloat(0), through: document + 200, by: 30)) + [document - viewport - 40, + document - viewport - 5, document - viewport, document, document - viewport / 2]) + for (index, target) in stops.enumerated() { + let limit = max(0, (scrollView.documentView?.frame.height ?? document) - viewport) + offset = min(limit, max(0, target)) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + try await Task.sleep(for: .milliseconds(40)) + // Every stop is measured: the loop needs a tall open card right at the viewport top edge. + // `GENESIS_WIDGET_REPLAY_HOLD=<seconds>`: stay at each stop that long, to sample the process there. + let hold = environment["GENESIS_WIDGET_REPLAY_HOLD"].flatMap(Double.init) + // `GENESIS_WIDGET_REPLAY_SAMPLE=<folder>`: sample this process during each hold, one file per stop. + if let folder = environment["GENESIS_WIDGET_REPLAY_SAMPLE"], let hold { + let sampler = Process() + sampler.executableURL = URL(fileURLWithPath: "/usr/bin/sample") + sampler.arguments = [String(getpid()), String(max(1, Int(hold) - 1)), "1", "-mayDie", "-file", + folder + "/replay-stop-\(Int(offset)).sample.txt"] + sampler.standardOutput = FileHandle.nullDevice + sampler.standardError = FileHandle.nullDevice + try? sampler.run() + } + let sample = try await measure(seconds: hold ?? (index >= stops.count - 5 ? 1.5 : 0.5)) + if sample.loops > 60 || index >= stops.count - 5 { + print("replay: stop at \(Int(offset)) doc \(Int(scrollView.documentView?.frame.height ?? 0)) \(sample.text)") + } + if sample.cpu > (worst?.cpu ?? -1) { worst = sample } + } + let after = try XCTUnwrap(worst) + XCTAssertLessThan(after.loops, max(300, quiet.loops * 4), "the run loop goes quiet once the scroll stops") + XCTAssertLessThan(after.cpu, 0.25, "the idle main thread spends under a quarter second of 1.5 s on the panel") + } + + private struct Sample { + let loops: Int + let cpu: Double + let renders: [String: Int] + var text: String { + "loops=\(loops) cpu=\(String(format: "%.2f", cpu))s renders=" + + renders.sorted { $0.value > $1.value }.prefix(8).map { "\($0.key)=\($0.value)" }.joined(separator: " ") + } + } + + /// Run-loop passes, main-thread CPU and view bodies over `seconds` of doing nothing. + private func measure(seconds: Double) async throws -> Sample { + var loops = 0 + let observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, CFRunLoopActivity.beforeSources.rawValue, + true, 0) { _, _ in loops += 1 } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + defer { CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, .commonModes) } + let start = RenderProbe.snapshot() + let cpuStart = Self.threadCPU() + try await Task.sleep(for: .seconds(seconds)) + let now = RenderProbe.snapshot() + let renders = now.reduce(into: [String: Int]()) { result, entry in + let delta = entry.value - (start[entry.key] ?? 0) + if delta > 0 { result[entry.key] = delta } + } + return Sample(loops: loops, cpu: Self.threadCPU() - cpuStart, renders: renders) + } + + private static func threadCPU() -> Double { + var time = timespec() + clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time) + return Double(time.tv_sec) + Double(time.tv_nsec) / 1e9 + } + + private static func scrollViews(in view: NSView) -> [NSScrollView] { + ((view as? NSScrollView).map { [$0] } ?? []) + view.subviews.flatMap(scrollViews) + } +} diff --git a/src/macos/GenesisTools/Package.swift b/src/macos/GenesisTools/Package.swift index d9786e05e4..3217774ca9 100644 --- a/src/macos/GenesisTools/Package.swift +++ b/src/macos/GenesisTools/Package.swift @@ -73,7 +73,9 @@ let executable: Target = .executableTarget( // and `--bench` runs. The exceptions render into a window nobody sees (alpha 0, below the desktop, // never activated): LiveTimeTests and SessionTranscriptScrollTests, copied from Genesis, and // WindowTitlebarTests (clicks sent to the title bar strip). It always tests every part. -let testDependencies: [Target.Dependency] = [.target(name: "GenesisToolsHost")] +let testDependencies: [Target.Dependency] = [ + .target(name: "GenesisToolsHost"), .product(name: "GenesisKitTestHooks", package: "GenesisKit"), +] + allParts.map { Target.Dependency.target(name: $0.target) } let tests: Target = .testTarget(name: "GenesisToolsTests", dependencies: testDependencies, path: "Tests") diff --git a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift index d6a895d86b..9fe6dc3943 100644 --- a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift +++ b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift @@ -154,7 +154,8 @@ final class GenesisAppDelegate: NSObject, NSApplicationDelegate { let window = NSWindow(contentViewController: controller) window.title = "GenesisTools" window.styleMask = [.titled, .closable, .miniaturizable, .resizable] - window.setContentSize(NSSize(width: 860, height: 620)) + // 660, not 620: the line under the table about Full Disk Access and Accessibility (Q9) took the last row. + window.setContentSize(NSSize(width: 860, height: 660)) window.center() window.makeKeyAndOrderFront(nil) self.window = window @@ -230,6 +231,10 @@ struct RootView: View { services.refresh() settings.refresh() } + // A grant changed in System Settings shows as soon as the user comes back, without Refresh. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + permissions.refreshOnActivation() + } } private var tabs: some View { diff --git a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift index 301344b172..286baec18e 100644 --- a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift +++ b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift @@ -19,6 +19,11 @@ enum GrantAction: Equatable { case openPane(String) /// touch the resource once so macOS asks (folders, Automation) case probe + + var opensSettings: Bool { + if case .openPane = self { return true } + return false + } } struct PermissionRow: Identifiable, Equatable { @@ -45,11 +50,17 @@ struct PermissionRow: Identifiable, Equatable { /// command needs each grant and remembers probe answers, which macOS has no status for. final class PermissionsModel: ObservableObject { @Published private(set) var rows: [PermissionRow] = [] + /// The id of the row whose request waits for macOS. @Published private(set) var busy: String? @Published private(set) var lastMessage: String? private let access: PermissionAccess private var probeResults: [PermissionKind: PermissionStatus] = [:] + /// The grant `lastMessage` is about: once a refresh finds it no longer refused, the message goes. + private var messageKind: PermissionKind? + private var rechecking = false + /// When the answered probes last ran again; an activation reruns them at most every 10 s. + private var lastProbeRecheck: Date? private static let usedBy: [(PermissionKind, String)] = [ (.calendars, "tools macos calendar, tools todo sync"), @@ -72,10 +83,51 @@ final class PermissionsModel: ObservableObject { self.access = access } + /// The title of the row whose request waits for macOS. + var busyTitle: String? { + busy.map { id in rows.first { $0.id == id }?.title ?? id } + } + + /// Reads every grant again, and runs each answered probe again: a probe grant has no silent read, so without it a + /// grant changed in System Settings kept its old answer. macOS answered those probes before, so none prompts. func refresh() { + rebuildRows() + recheckProbes() + } + + /// The app became active (the user may be back from System Settings). The rows are read again every time; the + /// answered probes run again at most every `PermissionAccess.activationRecheckInterval`, as the permission center + /// does, since activating is frequent and an Automation probe runs AppleScript. The Refresh button is not limited. + func refreshOnActivation(now: Date = Date()) { + rebuildRows() + guard PermissionAccess.activationRecheckDue(last: lastProbeRecheck, now: now) else { return } + recheckProbes(now: now) + } + + private func rebuildRows() { rows = Self.usedBy.map { kind, usedBy in PermissionRow(kind: kind, usedBy: usedBy, state: probeResults[kind] ?? access.status(kind)) } + if let kind = messageKind, rows.first(where: { $0.kind == kind })?.state != .denied { + lastMessage = nil + messageKind = nil + } + } + + private func recheckProbes(now: Date = Date()) { + // Only the probes answered in this window. A grant another feature got is rechecked app-wide on activation + // (`PermissionCenter.recheckGrantedProbesOnActivation`), and the rows read that remembered answer. + let kinds = PermissionKind.allCases.filter { probeResults[$0] != nil } + guard !kinds.isEmpty, !rechecking, busy == nil else { return } + rechecking = true + lastProbeRecheck = now + Task { @MainActor in + for kind in kinds { + probeResults[kind] = await access.recheck(kind) + } + rechecking = false + rebuildRows() + } } // MARK: - Actions @@ -83,16 +135,20 @@ final class PermissionsModel: ObservableObject { func request(_ row: PermissionRow) { busy = row.id lastMessage = nil + messageKind = nil let kind = row.kind Task { @MainActor in let result = await access.request(kind) if kind.requestStyle == .probe { probeResults[kind] = result - if result == .denied { lastMessage = "\(row.title): macOS refused. Allow it in System Settings." } + if result == .denied { + lastMessage = "\(row.title): macOS refused. Allow it in System Settings." + messageKind = kind + } } busy = nil - refresh() + rebuildRows() } } diff --git a/src/macos/GenesisTools/Sources/Host/App/Views.swift b/src/macos/GenesisTools/Sources/Host/App/Views.swift index e6ae6b6d07..ae68dcc9fd 100644 --- a/src/macos/GenesisTools/Sources/Host/App/Views.swift +++ b/src/macos/GenesisTools/Sources/Host/App/Views.swift @@ -31,42 +31,56 @@ struct PermissionsView: View { TableColumn("") { row in HStack { actionButton(row) - Button("Settings…") { model.openPane(row.pane) } + // A row whose action already opens System Settings needs no second button for it. + if !(row.action.opensSettings && row.state != .granted) { + Button("Settings…") { model.openPane(row.pane) } + .help("Opens this permission's list in System Settings.") + } } } .width(min: 220) } + Text("Full Disk Access and Accessibility are turned on in System Settings, not in a prompt: open the list, then drag \(appName) into it.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { - if let busy = model.busy { + if let busy = model.busyTitle { ProgressView().controlSize(.small) - Text("Waiting for macOS (\(busy))…").font(.caption) + Text("Asking macOS for \(busy)…").font(.system(size: 11)) } if let message = model.lastMessage { - Text(message).font(.caption).foregroundStyle(.orange) + Text(message).font(.system(size: 11)).foregroundStyle(.orange) } Spacer() Button("Reveal app in Finder") { model.revealApp() } - .help("Full Disk Access and Accessibility have no prompt: drag the app into the list.") + .help("Shows \(appName) in Finder, ready to drag into a System Settings list.") } } } + private var appName: String { PermissionKind.appName } + @ViewBuilder private func actionButton(_ row: PermissionRow) -> some View { switch row.action { case .prompt: - Button(row.state == .granted ? "Granted" : "Request") { model.request(row) } + Button(row.state == .granted ? "Granted" : "Ask macOS") { model.request(row) } .disabled(row.state == .granted || model.busy != nil) + .help("Shows the macOS prompt for this permission.") case .openPane(let pane): - Button(row.state == .granted ? "Granted" : "Open list & reveal") { + Button(row.state == .granted ? "Granted" : "Open System Settings") { model.openPane(pane) model.revealApp() } .disabled(row.state == .granted) + .help("Opens this list in System Settings and shows \(appName) in Finder, ready to drag into it.") case .probe: - Button(row.state == .granted ? "Granted" : "Test (asks)") { model.request(row) } + Button(row.state == .granted ? "Granted" : "Ask macOS") { model.request(row) } .disabled(row.state == .granted || model.busy != nil) + .help("Uses it once, so macOS shows its prompt.") } } } @@ -115,6 +129,9 @@ struct ServicesView: View { struct SettingsView: View { @ObservedObject var model: SettingsModel @AppStorage(AppMainWindow.key) private var mainWindow = AppMainWindow.permissions + @AppStorage(PermissionSettingsGuide.enabledKey) private var settingsGuide = true + @AppStorage(ApprovalAttention.soundKey) private var approvalSound = ApprovalAttention.defaultSound + @AppStorage(ApprovalAttention.notifyAfterKey) private var approvalNotifyAfter = ApprovalAttention.defaultNotifyAfter var body: some View { Form { @@ -129,6 +146,34 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Privacy panes") { + Toggle("Show a drag helper over System Settings", isOn: $settingsGuide) + Text("When GenesisTools opens a Privacy pane, its icon floats at the bottom of the list. Drag it into Accessibility, Input Monitoring, Screen Recording or Full Disk Access; other lists only need its switch turned on.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Approval requests") { + HStack { + Picker("Sound when a request appears", selection: $approvalSound) { + Text("None").tag("none") + ForEach(ApprovalAttention.sounds, id: \.self) { name in + Text(name).tag(name) + } + } + Button("Play") { ApprovalAttention.play(approvalSound) } + .disabled(approvalSound == "none") + } + Picker("Notification while it waits", selection: $approvalNotifyAfter) { + ForEach(ApprovalAttention.notifyAfterChoices) { choice in + Text(choice.title).tag(choice.seconds) + } + } + Text("A program that asks for the vault key or an AI account opens a request window. The window never takes the keyboard by itself, so this sound and notification tell you it is waiting.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Section("Launcher") { Toggle("Route `tools` through this app (owns the privacy grants)", isOn: $model.launcherEnabled) Text(model.launcherEnabled diff --git a/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift index 74254af4ad..ea3e105105 100644 --- a/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift +++ b/src/macos/GenesisTools/Sources/Host/App/WindowCycle.swift @@ -183,8 +183,14 @@ package enum WindowCycle { /// Cmd+Tab activates on the release of ⌘, so a modifier change is the newest input. A click (the Dock, /// a window, a notification, a link in a browser) or a typed key (a launcher, a terminal) is newer otherwise. - package static func cameByCommandTab(sinceFlags: Double, sinceMouse: Double, sinceKey: Double) -> Bool { - sinceFlags < 1 && sinceFlags < sinceMouse && sinceFlags < sinceKey + /// + /// The activation can also arrive while ⌘ is still down, right after the Tab press: logged 2026-10-25 15:13 as + /// "flags 1.65s, mouse 8.92s, key 0.29s ago", and the window stayed behind. A typed key with ⌘ held is that case; + /// a launcher's typed key has no ⌘ held. + package static func cameByCommandTab(sinceFlags: Double, sinceMouse: Double, sinceKey: Double, + commandHeld: Bool = false) -> Bool { + if sinceFlags < 1 && sinceFlags < sinceMouse && sinceFlags < sinceKey { return true } + return commandHeld && sinceKey < 1 && sinceKey < sinceMouse } private static func handle(_ event: NSEvent) -> Bool { @@ -310,13 +316,29 @@ package enum WindowCycle { /// The tile owner came to the front. On Cmd+Tab back, show the window that was key when GenesisTools left. private static func becameActive() { - guard let left = leftFrom else { return } + guard NSApp.activationPolicy() == .regular else { return } + guard let left = leftFrom else { + HubPerf.log("windowCycle: active, no window list from leaving (another face took focus since)") + return + } leftFrom = nil - guard NSApp.activationPolicy() == .regular, Date().timeIntervalSince(lastCycleRaise) > 1.5 else { return } + guard Date().timeIntervalSince(lastCycleRaise) > 1.5 else { return } let since = { (type: CGEventType) in CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: type) } let mouse = min(since(.leftMouseDown), since(.rightMouseDown), since(.otherMouseDown)) - guard cameByCommandTab(sinceFlags: since(.flagsChanged), sinceMouse: mouse, sinceKey: since(.keyDown)), - let target = restoreTarget(left, candidates: currentCandidates(), own: getpid()) else { return } + let commandHeld = CGEventSource.flagsState(.combinedSessionState).contains(.maskCommand) + guard cameByCommandTab(sinceFlags: since(.flagsChanged), sinceMouse: mouse, sinceKey: since(.keyDown), + commandHeld: commandHeld) else { + HubPerf.log(String(format: "windowCycle: active, not by Cmd+Tab (flags %.2fs, mouse %.2fs, key %.2fs ago, ⌘ %@)", + since(.flagsChanged), mouse, since(.keyDown), commandHeld ? "held" : "up")) + return + } + let candidates = currentCandidates() + guard let target = restoreTarget(left, candidates: candidates, own: getpid()) else { + let first = left.first { item in candidates.contains { $0.number == item.number } } + HubPerf.log("windowCycle: Cmd+Tab back, nothing to hand off (left \(left.count) windows, first on screen " + + (first.map { "\($0.number) of pid \($0.pid)" } ?? "none") + ")") + return + } HubPerf.log("windowCycle: Cmd+Tab back, showing window \(target.number) of pid \(target.pid)") handOff(to: target, session: nil) } diff --git a/src/macos/GenesisTools/Sources/Host/HubLink.swift b/src/macos/GenesisTools/Sources/Host/HubLink.swift index 7f844d0967..d07acb70d8 100644 --- a/src/macos/GenesisTools/Sources/Host/HubLink.swift +++ b/src/macos/GenesisTools/Sources/Host/HubLink.swift @@ -7,6 +7,8 @@ package enum HubLink { /// show, not a file to write or a command to run. package static let linkKeys: Set<String> = [ "mode", "session", "agent", "pr", "reveal", "commit", "repo", "tab", "filter", "worktree", "decision", "question", + // Who asked and why, for the hub's history (`tools hub nav open`): never a command, only words on an entry. + "actor", "note", "from", "nav", ] /// `genesis-tools://hub?session=<parent>&agent=<child>` (any of `linkKeys`) as `--hub` arguments, the diff --git a/src/macos/GenesisTools/Sources/Host/Notify.swift b/src/macos/GenesisTools/Sources/Host/Notify.swift index 8899afb680..fcf665df9c 100644 --- a/src/macos/GenesisTools/Sources/Host/Notify.swift +++ b/src/macos/GenesisTools/Sources/Host/Notify.swift @@ -17,6 +17,7 @@ import AppKit import Foundation +import GenesisKitBase import UserNotifications // MARK: - Request shapes @@ -130,6 +131,8 @@ func emitError(code: String, message: String, exitCode: Int32) -> Never { } private func emit(_ payload: [String: Any]) { + // Every RPC exits right after this; the notification ledger writes on its own queue. + NotificationLedger.flush() guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), let line = String(data: data, encoding: .utf8) @@ -155,25 +158,10 @@ private func writeReply(_ text: String) { /// `sound` defaults to the system default, matching the DarwinKit and osascript backends this /// replaces. terminal-notifier stays silent unless asked, so a caller that relied on that gets a /// sound it did not before; that is the one deliberate behaviour change here. -func notificationContent(_ params: NotifyPostParams, identifier: String) -> UNMutableNotificationContent { - let content = UNMutableNotificationContent() - content.title = params.title ?? "GenesisTools" - content.body = params.message - - if let subtitle = params.subtitle, !subtitle.isEmpty { - content.subtitle = subtitle - } - - if let sound = params.sound, !sound.isEmpty, sound.lowercased() != "default" { - content.sound = UNNotificationSound(named: UNNotificationSoundName(sound)) - } else { - content.sound = .default - } - - if let group = params.group, !group.isEmpty { - content.threadIdentifier = group - } - +/// The banner as GenesisKit's `NotificationEngine` posts it. The engine registers the buttons as a +/// category named after the id, in a union with every category already registered, and writes the +/// notification ledger line (`source` is the poster's group, as the TypeScript side names it). +func engineNotification(_ params: NotifyPostParams, identifier: String) -> EngineNotification { // Keyed by the action that should run it. The body click is "default"; a button click uses the // button's own id. Reading it back needs no knowledge of the category that rendered the buttons. var routes: [String: [String: String]] = [:] @@ -193,30 +181,32 @@ func notificationContent(_ params: NotifyPostParams, identifier: String) -> UNMu userInfo["routes"] = filteredRoutes } - content.userInfo = userInfo - - // Best effort only. Punching through a Focus mode needs the time-sensitive entitlement, which a - // Developer ID signature cannot carry, so this degrades to a normal banner rather than failing. - if params.ignoreDnD == true { - content.interruptionLevel = .timeSensitive + let sound: UNNotificationSound = if let name = params.sound, !name.isEmpty, name.lowercased() != "default" { + UNNotificationSound(named: UNNotificationSoundName(name)) + } else { + .default } // appIcon first, so it is the thumbnail on the banner. UNUserNotificationCenter has no way to // replace the app icon itself (terminal-notifier's -appIcon meaning), so the closest honest // mapping is to show the image as the notification's own artwork. let files = [params.appIcon].compactMap { $0 } + (params.attachments ?? []) - content.attachments = files.enumerated().compactMap { index, path in - buildAttachment(path: path, index: index) - } - - if let actions = params.actions, !actions.isEmpty { - // Category registration happens in `post()`, as a union with whatever is already - // registered. `setNotificationCategories` replaces the entire set, so doing it here - // with `[one]` would wipe every other notification's buttons. - content.categoryIdentifier = identifier - } - return content + return EngineNotification( + source: params.group.flatMap { $0.isEmpty ? nil : $0 } ?? "tools", + title: params.title ?? "GenesisTools", + subtitle: params.subtitle ?? "", + body: params.message, + identifier: identifier, + threadId: params.group.flatMap { $0.isEmpty ? nil : $0 }, + userInfo: userInfo, + sound: sound, + // Best effort only. Punching through a Focus mode needs the time-sensitive entitlement, which a + // Developer ID signature cannot carry, so this degrades to a normal banner rather than failing. + interruption: params.ignoreDnD == true ? .timeSensitive : nil, + attachments: files.enumerated().compactMap { index, path in buildAttachment(path: path, index: index) }, + actions: (params.actions ?? []).map(buildAction) + ) } private func route(open: String?, execute: String?) -> [String: String] { @@ -562,41 +552,25 @@ private func post(_ params: NotifyPostParams) { exitCode: 77 ) default: - let request = UNNotificationRequest( - identifier: identifier, - content: notificationContent(params, identifier: identifier), - trigger: nil - ) - let add = { - center.add(request) { addError in - if let addError { - emitError( - code: "internal", - message: "could not post: \(addError.localizedDescription)", - exitCode: 70 - ) - } - - emitResult(["id": identifier]) + let result = NotificationEngine.shared.post(engineNotification(params, identifier: identifier)) { addError in + if let addError { + emitError( + code: "internal", + message: "could not post: \(addError.localizedDescription)", + exitCode: 70 + ) } - } - guard let actions = params.actions, !actions.isEmpty else { - add() - return + emitResult(["id": identifier]) } - let category = UNNotificationCategory( - identifier: identifier, - actions: actions.map(buildAction), - intentIdentifiers: [], - options: [] - ) - center.getNotificationCategories { existing in - var next = existing.filter { $0.identifier != identifier } - next.insert(category) - center.setNotificationCategories(next) - add() + switch result { + case .posted: + break + case .suppressed(let reason): + emitResult(["id": identifier, "suppressed": reason]) + case .unavailable: + emitError(code: "internal", message: "no notification center in this process", exitCode: 70) } } } diff --git a/src/macos/GenesisTools/Sources/Host/RouteApproval.swift b/src/macos/GenesisTools/Sources/Host/RouteApproval.swift index 8dbe01c827..13d5a98702 100644 --- a/src/macos/GenesisTools/Sources/Host/RouteApproval.swift +++ b/src/macos/GenesisTools/Sources/Host/RouteApproval.swift @@ -2,7 +2,8 @@ import AppKit import Foundation /// The Allow / Deny card for a route that runs a command. Same look as RouteToast: dark card, -/// gold hairline, the Genesis Tools mark. Return allows, Esc denies. +/// gold hairline, the GenesisTools mark. Return does nothing here: the card takes the keyboard while +/// the user may still be typing elsewhere. A click allows, or Space after Tab with Full Keyboard Access. Esc denies. enum RouteApproval { /// The "Running" toast is already up (statusBar level, same spot) and would hide this card, /// so it steps aside while the question is open and only comes back when the run is allowed. @@ -30,7 +31,8 @@ private enum Palette { static let gold = NSColor(srgbRed: 1, green: 0.62, blue: 0.12, alpha: 1) static let brand = NSColor(srgbRed: 0.96, green: 0.78, blue: 0.45, alpha: 0.9) static let text = NSColor(white: 0.96, alpha: 1) - static let dim = NSColor(white: 0.55, alpha: 1) + /// 62% white: text the user must read keeps 4.5:1 on the card. + static let dim = NSColor(white: 0.62, alpha: 1) static let flag = NSColor(srgbRed: 0.96, green: 0.78, blue: 0.45, alpha: 0.85) static let well = NSColor(white: 1, alpha: 0.05) static let wellBorder = NSColor(white: 1, alpha: 0.08) @@ -45,6 +47,7 @@ final class RouteApprovalCard: NSPanel { init(argv: [String], open: String?) { let body = ApprovalBody(argv: argv, open: open) let size = body.fittingSize + let headline = ApprovalBody.headline(argv) super.init( contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height), styleMask: [.borderless], @@ -59,6 +62,10 @@ final class RouteApprovalCard: NSPanel { hasShadow = true hidesOnDeactivate = false contentView = body + // VoiceOver reads a dialog named by what it asks, not an unnamed borderless window. + title = "Approval needed: \(headline)" + setAccessibilitySubrole(.dialog) + setAccessibilityLabel("Approval needed: \(headline)") body.onAnswer = { [weak self] answer in self?.finish(answer) } @@ -67,12 +74,14 @@ final class RouteApprovalCard: NSPanel { func runModal() -> Bool { let resting = frame.origin + // Under Reduce Motion the card fades in where it rests; otherwise it rises the last 14 pt. + let rise: CGFloat = GenMotion.systemReduceMotion ? 0 : 14 alphaValue = 0 - setFrameOrigin(NSPoint(x: resting.x, y: resting.y - 14)) + setFrameOrigin(NSPoint(x: resting.x, y: resting.y - rise)) NSApp.activate(ignoringOtherApps: true) makeKeyAndOrderFront(nil) orderFrontRegardless() - fadeIn(to: resting) + fadeIn(to: resting, rise: rise) NSApp.runModal(for: self) orderOut(nil) return allowed @@ -80,10 +89,11 @@ final class RouteApprovalCard: NSPanel { /// Stepped by a timer in `.common` modes, not `animator()`: the card is modal, and an animation /// that does not tick in the modal run loop would leave an invisible card waiting for a click. - /// The timer stops itself after `duration`. - private func fadeIn(to resting: NSPoint) { + /// The timer stops itself after `duration`. Nothing waits on it: the buttons answer from the + /// first frame. + private func fadeIn(to resting: NSPoint, rise: CGFloat) { let start = Date() - let duration = 0.28 + let duration = rise == 0 ? 0.15 : GenMotion.expandDuration let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] timer in guard let self else { timer.invalidate() @@ -93,7 +103,7 @@ final class RouteApprovalCard: NSPanel { let progress = min(1, Date().timeIntervalSince(start) / duration) let eased = CGFloat(1 - pow(1 - progress, 3)) alphaValue = eased - setFrameOrigin(NSPoint(x: resting.x, y: resting.y - 14 * (1 - eased))) + setFrameOrigin(NSPoint(x: resting.x, y: resting.y - rise * (1 - eased))) if progress >= 1 { timer.invalidate() } @@ -145,19 +155,20 @@ private final class ApprovalBody: NSView { header.orientation = .horizontal header.alignment = .top - let kicker = label("APPROVAL NEEDED", size: 12, weight: .semibold, color: Palette.gold) + let kicker = label("Approval needed", size: 12, weight: .semibold, color: Palette.gold) let headline = label(Self.headline(argv), size: 24, weight: .semibold, color: Palette.text) var rows: [NSView] = [header, kicker, headline, commandWell(argv)] if let program = argv.first, program.contains("/") { - rows.append(caption("PROGRAM", Self.shorten(program))) + rows.append(caption("Program", Self.shorten(program))) } if let open, !open.isEmpty { - rows.append(caption("THEN OPENS", open)) + rows.append(caption("Then opens", open)) } - rows.append(label("Runs once, without a shell. Nothing happens if you deny.", size: 11, weight: .regular, color: Palette.dim)) + rows.append(label("Runs once, without a shell. Nothing happens if you deny. Return does not allow it; click Allow.", + size: 11, weight: .regular, color: Palette.dim)) rows.append(buttonRow()) let stack = NSStackView(views: rows) @@ -201,7 +212,7 @@ private final class ApprovalBody: NSView { dot.heightAnchor.constraint(equalToConstant: 8), ]) - let brand = label("GENESIS TOOLS", size: 11, weight: .semibold, color: Palette.brand) + let brand = label("GenesisTools", size: 11, weight: .semibold, color: Palette.brand) let row = NSStackView(views: [dot, brand]) row.orientation = .horizontal row.alignment = .centerY @@ -238,7 +249,7 @@ private final class ApprovalBody: NSView { } private func caption(_ title: String, _ value: String) -> NSView { - let name = label(title, size: 10, weight: .semibold, color: Palette.dim) + let name = label(title, size: 11, weight: .semibold, color: Palette.dim) name.translatesAutoresizingMaskIntoConstraints = false name.widthAnchor.constraint(equalToConstant: 78).isActive = true @@ -262,11 +273,16 @@ private final class ApprovalBody: NSView { deny.keyEquivalent = "\u{1b}" deny.target = self deny.action = #selector(denyClicked) + deny.toolTip = "Nothing runs (Esc)" + deny.setAccessibilityHelp("Esc denies too.") - let allow = ApprovalButton(title: "Allow", hint: "↩", primary: true) - allow.keyEquivalent = "\r" + // No key equivalent on purpose: the card takes the keyboard when it appears, and a Return meant + // for another app must not run a command. Full Keyboard Access still reaches it with Tab and Space. + let allow = ApprovalButton(title: "Allow", hint: nil, primary: true) allow.target = self allow.action = #selector(allowClicked) + allow.toolTip = "Runs the command once. Return does not press it, so a key pressed while you type cannot." + allow.setAccessibilityHelp("Runs the command once.") let row = NSStackView(views: [NSView(), deny, allow]) row.orientation = .horizontal @@ -299,7 +315,7 @@ private final class ApprovalBody: NSView { /// `Run tools say` for `[.../tools, say, ...]`; the program alone when the next word is a flag. /// A script behind a runner (`bun .../rohlik.ts add`) is named by the script, not the runner. - private static func headline(_ argv: [String]) -> String { + static func headline(_ argv: [String]) -> String { guard let program = argv.first else { return "Run a command" } var name = (program as NSString).lastPathComponent var rest = Array(argv.dropFirst()) @@ -367,7 +383,9 @@ private final class ApprovalBody: NSView { } private final class ApprovalButton: NSButton { - init(title: String, hint: String, primary: Bool) { + /// `hint` is the key drawn after the title; it is left out of the accessibility label, so VoiceOver + /// reads "Deny", not "Deny esc". + init(title: String, hint: String?, primary: Bool) { super.init(frame: .zero) isBordered = false wantsLayer = true @@ -382,11 +400,14 @@ private final class ApprovalButton: NSButton { .font: NSFont.systemFont(ofSize: 13, weight: .semibold), .foregroundColor: ink, ]) - text.append(NSAttributedString(string: " \(hint)", attributes: [ - .font: NSFont.systemFont(ofSize: 11, weight: .medium), - .foregroundColor: hintInk, - ])) + if let hint { + text.append(NSAttributedString(string: " \(hint)", attributes: [ + .font: NSFont.systemFont(ofSize: 11, weight: .medium), + .foregroundColor: hintInk, + ])) + } attributedTitle = text + setAccessibilityLabel(title) translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ diff --git a/src/macos/GenesisTools/Sources/Host/VaultUnlock.swift b/src/macos/GenesisTools/Sources/Host/VaultUnlock.swift index aec54cdc97..3bd9c1f3e1 100644 --- a/src/macos/GenesisTools/Sources/Host/VaultUnlock.swift +++ b/src/macos/GenesisTools/Sources/Host/VaultUnlock.swift @@ -501,6 +501,8 @@ enum VaultWindowLock { struct VaultWindowContent { var title: String var purpose: String + /// The command line, when the purpose is only "run <it>": then it shows as a code block, not inside a sentence. + var command: String? = nil var facts: [ApprovalFact] var details: [ApprovalFact] var warning: String? @@ -584,9 +586,13 @@ struct VaultWindowContent { } let injected = caller.injected + let asked = GateCredentialRedactor.text(params.purpose).trimmingCharacters(in: .whitespacesAndNewlines) + let line = GateCredentialRedactor.text(params.command ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let onlyRuns = !line.isEmpty && asked.lowercased() == "run " + line.lowercased() return VaultWindowContent( title: "“\(tool)” wants the vault key", - purpose: sentence(GateCredentialRedactor.text(params.purpose)), + purpose: onlyRuns ? "To run this command:" : sentence(asked), + command: onlyRuns ? line : nil, facts: facts, details: details, warning: injected.isEmpty ? nil : "Extra code may be loaded (\(injected.joined(separator: ", "))). It can only be allowed once.", @@ -774,8 +780,8 @@ func unlockVault(_ params: VaultUnlockParams) { DispatchQueue.main.async { let model = ApprovalDialogModel( - title: content.title, purpose: content.purpose, symbol: "key.fill", facts: content.facts, details: content.details, - warning: content.warning, footnote: VaultWindowContent.footnote, + title: content.title, purpose: content.purpose, command: content.command, symbol: "key.fill", facts: content.facts, + details: content.details, warning: content.warning, footnote: VaultWindowContent.footnote, rememberChoices: rememberable ? VaultWindowContent.rememberChoices : [], rememberNote: content.onceOnlyReason.map { "Only once: \($0)" }, allowTitle: biometric ? "Allow with Touch ID" : "Allow…", diff --git a/src/macos/GenesisTools/Sources/Parts/Clicky/ClickyHost.swift b/src/macos/GenesisTools/Sources/Parts/Clicky/ClickyHost.swift index e86bc1c706..2ce6f78990 100644 --- a/src/macos/GenesisTools/Sources/Parts/Clicky/ClickyHost.swift +++ b/src/macos/GenesisTools/Sources/Parts/Clicky/ClickyHost.swift @@ -53,11 +53,12 @@ final class ClickyHost: NSObject { guard let button = item?.button else { return } let popover = NSPopover() popover.behavior = .transient - popover.contentViewController = NSHostingController( + let controller = NSHostingController( rootView: ClickyPopoverView( model: model, + // Clicky's own first page: with no page the window reopened wherever it was last (Widgets → Providers). showSettings: { [weak self] in - self?.showSettings() + self?.showSettings(pageID: ClickySettingsPages.pageID(.sound)) }, quit: { [weak self] in guard let self else { return } @@ -65,6 +66,11 @@ final class ClickyHost: NSObject { self.stop() if shouldQuit { NSApp.terminate(nil) } })) + // Sized before it shows: a popover that only learns its height after `show` grew upward past the top of the + // display, over the menu bar, so its first 200 pt could not be seen (Martin, 2026-10-25). + controller.sizingOptions = [.preferredContentSize] + popover.contentSize = controller.view.fittingSize + popover.contentViewController = controller self.popover = popover popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) } diff --git a/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift b/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift index d9eb3318a4..01df92b5cf 100644 --- a/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift +++ b/src/macos/GenesisTools/Sources/Parts/Features/FeaturesEntry.swift @@ -283,7 +283,10 @@ package func runFeatures(_ args: [String] = []) -> Never { // A notification click that runs something hands focus back to the app it was clicked over. BrowserURLForwarder.shared.trackOtherApps() installNotificationClicksForWindowFace() - MainActor.assumeIsolated { WindowCycle.install() } + MainActor.assumeIsolated { + WindowCycle.install() + HangWatch.start() + } app.setActivationPolicy(.accessory) app.run() withExtendedLifetime(delegate) {} diff --git a/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift b/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift index 8c1a3bfd6a..d2d2a5037b 100644 --- a/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift +++ b/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift @@ -239,8 +239,9 @@ final class DaemonSettingsStore: ObservableObject { } /// One `tools daemon config set <task> <flags> --json`. The switch moves at once; the CLI's answer (the stored - /// task) replaces it, and a refused change puts the old value back and says why. - func set(_ task: DaemonTaskConfig, _ flags: [String], optimistic: ((inout DaemonTaskConfig) -> Void)? = nil) { + /// task) replaces it, and a refused change puts the old value back, says why and runs `onFailure`. + func set(_ task: DaemonTaskConfig, _ flags: [String], optimistic: ((inout DaemonTaskConfig) -> Void)? = nil, + onFailure: (() -> Void)? = nil) { writeGeneration += 1 let before = status if let optimistic, let index = status?.tasks.firstIndex(where: { $0.name == task.name }) { @@ -257,6 +258,7 @@ final class DaemonSettingsStore: ObservableObject { show("Saved \(task.name). The daemon uses it from its next tick.", error: false) case .failure(let error): status = before + onFailure?() show("\(task.name): \(error)", error: true) } } @@ -401,7 +403,11 @@ private struct DaemonTaskCard: View { .buttonStyle(.bordered) .nativeSettingsPointer() .disabled(store.runningNow.contains(task.name)) - .instantTooltip("Run it once now with its timeout and priority (tools daemon run \(task.name))") + .instantTooltip("Run this task once now, with its timeout and priority") + // The command for a terminal stays one right-click away. + .contextMenu { + Button("Copy Command") { Clipboard.copy("tools daemon run \(task.name)", what: "command") } + } .accessibilityIdentifier("daemon.\(task.name).run") Toggle("Enabled", isOn: Binding( get: { task.enabled }, @@ -533,13 +539,22 @@ private struct DaemonTaskEditor: View { @ObservedObject var store: DaemonSettingsStore @State private var every = "" @State private var retryTimes = "" + /// The text field being edited: leaving one saves it, as Return does. + @FocusState private var editing: Field? + /// The last value sent to the daemon from each field, until the task comes back with it. + @State private var sentEvery: String? + @State private var sentRetryTimes: String? + + private enum Field { case every, retryTimes } var body: some View { VStack(alignment: .leading, spacing: 14) { - NativeSettingsRow("Schedule", detail: "\"every 5 minutes\", \"every 2 hours\" or \"every day at 03:00\". Return saves.") { + NativeSettingsRow("Schedule", detail: "\"every 5 minutes\", \"every 2 hours\" or \"every day at 03:00\". Return or leaving the field saves; Esc puts it back.") { TextField("every 5 minutes", text: $every) .textFieldStyle(.roundedBorder).frame(width: 200) + .focused($editing, equals: .every) .onSubmit { commitEvery() } + .onExitCommand { every = task.every } .accessibilityIdentifier("daemon.\(task.name).every") } NativeSettingsRow("Timeout", detail: "A run still going after this long is stopped and counts as timed out.") { @@ -613,10 +628,12 @@ private struct DaemonTaskEditor: View { .accessibilityIdentifier("daemon.\(task.name).missedRun") } if task.missedRun.mode == "retry-at" { - NativeSettingsRow("Times", detail: "Comma-separated, 24-hour HH:MM. Return saves.") { + NativeSettingsRow("Times", detail: "Comma-separated, 24-hour HH:MM. Return or leaving the field saves; Esc puts it back.") { TextField("10:00, 12:00", text: $retryTimes) .textFieldStyle(.roundedBorder).frame(width: 200) - .onSubmit { store.set(task, ["--retry-at", retryTimes]) } + .focused($editing, equals: .retryTimes) + .onSubmit { commitRetryTimes() } + .onExitCommand { retryTimes = Self.times(task) } .accessibilityIdentifier("daemon.\(task.name).retryAt") } } @@ -624,7 +641,18 @@ private struct DaemonTaskEditor: View { NativeSettingsRow("Command", detail: task.command) { EmptyView() } } .onAppear(perform: syncFields) - .onChange(of: task) { _, _ in syncFields() } + // Another setting's save brings a new task; a field the user is still typing in keeps their text. + .onChange(of: task) { old, _ in + syncFields(from: old) + sentEvery = nil + sentRetryTimes = nil + } + // A click on another field or Tab saves what was typed, as Return does. + .onChange(of: editing) { old, _ in + if let old { commit(old) } + } + // Closing the disclosure, another page or the window saves an edit that is still open. + .onDisappear { commit(nil) } } private var priorityDetail: String { @@ -635,15 +663,48 @@ private struct DaemonTaskEditor: View { } } + private static func times(_ task: DaemonTaskConfig) -> String { + (task.missedRun.times ?? []).joined(separator: ", ") + } + private func syncFields() { every = task.every - retryTimes = (task.missedRun.times ?? []).joined(separator: ", ") + retryTimes = Self.times(task) + } + + /// Takes the new task's values, except in a field that has the focus and holds an edit not saved yet. + private func syncFields(from old: DaemonTaskConfig) { + if editing != .every || every == old.every { every = task.every } + // The text just saved comes back in the stored form ("10:00,12:00" as "10:00, 12:00"), so it is not saved again. + if editing != .retryTimes || retryTimes == Self.times(old) || retryTimes == sentRetryTimes { + retryTimes = Self.times(task) + } } + private func commit(_ field: Field?) { + switch field { + case .every: commitEvery() + case .retryTimes: commitRetryTimes() + case nil: + commitEvery() + commitRetryTimes() + } + } + + /// Saves a changed schedule once: Return and the focus leaving the field right after it do not save it twice. private func commitEvery() { let value = every.trimmingCharacters(in: .whitespaces) - guard !value.isEmpty, value != task.every else { return } - store.set(task, ["--every", value]) + guard !value.isEmpty, value != task.every, value != sentEvery else { return } + sentEvery = value + // A failed save lets the same text be sent again. + store.set(task, ["--every", value], onFailure: { if sentEvery == value { sentEvery = nil } }) + } + + private func commitRetryTimes() { + let value = retryTimes.trimmingCharacters(in: .whitespaces) + guard task.missedRun.mode == "retry-at", !value.isEmpty, value != Self.times(task), value != sentRetryTimes else { return } + sentRetryTimes = value + store.set(task, ["--retry-at", value], onFailure: { if sentRetryTimes == value { sentRetryTimes = nil } }) } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift index a912dc18ad..913d08f9e9 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift @@ -278,6 +278,8 @@ final class HubAgentsModel: ObservableObject { private(set) var showingCached = false @Published private(set) var loaded = false @Published private(set) var error: String? + /// A `--session` or `--agent` request the loaded list does not hold; the list itself loaded fine. + @Published var requestMiss: String? /// `AgentTree.key(parent:child:)` of the open child. @Published var selectedID: String? { didSet { @@ -938,6 +940,7 @@ final class HubAgentsModel: ObservableObject { /// named by its id, its id's start, or its teammate name; the parent by its id or id's start. func request(parent: String?, child: String) { pendingRequest = (parent, child) + requestMiss = nil resolvePendingRequest(final: false) } @@ -952,7 +955,7 @@ final class HubAgentsModel: ObservableObject { revealRequest += 1 } else if final { pendingRequest = nil - error = "No agent session \(wanted.parent?.prefix(8) ?? "") in the list" + requestMiss = "No agent session \(wanted.parent?.prefix(8) ?? "") in the list." } return } @@ -965,7 +968,7 @@ final class HubAgentsModel: ObservableObject { revealRequest += 1 } else if final { pendingRequest = nil - error = "No agent \(wanted.child)\(wanted.parent.map { " under session \($0.prefix(8))" } ?? "") in the list" + requestMiss = "No agent \(wanted.child)\(wanted.parent.map { " under session \($0.prefix(8))" } ?? "") in the list." } } @@ -1165,10 +1168,16 @@ struct AgentsListView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 2, pinnedViews: [.sectionHeaders]) { if let error = agents.error { - Text(error) - .font(.system(size: 11)) - .foregroundColor(ReviewPalette.removed) - .lineLimit(3) + VStack(alignment: .leading, spacing: 8) { + NoticePill(text: "Could not load the agents.", more: error, tone: .error) + GhostButton("Try again", symbol: "arrow.clockwise", tooltip: "Load the agents again", + action: agents.reload) + } + .padding(.horizontal, 14) + .padding(.vertical, 4) + } + if let miss = agents.requestMiss { + NoticePill(text: miss, tone: .warning) { agents.requestMiss = nil } .padding(.horizontal, 14) .padding(.vertical, 4) } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubCommitLink.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubCommitLink.swift index 83b6085ed6..9cf104bc9c 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubCommitLink.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubCommitLink.swift @@ -68,6 +68,82 @@ struct PRCommitPick: Equatable { struct PRNotice: Equatable { let ref: HubPRRef let text: String + /// False for a note that the link worked another way (its commits are local only). + var isError = true +} + +/// A link's commits that the forge does not list yet: an agent links its own commits before it pushes them, so +/// they exist only in the PR's checkout (MR !7555, 2026-10-23: `efb5951c5a..efe2e40b4b` with four local commits +/// opened the whole MR). Read from that checkout's git, off the main thread. +struct HubLocalCommits: Equatable { + /// Full shas, oldest first; `last` is nil for one commit. + let first: String + let last: String? + /// The commits the range holds, both ends included, and how many of them the PR's pushed head lacks. + let count: Int + let unpushed: Int + let title: String + + enum Miss: Error, Equatable { + /// The checkout does not have this end either. + case unknown(String) + /// Both ends exist, but neither descends from the other. + case unrelated + /// The commits do not continue the PR: the last one does not descend from its pushed head, or the first one is + /// neither one of its commits nor after that head (a commit from before the PR would show the base's changes). + case offBranch + } + + /// `ends` are the link's normalized shas, `head` the PR's pushed head and `pushed` the shas the forge lists for it. + /// Runs git, so never on the main thread. + nonisolated static func resolve(ends: [String], repo: String, head: String, pushed: Set<String> = []) -> Result<HubLocalCommits, Miss> { + var full: [String] = [] + for end in ends { + guard let sha = git(repo, ["rev-parse", "--verify", "--quiet", end + "^{commit}"])? + .trimmingCharacters(in: .whitespacesAndNewlines), !sha.isEmpty else { return .failure(.unknown(end)) } + full.append(sha) + } + var first = full[0] + var last = full.count > 1 ? full[1] : full[0] + if first != last, !isAncestor(first, of: last, repo: repo) { + guard isAncestor(last, of: first, repo: repo) else { return .failure(.unrelated) } + swap(&first, &last) + } + guard isAncestor(head, of: last, repo: repo), + pushed.contains(first) || isAncestor(head, of: first, repo: repo) else { + return .failure(.offBranch) + } + let span = Int(git(repo, ["rev-list", "--count", "--ancestry-path", first + ".." + last])? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0 + let afterHead = Int(git(repo, ["rev-list", "--count", head + ".." + last])? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0 + // Unpushed commits before the link's first one are not the link's. + let unpushed = min(afterHead, span + 1) + let title = git(repo, ["log", "-1", "--format=%s", last])?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return .success(HubLocalCommits(first: first, last: full.count > 1 ? last : nil, count: span + 1, + unpushed: unpushed, title: title)) + } + + nonisolated private static func isAncestor(_ older: String, of newer: String, repo: String) -> Bool { + git(repo, ["merge-base", "--is-ancestor", older, newer]) != nil + } + + nonisolated private static func git(_ repo: String, _ args: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", repo, "--no-optional-locks"] + args + let out = Pipe() + process.standardOutput = out + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil + } + let data = out.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return process.terminationStatus == 0 ? String(decoding: data, as: UTF8.self) : nil + } } /// The commit row a PR overview scrolls to once (`PRsModel.commitScroll`). @@ -182,7 +258,7 @@ extension HubModel { guard commitLinkRequest == request else { return } switch outcome { case .found(let found): - showCommit(repo: found.root, sha: found.sha, title: found.title, base: found.base) + showCommit(repo: found.root, sha: found.sha, title: found.title, base: found.base, link: commitLinkRaw) case .failed(let message): HubPerf.log("hub.commitLink \(message)") notice = message @@ -203,6 +279,7 @@ extension HubModel { notice = "Commit \(span.label) needs its PR (pr=) or its project (repo=) to open" return } + commitLinkRaw = span.to.map { "\(span.from)..\($0)" } ?? span.from cancelSessionNavigation() let known = prs.allProjects Task { diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift index 203df7f8d4..f376b1ef48 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift @@ -753,6 +753,9 @@ struct GroupHeader: View { if let path { Button("Copy absolute path") { PathOpener.copy(path, what: "path") } } + if let remove { + Button("Hide project") { remove() } + } } } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift index 532aea626e..22581435dd 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift @@ -81,7 +81,7 @@ struct DecisionsView: View { ForEach(shown) { item in InboxDecisionCard(model: model, inbox: inbox, session: session, item: item) } - InboxSendBar(model: model, inbox: inbox, session: session) + InboxSendBar(model: model, inbox: inbox, session: session, claimsShortcut: true) } } .padding(18) @@ -115,11 +115,11 @@ struct DecisionsView: View { .frame(width: 210) .instantTooltip("Which decisions to show") // Short enough for the pane's header; the whole explanation is the tooltip (it was cut mid-sentence, H19). - Text("A click marks an option · Send delivers") + Text("A click stages an option · Send delivers") .font(.system(size: 11)) .foregroundColor(ReviewPalette.dim) .lineLimit(1) - .instantTooltip("Posted and harvested decisions, and the ones the last reply asks. A click on an option marks it; Send delivers every marked answer to the session.") + .instantTooltip("Posted and harvested decisions, and the ones the last reply asks. A click on an option stages it; Send delivers every staged answer to the session.") if model.loadingDecisions { ProgressView().controlSize(.small) } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift index 7c5a90533c..c8f78a4190 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift @@ -794,7 +794,7 @@ final class HubInboxModel: ObservableObject { } /// Brings the session's cmux pane forward through its own agent's resolver (`tools <agent> cmux focus`). - func focusPane(_ session: InboxSession, notice: @escaping (String) -> Void) { + func focusPane(_ session: InboxSession, notice: @escaping (String, NoticePill.Tone) -> Void) { guard let sessionId = session.sessionId else { return } let tool = cmuxTool(provider: session.provider) DispatchQueue.global(qos: .userInitiated).async { @@ -804,7 +804,7 @@ final class HubInboxModel: ObservableObject { let message = failed ? "No cmux pane found for this session: " + (capture.map { String(decoding: $0.stderr, as: UTF8.self).trimmed.suffix(120) } ?? "tools did not run") : "Focused the session's cmux pane" - DispatchQueue.main.async { notice(String(message)) } + DispatchQueue.main.async { notice(String(message), failed ? .error : .success) } } } } @@ -846,7 +846,6 @@ struct InboxListView: View { } } .frame(width: 16, height: 16) - IconButton(systemName: "arrow.clockwise", tooltip: "Look for waiting sessions again") { inbox.load() } } .padding(.horizontal, 14) .padding(.bottom, 6) @@ -857,8 +856,16 @@ struct InboxListView: View { .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Looking for waiting sessions") + } else if rows.isEmpty, let failure = inbox.error { + InboxLoadFailure(raw: failure) { inbox.load() } + .padding(14) + } else if rows.isEmpty, !inbox.sessions.isEmpty, !model.filter.trimmed.isEmpty { + EmptyState( + text: "No session matches “\(model.filter)”", + actionTitle: "Clear filter" + ) { model.filter = "" } } else if rows.isEmpty { - Text(inbox.error ?? "Nothing is waiting for you.") + Text("Nothing is waiting for you.") .font(.system(size: 12)) .foregroundColor(ReviewPalette.dim) .padding(14) @@ -899,10 +906,29 @@ struct InboxListView: View { } } +/// A failed load of the Inbox: one sentence and a retry; the raw failure sits behind Details. +private struct InboxLoadFailure: View { + let raw: String + let retry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + NoticePill(text: "Could not load the waiting sessions.", more: raw, tone: .error) + GhostButton("Try again", symbol: "arrow.clockwise", tooltip: "Look for waiting sessions again", action: retry) + } + } +} + private struct InboxSessionRow: View { let session: InboxSession let selected: Bool + private var waitingText: String { + session.isStale + ? "\(Plural.count(session.waiting, "open item")), quiet for three days: not counted as waiting" + : "\(Plural.count(session.waiting, "answer")) waiting" + } + var body: some View { HStack(alignment: .top, spacing: 9) { ProviderBadge(provider: session.provider ?? "?") @@ -929,9 +955,9 @@ private struct InboxSessionRow: View { .foregroundColor(session.isStale ? ReviewPalette.dim : .black) .frame(minWidth: 18, minHeight: 18) .background(Capsule().fill(session.isStale ? Color.white.opacity(0.08) : InboxStyle.accent)) - .instantTooltip(session.isStale - ? "\(Plural.count(session.waiting, "open item")), quiet for three days: not counted as waiting" - : "\(Plural.count(session.waiting, "answer")) waiting") + .instantTooltip(waitingText) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(waitingText)) } else { Badge("queued", color: ReviewPalette.modified, look: .filled) .instantTooltip("Answered; the session's next prompt receives it. Nothing waits for you here.") @@ -971,12 +997,24 @@ struct InboxMain: View { LazyVStack(alignment: .leading, spacing: 22) { if rows.isEmpty, inbox.loading { PaneSkeleton("Looking for waiting sessions") - } else if rows.isEmpty { - Text("No session is waiting for an answer.") - .font(.system(size: 13)) - .foregroundColor(ReviewPalette.dim) + } else if rows.isEmpty, let failure = inbox.error { + InboxLoadFailure(raw: failure) { inbox.load() } .frame(maxWidth: .infinity) .padding(.top, 40) + } else if rows.isEmpty, !inbox.sessions.isEmpty, !model.filter.trimmed.isEmpty { + EmptyState( + text: "No session matches “\(model.filter)”", + actionTitle: "Clear filter" + ) { model.filter = "" } + .padding(.top, 40) + } else if rows.isEmpty { + EmptyState( + symbol: "tray", + text: "No session is waiting for an answer.", + actionTitle: "Look again", + action: { inbox.load() } + ) + .padding(.top, 40) } ForEach(rows.filter { !$0.isQueuedOnly }) { session in InboxSessionSection(model: model, inbox: inbox, session: session) @@ -1063,8 +1101,8 @@ struct InboxMain: View { } .titlebarLabel() Spacer() - if let notice = model.notice { - NoticePill(text: notice) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } if let missing = inbox.revealMissing { NoticePill(text: missing, isError: true) { inbox.revealMissing = nil } @@ -1165,7 +1203,7 @@ private struct InboxSessionSection: View { } if let provider = session.provider, ["claude", "codex", "grok"].contains(provider) { IconButton(systemName: "terminal", tooltip: "Show this session's cmux pane") { - inbox.focusPane(session) { model.notice = $0 } + inbox.focusPane(session) { model.post($0, $1) } } } IconButton(systemName: "text.bubble", tooltip: hubSession == nil ? "This session is older than the hub's list" : "Open this session's transcript in the hub") { @@ -1179,15 +1217,23 @@ private struct InboxSessionSection: View { /// One session's Send: "Send N answers to <session>", the exact message under it, then where it went /// (or why it did not, with Resume elsewhere). Shared by the Inbox section and the Decisions pane, so -/// both send the same way: every marked answer as one message through `tools question inbox send`. +/// both send the same way: every staged answer as one message through `tools question inbox send`. struct InboxSendBar: View { @ObservedObject var model: HubModel @ObservedObject var inbox: HubInboxModel let session: InboxSession + /// The Decisions pane shows one session's bar, whatever the Inbox has selected, so that bar takes ⌘↩. + var claimsShortcut = false private var drafted: [InboxItem] { session.draftedItems } private var sending: Bool { session.sessionId.map { inbox.sendingSessions.contains($0) } ?? false } private var result: InboxDelivery? { session.sessionId.flatMap { inbox.sessionResults[$0] } } + private var ownsShortcut: Bool { + guard !drafted.isEmpty else { return false } + if claimsShortcut || inbox.selectedID == session.id { return true } + let staging = inbox.sessions.filter { !$0.draftedItems.isEmpty } + return staging.count == 1 && staging[0].id == session.id + } /// The message the Send button will type, previewed under it. static func preview(_ drafted: [InboxItem]) -> String { @@ -1195,8 +1241,11 @@ struct InboxSendBar: View { guard let number = item.number else { return nil } let letters = (item.draftOption ?? "").map { "\($0)) " }.joined() let note = item.draft?.trimmed ?? "" - let labels = (item.draftOption ?? "").compactMap { letter in - (item.choices ?? []).first { $0.id == String(letter) }?.label + // Most labels already start with their own "b) "; drop it so the letter is not written twice. + let labels = (item.draftOption ?? "").compactMap { letter -> String? in + guard let label = (item.choices ?? []).first(where: { $0.id == String(letter) })?.label else { return nil } + let prefix = "\(letter)) " + return label.hasPrefix(prefix) ? String(label.dropFirst(prefix.count)) : label } let text = note.isEmpty ? labels.joined(separator: " / ") : note return "DECISION \(number): \(letters)\(text)".trimmed @@ -1210,8 +1259,10 @@ struct InboxSendBar: View { Button { inbox.sendSession(session) } label: { - Label(sending ? "Sending…" : "Send \(drafted.count) answer\(drafted.count == 1 ? "" : "s") to \(session.displayTitle)", systemImage: "paperplane.fill") + Label(sending ? "Sending…" : "Send \(Plural.count(drafted.count, "answer")) to \(session.displayTitle)", systemImage: "paperplane.fill") .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) .foregroundColor(drafted.isEmpty ? ReviewPalette.dim : .black) .padding(.horizontal, 12) .frame(height: 28) @@ -1219,7 +1270,11 @@ struct InboxSendBar: View { } .buttonStyle(.genHoverPlain()) .disabled(drafted.isEmpty || sending || session.sessionId == nil) - .instantTooltip("Send every marked answer to the agent as one message") + // One session answers the chord: the selected one, or the only one with staged answers. + .keyboardShortcut(ownsShortcut ? KeyboardShortcut(.return, modifiers: .command) : nil) + .instantTooltip(ownsShortcut ? "Send every staged answer to the agent as one message (⌘↩)" + : "Send every staged answer to the agent as one message") + .accessibilityHint(Text(ownsShortcut ? "Command-Return" : "")) if sending { ProgressView().controlSize(.small) } @@ -1626,7 +1681,7 @@ struct InboxDecisionCard: View { .buttonStyle(.genHoverRow(accent: .white, cornerRadius: 7)) .disabled(!pickable) .instantTooltip(pickable - ? (chosen ? "Click to unmark \(choice.id)) · ⌘-click to add another" : "Mark \(choice.id)) · ⌘-click to add a second") + ? (chosen ? "Click to unstage \(choice.id)) · ⌘-click to add another" : "Stage \(choice.id)) · ⌘-click to stage a second") : "Already \(item.status)") } @@ -2221,9 +2276,10 @@ private struct InboxFormCard: View { .foregroundColor(.white) .background(Circle().fill(Color.black.opacity(0.6))) } - .buttonStyle(.plain) + .buttonStyle(.genHoverPlain()) .offset(x: 4, y: -4) .instantTooltip("Remove this image") + .accessibilityLabel(Text("Remove image")) } } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubKeymap.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubKeymap.swift index 14fea3d5e7..310dee41e6 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubKeymap.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubKeymap.swift @@ -125,6 +125,9 @@ enum HubKeymap { // The two keys right of P: "[" and "]" on US, "ú" and ")" on Czech. The position is the shortcut. KeymapCommand("hub.go.back", "Back", group: "Go", keys: [.code(33, .command)]), KeymapCommand("hub.go.forward", "Forward", group: "Go", keys: [.code(30, .command)]), + KeymapCommand("hub.go.history", "History", group: "Go", + detail: "Every place the hub showed, who opened it (you, an agent, a link) and why.", + keys: [.character("y", .command)]), KeymapCommand("hub.go.session", "Session", group: "Go", keys: []), KeymapCommand("hub.go.worktree", "Worktree", group: "Go", keys: []), KeymapCommand("hub.go.pr", "Pull Request", group: "Go", keys: []), diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift index 99e9ce35c0..68c543b185 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift @@ -30,14 +30,8 @@ extension TerminalHost { func send(sessionId: String, text: String) -> String? { send(sessionId: sessionId, provider: nil, text: text) } } -/// The `tools <agent>` that owns a session's cmux resolver: `tools codex cmux` and `tools grok cmux` know -/// their own history (Grok has no journal and its own fallback); anything else goes through Claude's. -func cmuxTool(provider: String?) -> String { - switch provider { - case "codex", "grok": return provider ?? "claude" - default: return "claude" - } -} +/// The `tools <agent>` that owns a session's cmux resolver (GenesisKit `CmuxCommandRunner.tool`). +func cmuxTool(provider: String?) -> String { CmuxCommandRunner.tool(provider: provider) } enum TerminalHosts { static let current: TerminalHost = CmuxHost() @@ -54,11 +48,7 @@ struct CmuxHost: TerminalHost { var isAvailable: Bool { Self.binary != nil } - static var binary: String? { - ["/Applications/cmux.app/Contents/Resources/bin/cmux", "~/.local/bin/cmux", "/usr/local/bin/cmux", "/opt/homebrew/bin/cmux"] - .map { ($0 as NSString).expandingTildeInPath } - .first { FileManager.default.isExecutableFile(atPath: $0) } - } + static var binary: String? { CmuxCommandRunner.binary } func tree() -> CmuxTree? { let span = HubPerf.begin("cmux.tree") @@ -83,31 +73,11 @@ struct CmuxHost: TerminalHost { } } - /// What `tools claude cmux focus --json` said, in words: nil when it focused. A recorded tab that - /// was closed answers `gone`; before, the hub and Genesis showed cmux's raw "[socket] RPC error". static func focusFailure(status: Int32, stdout: Data, stderr: Data) -> String? { - guard status != 0 else { return nil } - struct Opaque: Decodable { - init(from decoder: Decoder) throws {} - } - struct Result: Decodable { - let gone: Bool? - let matches: [Opaque]? - } - if let result = try? JSONDecoder().decode(Result.self, from: stdout) { - if result.gone == true { - return CmuxHost.paneGone - } - if result.matches?.isEmpty == true { - return "No cmux pane shows this session. Choose a pane to resume it in." - } - } - let lines = String(decoding: stderr, as: UTF8.self).split(whereSeparator: \.isNewline) - let said = lines.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│■▲● ").union(.whitespaces)) }.last { !$0.isEmpty } - return said.map { String($0) } ?? "tools claude cmux focus exited \(status)" + CmuxCommandRunner.focusFailure(status: status, stdout: stdout, stderr: stderr) } - static let paneGone = "That cmux pane is gone. Choose a pane to resume the session in." + static let paneGone = CmuxCommandRunner.paneGone func send(sessionId: String, provider: String?, text: String) -> String? { do { @@ -142,69 +112,25 @@ struct CmuxHost: TerminalHost { } } - /// `open-session --json` reports a failure as {ok: false, error} on stdout, with stderr empty. private static func failure(of capture: ProcessCapture) -> String { - struct Failure: Decodable { let error: String } - if let failure = try? JSONDecoder().decode(Failure.self, from: capture.stdout) { - return failure.error - } - let stderr = String(decoding: capture.stderr, as: UTF8.self).trimmed - return stderr.isEmpty ? "tools claude cmux open-session exited \(capture.status)" : String(stderr.suffix(300)) + CmuxCommandRunner.openSessionFailure(status: capture.status, stdout: capture.stdout, stderr: capture.stderr) + ?? "tools claude cmux open-session failed" } private static func openSessionArgs(_ target: CmuxTarget) -> [String]? { target.openSessionArgs } - /// Words are shell-quoted here because cmux takes one command string; nothing in them comes from a URL. private func run(_ command: [String], cwd: String, name: String, at target: CmuxTarget) -> String? { - let words = command.map(ShellWords.word).joined(separator: " ") - let line = cwd.isEmpty ? words : "cd \(ShellWords.word(cwd)) && \(words)" - switch target { - case .newWorkspace(let window): - let result = Self.cmux(["new-workspace", "--name", name, "--cwd", cwd, "--command", words, "--focus", "true"] + (window.map { ["--window", $0] } ?? [])) - return result.ok ? nil : result.out.trimmed - case .newPane(let workspace): - return typeInto(Self.cmux(["new-pane", "--type", "terminal", "--direction", "right", "--workspace", workspace, "--focus", "true"]), workspace: workspace, line: line) - case .newTab(let workspace, let pane): - return typeInto(Self.cmux(["new-surface", "--type", "terminal", "--workspace", workspace, "--pane", pane, "--focus", "true"]), workspace: workspace, line: line) - case .surface(let workspace, let surface): - return send(line, workspace: workspace, surface: surface) - } - } - - /// new-surface / new-pane print the new surface ref; the command is typed into it, then Enter. - private func typeInto(_ created: (ok: Bool, out: String), workspace: String, line: String) -> String? { - guard created.ok else { return created.out.trimmed } - guard let range = created.out.range(of: "surface:[0-9]+", options: .regularExpression) else { - return "cmux did not report the new surface: \(created.out.trimmed)" - } - return send(line, workspace: workspace, surface: String(created.out[range])) - } - - private func send(_ line: String, workspace: String, surface: String) -> String? { - let sent = Self.cmux(["send", "--workspace", workspace, "--surface", surface, line]) - guard sent.ok else { return sent.out.trimmed } - let enter = Self.cmux(["send-key", "--workspace", workspace, "--surface", surface, "Enter"]) - return enter.ok ? nil : enter.out.trimmed + CmuxCommandRunner.run(command, cwd: cwd, name: name, at: target) } @discardableResult static func cmux(_ args: [String]) -> (ok: Bool, out: String) { - guard let binary else { return (false, "cmux is not installed") } let span = HubPerf.begin("cmux.\(args.first ?? "")") defer { span.end() } - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = args - var environment = ProcessInfo.processInfo.environment - environment["CMUX_QUIET"] = "1" - process.environment = environment - guard let result = try? process.runCapturing() else { return (false, "cmux failed to start") } - let text = String(decoding: result.stdout, as: UTF8.self) - return (result.status == 0, result.status == 0 ? text : String(decoding: result.stderr, as: UTF8.self) + text) + return CmuxCommandRunner.cmux(args) } - } // MARK: - Loading the tree off the main thread diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubMainMenu.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubMainMenu.swift index c3982d5bd5..12a84f5c56 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubMainMenu.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubMainMenu.swift @@ -245,6 +245,7 @@ enum HubMenuCommands { static let go: [HubMenuCommand] = [ HubMenuCommand(title: "Back", id: "hub.go.back", run: { $0.goBack() }, enabled: { $0.history.canGoBack }), HubMenuCommand(title: "Forward", id: "hub.go.forward", run: { $0.goForward() }, enabled: { $0.history.canGoForward }), + HubMenuCommand(title: "History…", id: "hub.go.history", run: { $0.historyOpen.toggle() }), HubMenuCommand(title: "Session…", id: "hub.go.session", run: { palette($0, "session ") }, separated: true), HubMenuCommand(title: "Worktree…", id: "hub.go.worktree", run: { palette($0, "worktree ") }), HubMenuCommand(title: "Pull Request…", id: "hub.go.pr", run: { palette($0, "pr ") }), diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNavigation.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNavigation.swift index a0ecbebcd2..9d6b213501 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNavigation.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNavigation.swift @@ -1,65 +1,202 @@ import AppKit import SwiftUI -// Back and forward through what the hub showed: every mode switch, session, worktree and PR picked, -// and "Open" from a PR's or a worktree's session list. ⌘[ and ⌘], the arrows above the mode switch, -// the mouse's side buttons and a trackpad swipe all walk the same history (`HubModel.goBack`). +// Back and forward through what the hub showed, and the log of it: every mode switch, session, worktree, PR and +// commit, who took the hub there (a click here, an agent, a link) and why. The engine is GenesisKit's +// `NavigationHistory`; the log lives in `tools hub nav` (`~/.genesis-tools/hub/data.db`), so an agent's +// `tools hub nav open` and the hub write one list. ⌘[ and ⌘], the arrows above the mode switch, the mouse's side +// buttons and a trackpad swipe walk it (`HubModel.goBack`); ⌘Y and the clock button list it (`HubHistoryButton`). -/// One place in the hub: the mode and what it had selected. +/// The place a mode shows: the mode and what it had selected. struct HubNavEntry: Equatable { var mode: HubMode /// The session row id, worktree path or PR id the mode shows; nil while nothing is picked yet. var selection: String? } -/// A browser's history: `visit` pushes, `goBack` and `goForward` walk it. Pure, so tests drive it. -struct HubNavHistory: Equatable { - static let limit = 100 - - private(set) var back: [HubNavEntry] = [] - private(set) var current: HubNavEntry? - private(set) var forward: [HubNavEntry] = [] - - var canGoBack: Bool { !back.isEmpty } - var canGoForward: Bool { !forward.isEmpty } - - /// A new place. A new place after going back drops the forward entries, as a browser does. - /// - /// A place with nothing picked (the hub's first moment, a mode whose list still loads) is never - /// kept: whatever follows replaces it. Otherwise every launch began with a dead "back" to an - /// empty Sessions mode, and a quick look at a loading mode left a stop with nothing in it. - mutating func visit(_ entry: HubNavEntry) { - guard entry != current else { return } - if let current, current.selection != nil { - back.append(current) - if back.count > Self.limit { - back.removeFirst(back.count - Self.limit) - } - forward.removeAll() - } else if back.last == entry { - // Out to an empty mode and straight back: the place before it, not a copy of it. - current = back.popLast() - return - } - current = entry +/// A place in the history: the hub's link keys (`HubLink.linkKeys`) plus `selection`, the row the hub's own mode +/// had selected. A place the hub showed has `mode` and `selection` and is restored exactly; a place an agent named +/// (`tools hub nav open`) has link keys only and opens as that link would. Stored as a flat JSON object, which is +/// the `target` of `tools hub nav` (src/hub/lib/navigation/types.ts). +struct HubNavTarget: Codable, Hashable, Sendable { + private(set) var fields: [String: String] + + /// The keys a place keeps, in the order `arguments` writes them. + static let keys = ["mode", "selection", "session", "agent", "pr", "reveal", "repo", "commit", "tab", "worktree"] + + init(_ fields: [String: String]) { + self.fields = fields.filter { Self.keys.contains($0.key) && !$0.value.isEmpty } + } + + /// The place a mode shows, with the commit a link or an Activity row opened there. + init(place: HubNavEntry, commit: String? = nil, repo: String? = nil) { + var fields = ["mode": place.mode.rawValue] + fields["selection"] = place.selection + fields["commit"] = commit + fields["repo"] = repo + self.init(fields) + } + + init(from decoder: Decoder) throws { + self.init(try decoder.singleValueContainer().decode([String: String].self)) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(fields) + } + + subscript(_ key: String) -> String? { fields[key] } + + var mode: HubMode? { fields["mode"].flatMap(HubMode.init(rawValue:)) } + + /// The hub's own place, when it has one. + var place: HubNavEntry? { + guard let mode, let selection = fields["selection"] else { return nil } + return HubNavEntry(mode: mode, selection: selection) + } + + /// The `--hub` flags of a place an agent or a link named. + var arguments: [String] { + Self.keys.filter { $0 != "selection" }.flatMap { key in fields[key].map { ["--\(key)", $0] } ?? [] } + } +} + +typealias HubNavHistory = NavigationHistory<HubNavTarget> + +/// The commit a link or an Activity row shows in a checkout's Worktrees diff (`HubModel.showCommit`). +struct HubShownCommit: Equatable { + /// The checkout, as the Worktrees mode selects it. + let repo: String + /// One sha or `<from>..<to>`, as the link wrote it. + let commit: String +} + +/// A place an agent or a link asked for, with who and why, until the hub shows it. Matched against what the hub +/// shows (`HubModel.navRequestMatches`), so a PR that loads for a few seconds still gets its note. +struct HubNavRequest { + var actor: NavigationActor + var note: String? + var from: String? + /// The entry `tools hub nav open` stored; the hub's record replaces it. + var id: String? + var pr: HubPRRef? + var commit: String? + var repo: String? + var session: String? + var agent: String? + var worktree: String? + var mode: HubMode? + /// A history entry being shown again: its own entry takes the shown place's form instead of a new entry. + var restoring = false + let asked: Date + + /// Nil for a request that names no place (an overlay, `--palette`, a bare `tools hub`). + init?(_ request: HubRequest, now: Date = Date()) { + guard request.session != nil || request.mode != nil || request.agent != nil || request.worktree != nil + || request.pr != nil || request.commit != nil + else { return nil } + actor = request.actor.flatMap(NavigationActor.init(rawValue:)) ?? .link + note = request.note + from = request.from + id = request.nav + pr = request.pr + commit = request.commit + repo = request.repo + session = request.session + agent = request.agent + worktree = request.worktree + mode = request.mode + asked = now } - mutating func goBack() -> HubNavEntry? { - guard let previous = back.popLast() else { return nil } - if let current, current.selection != nil { - forward.append(current) + /// How long the hub waits for the place before it forgets who asked. + static let patience: TimeInterval = 45 + /// How long, after the request, the hub's own steps on the way (the mode switch, the old row still selected) + /// are not taken for the person's clicks. + static let settle: TimeInterval = 3 + + /// The link keys, for an entry the hub adds without showing it (agents only add to History). + var target: HubNavTarget { + var fields: [String: String] = [:] + fields["mode"] = mode?.rawValue + fields["session"] = session + fields["agent"] = agent + fields["pr"] = pr.map { $0.pageURL ?? $0.label } + fields["commit"] = commit + fields["repo"] = repo + fields["worktree"] = worktree + return HubNavTarget(fields) + } + + /// "PR #563 · commit 0751d8a971", until the hub can name it better. + var title: String { + let commitLabel = commit.map { "commit " + $0.split(separator: ".").filter { !$0.isEmpty }.map { String($0.prefix(10)) }.joined(separator: "..") } + if let pr { + return (["PR \(pr.label)"] + (commitLabel.map { [$0] } ?? [])).joined(separator: " · ") + } + if let commitLabel, let repo { + return "\(commitLabel) in \((repo as NSString).lastPathComponent)" } - current = previous - return previous + if let worktree { return "worktree " + (worktree as NSString).lastPathComponent } + if let agent { return "agent \(agent)" } + if let session { return "session \(session.prefix(8))" } + return mode?.title ?? "Hub" } +} + +/// The log, through `tools hub nav` (the hub's resident server answers it without a new process). Every call runs +/// on `queue`, never on the main thread. +final class HubNavStore: NavigationHistoryStore, Sendable { + typealias Target = HubNavTarget + + static let queue = DispatchQueue(label: "hub.nav.store", qos: .utility) + let limit: Int - mutating func goForward() -> HubNavEntry? { - guard let next = forward.popLast() else { return nil } - if let current, current.selection != nil { - back.append(current) + init(limit: Int) { + self.limit = limit + } + + private struct Row: Decodable { + let id: String + let at: Double + let actor: String + let title: String + let target: HubNavTarget + let note: String? + let from: String? + } + + private struct Listing: Decodable { + let entries: [Row] + } + + func load() throws -> [NavigationEntry<HubNavTarget>] { + try Self.decode(ToolsCLIRunner.run(["hub", "nav", "list", "--json", "--limit", String(limit)])) + } + + /// `tools hub nav list --json`: `{ entries: [{ id, at (ms), actor, title, target, note?, from? }], keep, path }`. + static func decode(_ data: Data) throws -> [NavigationEntry<HubNavTarget>] { + try JSONDecoder().decode(Listing.self, from: data).entries.map { row in + NavigationEntry(id: row.id, at: Date(timeIntervalSince1970: row.at / 1000), target: row.target, title: row.title, + actor: NavigationActor(rawValue: row.actor) ?? .user, agentSession: row.from, note: row.note) } - current = next - return next + } + + func record(_ entry: NavigationEntry<HubNavTarget>, keep: Int) throws { + _ = try ToolsCLIRunner.run(try Self.recordArguments(entry, keep: keep)) + } + + static func recordArguments(_ entry: NavigationEntry<HubNavTarget>, keep: Int) throws -> [String] { + let target = String(decoding: try JSONEncoder().encode(entry.target), as: UTF8.self) + // Free text rides as `--flag=value`, so a title or a note that starts with "-" is not read as a flag. + var args = [ + "hub", "nav", "record", "--id=\(entry.id)", "--at=\(Int64(entry.at.timeIntervalSince1970 * 1000))", + "--actor=\(entry.actor.rawValue)", "--title=\(entry.title)", "--target=\(target)", "--keep=\(keep)", + ] + if let note = entry.note { args.append("--note=\(note)") } + if let from = entry.agentSession { args.append("--from=\(from)") } + return args } } @@ -97,7 +234,8 @@ enum HubNavInput { // MARK: - Buttons -/// The back and forward arrows above the mode switch. The tooltip names where each one goes. +/// The back and forward arrows above the mode switch, and the history list beside them. The tooltips name where +/// each arrow goes and who took the hub there. struct HubNavButtons: View { @ObservedObject var model: HubModel @@ -105,15 +243,25 @@ struct HubNavButtons: View { let history = model.history HStack(spacing: 4) { arrow("chevron.left", enabled: history.canGoBack, - tooltip: history.back.last.map { "Back to \(model.navTitle($0)) (\(HubKeymap.label("hub.go.back")))" } + tooltip: history.backEntry.map { "Back to \(Self.describe($0)) (\(HubKeymap.label("hub.go.back")))" } ?? "\(HubKeymap.hint("Back", "hub.go.back")): nothing earlier") { model.goBack() } arrow("chevron.right", enabled: history.canGoForward, - tooltip: history.forward.last.map { "Forward to \(model.navTitle($0)) (\(HubKeymap.label("hub.go.forward")))" } + tooltip: history.forwardEntry.map { "Forward to \(Self.describe($0)) (\(HubKeymap.label("hub.go.forward")))" } ?? "\(HubKeymap.hint("Forward", "hub.go.forward")): nothing later") { model.goForward() } + HubHistoryButton(model: model) + } + } + + /// "PR #563 Fix it", or "PR #563 Fix it, opened by an agent" for a place the person did not pick. + static func describe(_ entry: NavigationEntry<HubNavTarget>) -> String { + switch entry.actor { + case .user: return entry.title + case .agent: return "\(entry.title), opened by an agent" + case .link: return "\(entry.title), opened by a link" } } @@ -123,3 +271,34 @@ struct HubNavButtons: View { .disabled(!enabled) } } + +/// The clock beside the arrows: every place, newest first, with who went there and why; a click hops there. +struct HubHistoryButton: View { + @ObservedObject var model: HubModel + + var body: some View { + IconButton(systemName: "clock.arrow.circlepath", tooltip: HubKeymap.hint("History", "hub.go.history"), size: 12) { + model.historyOpen.toggle() + } + .foregroundColor(model.history.entries.isEmpty ? ReviewPalette.dim.opacity(0.6) : ReviewPalette.dim) + .popover(isPresented: $model.historyOpen, arrowEdge: .bottom) { + HubHistoryPopover(history: model.history) { id in + model.historyOpen = false + model.hop(to: id) + } + } + } +} + +struct HubHistoryPopover: View { + let history: HubNavHistory + let onHop: (String) -> Void + + var body: some View { + NavigationHistoryList(history: history, maxHeight: 460, onHop: onHop) + .frame(width: 400) + .padding(.bottom, 4) + .background(Color(nsColor: KitPalette.background)) + .environment(\.colorScheme, .dark) + } +} diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift index c01f0bfd50..1c3602c68e 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift @@ -30,6 +30,13 @@ struct HubNotifyStatus: Decodable, Equatable { let lastOkAt: String? let lastError: String? let lastAttemptAt: String? + + /// A failing project in one sentence ("2 polls failed. The next try is at 21:49."); the host's raw error goes + /// behind Details, never into the sentence or a tooltip. + var problemText: String { + let next = HubFormat.date(nextAt).map { "at \($0.formatted(date: .omitted, time: .shortened))" } ?? "soon" + return "\(Plural.count(failures, "poll")) failed. The next try is \(next)." + } } struct Event: Decodable, Equatable, Identifiable { @@ -62,6 +69,74 @@ struct HubNotifyStatus: Decodable, Equatable { static func decode(_ data: Data) throws -> HubNotifyStatus { try JSONDecoder().decode(HubNotifyStatus.self, from: data) } + + /// The daemon task's state in one line; the bell and the settings page say the same words. + var daemonTitle: String { + switch daemonTask { + case true?: + return daemonTaskEvery == daemonTaskWanted + ? "The daemon checks every minute" + : "The daemon task still runs \(daemonTaskEvery ?? "at an old cadence")" + case false?: return "Nothing polls yet: the daemon task is missing" + case nil: return "The daemon's task list is unreadable" + } + } + + /// The button that fixes the daemon task (`tools hub notify install`), or nil while it ticks every minute or the + /// task list could not be read (the task may be there). + var daemonFixTitle: String? { + guard let daemonTask else { return nil } + guard !daemonTask || daemonTaskEvery != daemonTaskWanted else { return nil } + return daemonTask ? "Tick every minute" : "Add the daemon task" + } +} + +/// The words of the PR notification settings, shared by the bell popover and Settings, so one setting reads the same +/// in both places and offers the same choices (H17). +enum HubNotifyWords { + static let enabledDetail = "When this is off, no project is polled and nothing posts." + static let onlyMineDetail = "Changes on PRs someone else opened stay silent." + static let cadenceDetail = "How often a project without its own cadence is polled." + static let pollNowDetail = "Poll every watched project now, ignoring cadences and backoff" + static let installDetail = "Adds one background task that checks every watched project each minute." + static let installCommand = "tools hub notify install" + static let eventsDetail = "Which events this project posts; the defaults apply until you change one here" + + /// The cadences a project can pick, in minutes (the CLI takes any whole minute from 1 to 60). + static let cadenceChoices = [1, 2, 3, 5, 10, 15, 20, 30, 45, 60] + + static func cadenceTitle(_ minutes: Int) -> String { + minutes == 1 ? "Every minute" : "Every \(minutes) minutes" + } + + /// `cadenceChoices`, plus the stored value when the config holds another one. + static func cadenceOptions(including value: Int) -> [Int] { + cadenceChoices.contains(value) ? cadenceChoices : (cadenceChoices + [value]).sorted() + } + + /// The label of a project's events menu: "All events (default)", "3 events". + static func eventsSummary(repo: HubNotifyStatus.Repo?, config: HubNotifyStatus.Config) -> String { + let on = HubNotifyEvent.allCases.filter { repo?.events?[$0.rawValue] ?? config.events[$0.rawValue] ?? false } + let label = on.count == HubNotifyEvent.allCases.count ? "All events" : Plural.count(on.count, "event") + return repo?.events == nil ? "\(label) (default)" : label + } + + /// A project's events menu: each event checked as it applies to the project, then "Follow the defaults". + @MainActor + static func eventItems(path: String, repo: HubNotifyStatus.Repo?, config: HubNotifyStatus.Config, store: HubNotifyStore) -> [MenuButtonItem] { + HubNotifyEvent.allCases.map { event -> MenuButtonItem in + let own = repo?.events?[event.rawValue] + let effective = own ?? config.events[event.rawValue] ?? false + return .action("\(event.title)\(own == nil ? "" : " (this project)")", checked: effective) { + store.set(["--repo", path, "--event", "\(event.rawValue)=\(effective ? "off" : "on")"]) + } + } + [ + .divider, + .action("Follow the defaults", enabled: repo?.events != nil) { + store.set(["--repo", path, "--reset-repo-events"]) + }, + ] + } } enum HubNotifyEvent: String, CaseIterable, Identifiable { @@ -77,6 +152,29 @@ enum HubNotifyEvent: String, CaseIterable, Identifiable { case .merged: return "PR merged" } } + + /// When the event posts; the settings row's detail and the bell's tooltip. + var detail: String { + switch self { + case .thread: return "Someone else opens a review thread or comment thread on a PR." + case .reviewRequested: return "You are added as a reviewer." + case .ciFailed: return "The checks of a PR's newest commit fail." + case .ciPassed: return "The checks of a PR's newest commit pass." + case .botReview: return "A review bot posts a review." + case .merged: return "A watched open PR is merged." + } + } +} + +/// A note of the PR notification settings: one sentence, its tone, the raw output behind Details, and what Retry runs +/// again. The code that knows what happened sets the tone; the words never pick a colour (H5, H6). +struct HubNotifyNotice { + let id = UUID() + let text: String + let tone: NoticePill.Tone + var detail: String? + var more: String? + var retry: (@MainActor () -> Void)? } @MainActor @@ -85,7 +183,7 @@ final class HubNotifyStore: ObservableObject { @Published private(set) var status: HubNotifyStatus? @Published private(set) var busy = false - @Published var message: String? + @Published var message: HubNotifyNotice? /// Runs in flight: `busy` holds until the last one ends, not the first. private var inFlight = 0 { didSet { busy = inFlight > 0 } @@ -94,46 +192,57 @@ final class HubNotifyStore: ObservableObject { private var statusGeneration = 0 func load() { - run(["hub", "notify", "status", "--json"], label: "notify.status", decodesStatus: true) + run(["hub", "notify", "status", "--json"], label: "notify.status", failure: "The notification settings could not be read.", decodesStatus: true) } /// The status read has ended, with the settings or with the error it shows. var settled: Bool { !busy && (status != nil || message != nil) } - /// One `tools hub notify set` change, then the status again. + /// One `tools hub notify set` change, then the status again. Retry sends the same change. func set(_ flags: [String]) { - run(["hub", "notify", "set"] + flags + ["--json"], label: "notify.set", reload: true) + run(["hub", "notify", "set"] + flags + ["--json"], label: "notify.set", failure: "The change was not saved.", reload: true) } func pollNow() { - run(["hub", "notify", "poll", "--force", "--json"], label: "notify.poll", reload: true, summary: Self.pollSummary) + run(["hub", "notify", "poll", "--force", "--json"], label: "notify.poll", failure: "The check did not run.", reload: true, summary: Self.pollSummary) } - /// One line for `tools hub notify poll --json` (src/hub/lib/notify-poll.ts `PollReport`). - nonisolated static func pollSummary(_ data: Data) -> String? { + /// One note for `tools hub notify poll --json` (src/hub/lib/notify-poll.ts `PollReport`): a count on success, the + /// CLI's own reason behind Details when it skipped. + nonisolated static func pollSummary(_ data: Data) -> HubNotifyNotice { guard let report = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return "Reading the poll report failed" + return HubNotifyNotice(text: "The check ran, but its report could not be read.", tone: .warning, more: String(decoding: data.prefix(2000), as: UTF8.self)) } let items = (report["items"] as? [Any])?.count ?? 0 let posted = report["posted"] as? Int ?? 0 let requests = report["requests"] as? Int ?? 0 - if let skipped = report["skipped"] as? String { return "Poll skipped: \(skipped)" } - return "Polled: \(items) events, \(posted) posted, \(requests) requests" + if let skipped = report["skipped"] as? String { + return HubNotifyNotice(text: "The check did not run.", tone: .warning, more: skipped) + } + return HubNotifyNotice( + text: "Polled: \(Plural.count(items, "event")), \(posted) posted, \(Plural.count(requests, "request"))", + tone: .success + ) } /// `tools hub notify install`: the one `hub-pr-notify` daemon task, ticking every minute. func installDaemonTask() { - run(["hub", "notify", "install"], label: "notify.install", reload: true) { _ in "The daemon task is installed; it ticks every minute" } + run(["hub", "notify", "install"], label: "notify.install", failure: "The daemon task was not added.", reload: true) { _ in + HubNotifyNotice(text: "The daemon task is installed; it ticks every minute", tone: .success) + } } func sendTest(pr: String?) { - run(["hub", "notify", "test"] + (pr.map { ["--pr", $0] } ?? []), label: "notify.test") { _ in "Test notification posted" } + run(["hub", "notify", "test"] + (pr.map { ["--pr", $0] } ?? []), label: "notify.test", failure: "The test notification was not posted.") { _ in + HubNotifyNotice(text: "Test notification posted", tone: .success) + } } /// `decodesStatus`: the output is `status --json`. Only `load` sets it; `set --json` prints the config alone. + /// `failure`: the sentence a failed run shows; the CLI's own error goes behind Details, and Retry runs it again. private func run( - _ args: [String], label: String, decodesStatus: Bool = false, reload: Bool = false, - summary: ((Data) -> String?)? = nil + _ args: [String], label: String, failure: String, decodesStatus: Bool = false, reload: Bool = false, + summary: ((Data) -> HubNotifyNotice)? = nil ) { inFlight += 1 if decodesStatus { @@ -151,22 +260,52 @@ final class HubNotifyStore: ObservableObject { message = summary(data) } else if decodesStatus, generation == statusGeneration { // A silent `try?` left the popover on "Reading the notification settings…" forever - // whenever the CLI's JSON and this Decodable drifted apart. + // whenever the CLI's JSON and this Decodable drifted apart. A retry cannot fix that drift. do { status = try HubNotifyStatus.decode(data) } catch { - message = "Reading tools hub notify status --json failed: \(error)" + message = HubNotifyNotice( + text: failure, tone: .error, + detail: "The app and the tools command expect different formats. Updating both usually fixes it.", + more: "tools \(args.joined(separator: " ")): \(error)" + ) } } if reload { load() } case .failure(let error): span.end("failed") - message = "\(error)" + message = HubNotifyNotice(text: failure, tone: .error, more: "\(error)") { [weak self] in + self?.message = nil + self?.run(args, label: label, failure: failure, decodesStatus: decodesStatus, reload: reload, summary: summary) + } } } } } +/// The note under the bell's settings and the PR activity page, with Retry when a retry can help. VoiceOver hears a +/// failure, since it lands after the click that caused it. +struct HubNotifyNoticeView: View { + @ObservedObject var store: HubNotifyStore + /// The settings search draws the PR activity page hidden to learn its rows; a note there is nobody's to hear. + @Environment(\.nativeSettingsIndexing) private var hidden + + var body: some View { + if let message = store.message { + NoticePill( + text: message.text, detail: message.detail, more: message.more, tone: message.tone, + fixes: message.retry.map { retry in [NoticeFix.button("Retry", symbol: "arrow.clockwise", perform: retry)] } ?? [] + ) { store.message = nil } + .onAppear { announce(message) } + .onChange(of: message.id) { _, _ in announce(message) } + } + } + + private func announce(_ message: HubNotifyNotice) { + if !hidden, message.tone != .success { GenAnnounce.post(message.text) } + } +} + /// The bell: filled while notifications are on and at least one repo is watched. struct HubNotifyButton: View { @ObservedObject var prs: PRsModel @@ -179,7 +318,7 @@ struct HubNotifyButton: View { } var body: some View { - IconButton(systemName: active ? "bell.fill" : "bell", tooltip: "PR notifications: which events, which repos") { open.toggle() } + IconButton(systemName: active ? "bell.fill" : "bell", tooltip: "PR notifications: which events, which projects") { open.toggle() } .popover(isPresented: $open, arrowEdge: .bottom) { HubNotifySettings(prs: prs, store: store) } @@ -190,7 +329,9 @@ struct HubNotifyButton: View { struct HubNotifySettings: View { @ObservedObject var prs: PRsModel @ObservedObject var store: HubNotifyStore - @State private var find = PanelFindModel(scope: "notify", title: "repos and recent events") + @State private var find = PanelFindModel(scope: "notify", title: "projects and recent events") + /// Failing projects whose error is open under their row. + @State private var openProblems: Set<String> = [] /// The hub's projects (one checkout each) plus every configured repo, by path. private var repoPaths: [(path: String, name: String)] { @@ -209,15 +350,13 @@ struct HubNotifySettings: View { PanelFindBar(find: find) if let status = store.status { content(status) - } else { + } else if store.busy || store.message == nil { SkeletonLines(count: 4) .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Reading the notification settings") } - if let message = store.message { - NoticePill(text: message, isError: message.contains("exited") || message.contains("failed")) { store.message = nil } - } + HubNotifyNoticeView(store: store) } .padding(14) .frame(width: 400) @@ -225,7 +364,7 @@ struct HubNotifySettings: View { .panelFind(find, revision: store.status) { findRows } } - /// What ⌘F searches here: the repo names and the recent events, as shown. + /// What ⌘F searches here: the project names and the recent events, as shown. private var findRows: [PanelFindRow] { repoPaths.map { PanelFindRow(id: "repo:\($0.path)", fields: [PanelFindField("name", $0.name)]) } + (store.status?.recent.prefix(5) ?? []).map { PanelFindRow(id: "event:\($0.id)", fields: [PanelFindField("message", $0.message)]) } @@ -238,6 +377,7 @@ struct HubNotifySettings: View { Toggle("PR notifications", isOn: Binding(get: { config.enabled }, set: { store.set(["--enabled", $0 ? "on" : "off"]) })) .toggleStyle(.switch) .font(.system(size: 13, weight: .semibold)) + .instantTooltip(HubNotifyWords.enabledDetail) Spacer() if store.busy { ProgressView().controlSize(.small) } } @@ -247,8 +387,8 @@ struct HubNotifySettings: View { .font(.system(size: 11)) .foregroundColor(ReviewPalette.dim) .fixedSize(horizontal: false, vertical: true) - if status.daemonTask == false { - daemonHint + if let fix = status.daemonFixTitle { + daemonHint(status.daemonTitle, fix: fix) } section("Events") @@ -259,19 +399,30 @@ struct HubNotifySettings: View { )) .toggleStyle(.checkbox) .font(.system(size: 12)) + .instantTooltip(event.detail) } + // The same switch, choices and words as Settings' PR activity page (H17): a stepper here once took any minute + // while the page offered fixed choices. HStack(spacing: 14) { Toggle("Only my PRs", isOn: Binding(get: { config.onlyMine }, set: { store.set(["--only-mine", $0 ? "on" : "off"]) })) .toggleStyle(.checkbox) - .instantTooltip("Only PRs/MRs you opened") - Stepper(value: Binding(get: { config.intervalMinutes }, set: { store.set(["--interval", String($0)]) }), in: 1...60) { - Text(verbatim: "Every \(config.intervalMinutes) min").font(.system(size: 12, design: .monospaced)) + .instantTooltip(HubNotifyWords.onlyMineDetail) + Spacer(minLength: 0) + Picker("Cadence", selection: Binding( + get: { config.intervalMinutes }, + set: { store.set(["--interval", String($0)]) } + )) { + ForEach(HubNotifyWords.cadenceOptions(including: config.intervalMinutes), id: \.self) { + Text(HubNotifyWords.cadenceTitle($0)).tag($0) + } } - .instantTooltip("Minutes between polls of a repo without its own cadence (Settings → Notifications → PR activity); a failing repo backs off up to an hour") + .pickerStyle(.menu) + .fixedSize() + .instantTooltip(HubNotifyWords.cadenceDetail) } .font(.system(size: 12)) - section("Repos") + section("Projects") ScrollView { VStack(alignment: .leading, spacing: 2) { ForEach(repoPaths, id: \.path) { repo in @@ -305,25 +456,37 @@ struct HubNotifySettings: View { HStack(spacing: 12) { Button("Check now") { store.pollNow() } - .instantTooltip("Poll every watched repo now, ignoring the interval and any backoff") + .instantTooltip(HubNotifyWords.pollNowDetail) Button("Send a test") { store.sendTest(pr: prs.selected.map { "\($0.project)\($0.isGitLab ? "!" : "#")\($0.number)" }) } .instantTooltip("Post one notification marked as a test; its click opens the hub at the selected PR") Spacer() Button("Config file") { PathOpener.cursor(status.configPath) } - .instantTooltip(status.configPath) + .instantTooltip("Open \((status.configPath as NSString).abbreviatingWithTildeInPath) in Cursor") + Button("Settings…") { FeatureSettingsLaunch.open(pageID: PRActivitySettingsPages.pageID) } + .instantTooltip("Open the PR activity page in Settings: a cadence and events per project") } .buttonStyle(.genHoverPlain()) .font(.system(size: 11.5)) .disabled(store.busy) } - private var daemonHint: some View { - HStack(spacing: 6) { + /// Nothing polls, or the daemon task ticks at an old cadence: the button Settings has, in the same words (H8). + private func daemonHint(_ title: String, fix: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { Image(systemName: "exclamationmark.triangle").foregroundColor(ReviewPalette.modified) - Text("Nothing polls yet. Run `tools hub notify install` once to add the daemon task.") + Text(title) .font(.system(size: 11)) .fixedSize(horizontal: false, vertical: true) - IconButton(systemName: "doc.on.doc", tooltip: "Copy the command") { PathOpener.copy("tools hub notify install") } + Spacer(minLength: 4) + Button(fix) { store.installDaemonTask() } + .buttonStyle(.genHover()) + .font(.system(size: 11.5)) + .disabled(store.busy) + .instantTooltip(HubNotifyWords.installDetail) + .contextMenu { + Button("Copy Command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } + } + .accessibilityIdentifier("notify.install") } } @@ -341,43 +504,44 @@ struct HubNotifySettings: View { let repo = config.repos[path] let watched = repo?.enabled == true let problem = watched ? state.repos.values.first { $0.path == path && $0.lastError != nil } : nil - return HStack(spacing: 6) { - Toggle(isOn: Binding(get: { watched }, set: { store.set(["--repo", path, "--repo-enabled", $0 ? "on" : "off"]) })) { - FindText(name, field: "name").font(.system(size: 12)) - } - .toggleStyle(.checkbox) - .instantTooltip(path) - if let problem, let error = problem.lastError { - Image(systemName: "exclamationmark.circle") - .font(.system(size: 11)) - .foregroundColor(ReviewPalette.removed) - .instantTooltip("\(problem.failures) failed polls; next try \(problem.nextAt ?? "soon")\n\(error)") - } - Spacer(minLength: 4) - if watched { - MenuButton(items: { - HubNotifyEvent.allCases.map { event -> MenuButtonItem in - let own = repo?.events?[event.rawValue] - let effective = own ?? config.events[event.rawValue] ?? false - return .action("\(event.title)\(own == nil ? "" : " (this repo)")", checked: effective) { - store.set(["--repo", path, "--event", "\(event.rawValue)=\(effective ? "off" : "on")"]) + let showsProblem = problem != nil && openProblems.contains(path) + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Toggle(isOn: Binding(get: { watched }, set: { store.set(["--repo", path, "--repo-enabled", $0 ? "on" : "off"]) })) { + FindText(name, field: "name").font(.system(size: 12)) + } + .toggleStyle(.checkbox) + .instantTooltip(path) + if let problem { + // The tooltip says what happened; a click shows the host's own error below the row (H21). + IconButton( + systemName: "exclamationmark.circle", + tooltip: showsProblem ? "Hide the error" : "\(problem.problemText) Click to see the error.", + size: 11, tint: ReviewPalette.modified + ) { + if showsProblem { openProblems.remove(path) } else { openProblems.insert(path) } + } + } + Spacer(minLength: 4) + if watched { + MenuButton(items: { + HubNotifyWords.eventItems(path: path, repo: repo, config: config, store: store) + }) { + HStack(spacing: 3) { + Text(HubNotifyWords.eventsSummary(repo: repo, config: config)).font(.system(size: 11)) + Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) } - } + [ - .divider, - .action("Follow the global switches", enabled: repo?.events != nil) { - store.set(["--repo", path, "--reset-repo-events"]) - }, - ] - }) { - HStack(spacing: 3) { - Text(repo?.events == nil ? "All events" : "Own events").font(.system(size: 11)) - Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) } + .fixedSize() + .instantTooltip(HubNotifyWords.eventsDetail) } - .fixedSize() - .instantTooltip("Which events this repo posts; the global switches apply unless you change one here") + } + .frame(height: 24) + if showsProblem, let problem { + NoticePill(text: problem.problemText, more: problem.lastError, tone: .warning) + .padding(.leading, 20) + .padding(.bottom, 4) } } - .frame(height: 24) } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift index 15c7a88cc9..d92a4f699b 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift @@ -343,6 +343,8 @@ final class PRsModel: ObservableObject { /// The range a link's `commit=<from>..<to>` set, so the commits list marks its commits even when it spans /// the whole PR; any other scope (the whole PR, one commit) is not it. @Published private(set) var linkedRange: DiffScope? + /// The local-commit lookup still running for a link; a newer link or the reader's own pick clears it. + private var rangeLookup: UUID? /// What a link asked of a PR and could not get (its commit is not in the PR), in that PR's header until /// dismissed or another PR opens. Not in `errors`: every list load replaces those. @Published var notice: PRNotice? @@ -387,7 +389,7 @@ final class PRsModel: ObservableObject { private var freshGeneration = -1 /// The key of the rows on screen (the cache paints before the fresh answer). private var appliedKey: String? - private var lastLoad: Date? + private(set) var lastLoad: Date? private var searchTask: Task<Void, Never>? /// PR details being fetched, and when each last arrived fresh. private var detailLoading: Set<String> = [] @@ -571,6 +573,7 @@ final class PRsModel: ObservableObject { /// of 7 or more hex characters) selects that commit of the PR the same way (`pickPendingCommit`). func request(_ ref: HubPRRef, reveal: PRReveal? = nil, commit: String? = nil) { notice = nil + rangeLookup = nil pendingReveal = reveal pendingCommit = nil if let commit { @@ -735,7 +738,7 @@ final class PRsModel: ObservableObject { let settled = (detailFetched[id].map { $0 >= pending.asked } ?? false) && reviewHeadSha == shown.headSha guard let commit = HubCommitSha.find(pending.sha, in: commits) else { if settled { - showMissingCommit(pending, missing: pending.sha, in: shown, review: review) + pickLocalCommits(pending, in: shown, commits: commits, review: review) } return } @@ -743,7 +746,7 @@ final class PRsModel: ObservableObject { if let to = pending.to { guard let found = HubCommitSha.find(to, in: commits) else { if settled { - showMissingCommit(pending, missing: to, in: shown, review: review) + pickLocalCommits(pending, in: shown, commits: commits, review: review) } return } @@ -760,6 +763,64 @@ final class PRsModel: ObservableObject { } } + /// A link's commit that the forge does not list may be one the PR's checkout has not pushed yet: look there before + /// saying the PR does not hold it. The diff then comes from the checkout, which the review reads anyway. + private func pickLocalCommits(_ pending: PRCommitPick, in shown: HubPR, commits: [HubPRDetail.Commit], review: ReviewModel) { + pendingCommit = nil + let repo = review.repo.path + let ends = [pending.sha] + (pending.to.map { [$0] } ?? []) + // The newest commit the forge lists stands in for an unknown pushed head; with neither, nothing can be checked. + guard let head = shown.headSha ?? commits.last?.sha else { + showMissingCommit(pending, missing: pending.to ?? pending.sha, in: shown, review: review) + return + } + let pushed = Set(commits.map(\.sha)) + // A newer link or the reader's own pick replaces this one while git runs (`request`, `showCommit`). + let lookup = UUID() + rangeLookup = lookup + Task.detached(priority: .userInitiated) { [weak self] in + let found = HubLocalCommits.resolve(ends: ends, repo: repo, head: head, pushed: pushed) + await MainActor.run { + guard let self, self.rangeLookup == lookup, self.reviewPRID == shown.id, self.review === review else { return } + self.rangeLookup = nil + switch found { + case .success(let local): + self.showLocalCommits(local, pending: pending, in: commits, pr: shown, review: review) + case .failure(.unknown(let sha)): + self.showMissingCommit(pending, missing: sha, in: shown, review: review) + case .failure(.unrelated), .failure(.offBranch): + self.showMissingCommit(pending, missing: pending.to ?? pending.sha, in: shown, review: review) + } + } + } + } + + /// The link's commits from the checkout: the range from the commit before its first one (the PR's own base + /// when that first commit is the PR's first), and a note that some of them are not pushed. + func showLocalCommits(_ local: HubLocalCommits, pending: PRCommitPick, in commits: [HubPRDetail.Commit], pr: HubPR, review: ReviewModel) { + HubDefaults.store.set(true, forKey: "hub.prs.showDiff") + HubDefaults.store.set(false, forKey: "hub.prs.fold.commits") + HubPerf.log("prs.commit \(pr.label) \(local.first.prefix(10))\(local.last.map { "..\($0.prefix(10))" } ?? "") local unpushed=\(local.unpushed)") + let scope: DiffScope + if let last = local.last { + let label = "\(pr.label) \(local.first.prefix(7))..\(last.prefix(7)) (\(Plural.count(local.count, "commit")))" + // Only the PR's first commit takes the PR's own base; any other first commit starts at its parent. + if commits.first?.sha == local.first, + case .range(let base, _, _, let fallback) = Self.scope(pr, detail: details[pr.id], fetch: fetchedHead(pr)) { + scope = .range(base: base, head: last, label: label, fallbackBase: fallback) + } else { + scope = .range(base: local.first + "^", head: last, label: label) + } + linkedRange = scope + } else { + scope = .commit(sha: local.first, title: local.title) + } + if review.scope != scope { review.setScope(scope) } + if local.unpushed > 0 { + notice = PRNotice(ref: pending.ref, text: "\(Plural.count(local.unpushed, "commit")) of this link \(local.unpushed == 1 ? "is" : "are") not pushed yet; the diff comes from \((review.repo.path as NSString).abbreviatingWithTildeInPath)", isError: false) + } + } + /// A link's `commit=<from>..<to>`: those commits of the PR, both ends included, in either order. func showLinkedRange(_ one: HubPRDetail.Commit, _ other: HubPRDetail.Commit, in commits: [HubPRDetail.Commit], pr: HubPR, review: ReviewModel) { guard let a = commits.firstIndex(of: one), let b = commits.firstIndex(of: other) else { return } @@ -1378,6 +1439,7 @@ final class PRsModel: ObservableObject { guard let review, !commit.sha.isEmpty else { return false } // The reader picked one: a link's commit still waiting must not take the diff back. pendingCommit = nil + rangeLookup = nil review.setScope(.commit(sha: commit.sha, title: commit.title)) return true } @@ -1385,6 +1447,7 @@ final class PRsModel: ObservableObject { /// Back from one commit to the whole PR. func showWholePR() { pendingCommit = nil + rangeLookup = nil guard let pr = selected else { return } review?.setScope(Self.scope(pr, detail: details[pr.id], fetch: fetchedHead(pr))) } @@ -1413,15 +1476,16 @@ struct PRListView: View { @StateObject private var prefs = GroupPrefs(key: "prs.repos") @ObservedObject private var readiness = PRReadinessStore.shared @GenReduceMotion private var reduceMotion + /// The list's keyboard focus: a click on a row gives it, then Up and Down move the selection (`SidebarArrowKeys`). + @FocusState private var listFocused: Bool private var statePicker: some View { - Picker("", selection: Binding(get: { prs.state }, set: { prs.state = $0; prs.reload() })) { - Text("Open").tag("open") - Text("Merged").tag("merged") - Text("All").tag("all") - } - .pickerStyle(.segmented) - .labelsHidden() + GenSegmentedTabs( + "PR state", + items: [.init("open", "Open"), .init("merged", "Merged"), .init("all", "All")], + selection: Binding(get: { prs.state }, set: { prs.state = $0; prs.reload() }), + minSegmentWidth: 52 + ) .instantTooltip("Which PRs/MRs to list") } @@ -1491,6 +1555,41 @@ struct PRListView: View { .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Loading PRs and MRs") + } else if prs.prs.isEmpty, !prs.loading, prs.lastLoad != nil, prs.errors.isEmpty { + // A filter the server found nothing for: reloading repeats the same query, so the way + // out is clearing it. + let filtered = !prs.mineOnly && !model.filter.trimmed.isEmpty + EmptyState( + symbol: "arrow.triangle.pull", + text: prs.mineOnly ? "None of your PRs/MRs match." : "No PRs/MRs match.", + detail: prs.mineOnly ? "Turn off Mine to see everyone's." + : filtered ? "Nothing matches “\(model.filter)”." : "Try another state, or reload.", + actionTitle: prs.mineOnly ? "Show everyone's" : filtered ? "Clear filter" : "Reload", + action: { + if prs.mineOnly { + prs.mineOnly = false + } else if filtered { + model.filter = "" + return + } + prs.reload() + } + ) + .padding(.top, 24) + } else if groups.isEmpty, !prs.prs.isEmpty, !prs.loading { + if model.filter.trimmed.isEmpty { + EmptyState( + text: "None of your PRs/MRs match.", + actionTitle: "Show everyone's" + ) { prs.mineOnly = false; prs.reload() } + .padding(.top, 24) + } else { + EmptyState( + text: "No PR or MR matches “\(model.filter)”", + actionTitle: "Clear filter" + ) { model.filter = "" } + .padding(.top, 24) + } } ForEach(groups, id: \.project) { group in Section { @@ -1541,6 +1640,11 @@ struct PRListView: View { loaded: !prs.prs.isEmpty, prefs: prefs )) + .modifier(SidebarArrowKeys( + ids: { [collapsed = prefs.collapsed] in groups.filter { !collapsed.contains($0.project) }.flatMap { $0.rows.map(\.id) } }, + selection: { prs.selectedID }, + select: { id in if let pr = prs.prs.first(where: { $0.id == id }) { prs.select(pr, opened: true) } }, + proxy: proxy, focused: $listFocused)) } } // The list's own layout width: a sidebar drag that re-lays it out per step flips this per step. @@ -1648,7 +1752,7 @@ struct PRListView: View { // New and updated rows after a refresh flash once. .swrFlash(prs.changed.contains(pr.id)) .contentShape(Rectangle()) - .rowButton(cornerRadius: 8) { prs.select(pr, opened: true) } + .rowButton(cornerRadius: 8) { listFocused = true; prs.select(pr, opened: true) } .padding(.horizontal, 6) .instantTooltip("\(pr.label) \(pr.headBranch) → \(pr.baseBranch)\nRight-click for its web pages") // The row stays one button (select); its labels' web pages live here, since a link inside @@ -1779,6 +1883,8 @@ struct PRStateIcon: View { .foregroundColor(tone.color) .frame(width: 16) .instantTooltip(tone.title) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: tone.title)) } } @@ -1818,17 +1924,28 @@ struct CIBadge: View { default: return ("clock", ReviewPalette.dim) } }() + let sentence: String = { + switch ci { + case "success": return "All checks passed" + case "failed": return "Some checks failed" + case "running": return "Checks are running" + case "pending": return "Checks are waiting to start" + default: return "The checks report “\(ci)”" + } + }() let icon = Image(systemName: symbol).font(.system(size: 11)).foregroundColor(color) if let url { Button { ExternalOpener.open(url) } label: { icon } .buttonStyle(.genHoverIcon()) - .instantTooltip("CI: \(ci). Open the checks\n\(url.absoluteString)") - .accessibilityLabel(Text("CI: \(ci)")) + .instantTooltip("\(sentence). Open the checks\n\(url.absoluteString)") + .accessibilityLabel(Text(verbatim: sentence)) .accessibilityRemoveTraits(.isButton) .accessibilityAddTraits(.isLink) .accessibilityValue(Text(url.absoluteString)) } else { - icon.instantTooltip("CI: \(ci)") + icon.instantTooltip(sentence) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: sentence)) } } } @@ -1950,8 +2067,8 @@ struct PRDetailView: View { CIBadge(ci: pr.ci, url: detail?.webUrls?.checks.flatMap(URL.init(string:))) PRReadinessHeaderChip(pr: pr) Spacer() - if let notice = model.notice { - NoticePill(text: notice, isError: notice.contains("failed") || notice.hasPrefix("cmux:")) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } if prs.review != nil { IconButton(systemName: "arrow.up.right.square", tooltip: "Open this review in its own window: the same diff, threads and proposal") { @@ -1975,7 +2092,7 @@ struct PRDetailView: View { .popover(isPresented: $reviewing, arrowEdge: .bottom) { LaunchPicker(mode: .new(cwd: path, name: "review \(pr.repo) \(pr.label)", prompt: Self.reviewPrompt(pr))) { outcome in reviewing = false - if let notice = outcome.notice { model.notice = notice } + model.postLaunchNotice(outcome) } } // The palette's "review": the same popover, with the command shown before Start. @@ -1995,7 +2112,7 @@ struct PRDetailView: View { .popover(isPresented: $launching, arrowEdge: .bottom) { LaunchPicker(mode: .new(cwd: path, name: "\(pr.repo) \(pr.label)")) { outcome in launching = false - if let notice = outcome.notice { model.notice = notice } + model.postLaunchNotice(outcome) } } } @@ -2051,7 +2168,7 @@ struct PRDetailView: View { .foregroundColor(ReviewPalette.dim) // A row of its own: in the title row beside the title it had no width and wrapped a word per line. if let notice = prs.notice, notice.ref.matches(pr) { - NoticePill(text: notice.text, isError: true) { prs.notice = nil } + NoticePill(text: notice.text, isError: notice.isError) { prs.notice = nil } .padding(.top, 6) } } @@ -2178,7 +2295,7 @@ struct PRDetailView: View { .tint(Color(red: 0.62, green: 0.78, blue: 1)) .environment(\.openURL, OpenURLAction { url in if let id = PRDescriptionLinker.prID(from: url) { - if !prs.open(id: id) { model.notice = "That PR is no longer in the list" } + if !prs.open(id: id) { model.post("That PR is no longer in the list", .warning) } } else { ExternalOpener.open(url) } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift index a246f1af48..8edd38546e 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift @@ -150,6 +150,17 @@ enum TimelineRange: String, CaseIterable { } } + /// The next range that covers more, for an empty feed's "Show …" button (H16); nil when none does: Last 30 + /// days is the longest, and a custom range was picked on purpose. + var wider: TimelineRange? { + switch self { + case .lastHour: return .last24h + case .last24h, .today, .yesterday, .sinceLastVisit: return .last7 + case .last7: return .last30 + case .last30, .custom: return nil + } + } + /// The range in local time; a missing last visit falls back to the last 24 hours. func interval(now: Date = Date(), lastVisit: Date?, customFrom: Date, customTo: Date, calendar: Calendar = .current) -> DateInterval { let today = calendar.startOfDay(for: now) @@ -210,8 +221,8 @@ final class HubTimelineModel: ObservableObject { @Published private(set) var loadedAt: Date? @Published private(set) var error: String? @Published private(set) var hasMore = false - /// Why the last "Load older" failed; the footer shows it beside the button (the page is not empty, - /// so the empty-list error line never renders then). + /// Why the last "Load older" failed, as the CLI said it; the footer shows one sentence beside the button and this + /// behind Details (the page is not empty, so the list's own failure note never renders then). @Published private(set) var olderError: String? private var nextBefore: String? /// The page on screen is the last run's for this range and filters, from disk; the fresh one is loading. @@ -312,6 +323,20 @@ final class HubTimelineModel: ObservableObject { range.interval(lastVisit: lastVisit, customFrom: customFrom, customTo: customTo) } + /// A filter of the feed is on: a hidden kind, one project, an author, or "only what needs me". The sidebar's text + /// filter is the hub's own (`HubModel.filter`). + var hasFilters: Bool { + !hidden.isEmpty || project != nil || author != .all || needsMe + } + + /// Shows every kind, project and author again (an empty feed's "Clear filters"). + func clearFilters() { + hidden = [] + project = nil + author = .all + needsMe = false + } + /// The rows the list shows: hidden kinds, the project and the sidebar filter text. func shown(filter: String) -> [TimelineEvent] { let needle = filter.trimmed.lowercased() @@ -493,7 +518,7 @@ final class HubTimelineModel: ObservableObject { self.hasMore = envelope.hasMore ?? false self.nextBefore = envelope.nextBefore case .failure(let failure): - self.olderError = "Could not read older activity: \(failure)" + self.olderError = "\(failure)" } } } @@ -673,7 +698,21 @@ enum TimelinePrompts { } } +// Activity's notes go through `HubModel.post(_:_:)` with the tone the code that posts them knows (H5), so the same note +// shows the same colour in every pane's header (`HubNotice`, Hub/HubWindow.swift). + extension HubModel { + /// A launch (resume, a new agent) from an Activity or PR row as a note with its tone: a start is success, a + /// failure is an error, a cancel says nothing. + @MainActor + func postLaunchNotice(_ outcome: LaunchOutcome) { + switch outcome { + case .cancelled: return + case .launched(let text): post(text, .success) + case .failed(let text): post(text, .error) + } + } + /// A timeline row's click: the session, the commit in its repository's diff, the PR, or the decision's session. @MainActor func openTimelineEvent(_ event: TimelineEvent) { @@ -695,7 +734,7 @@ extension HubModel { @MainActor func showTimelineCommit(_ event: TimelineEvent, sha: String?, title: String) { guard let repo = event.repo, let sha, FileManager.default.fileExists(atPath: repo) else { - notice = "This repository is not on this Mac." + post("This repository is not on this Mac.", .warning) return } showCommit(repo: repo, sha: sha, title: title) @@ -725,7 +764,7 @@ extension HubModel { @MainActor func openTimelineSession(_ event: TimelineEvent, transcriptQuery query: String? = nil) { guard let session = timelineSession(event) else { - notice = "This row names no session." + post("This row names no session.", .warning) return } if !sessions.contains(where: { $0.id == session.id }) { @@ -764,7 +803,7 @@ extension HubModel { @MainActor func openTimelinePR(_ event: TimelineEvent, reveal path: String? = nil) { guard let ref = event.pr.flatMap({ pr in HubPRRef(pr.ref)?.withPage(pr.url) }) else { - notice = "This row names no PR." + post("This row names no PR.", .warning) return } setMode(.prs) @@ -779,7 +818,11 @@ extension HubModel { DispatchQueue.global(qos: .userInitiated).async { let failure = TerminalHosts.current.focus(sessionId: id, provider: provider) DispatchQueue.main.async { [weak self] in - self?.notice = failure.map { "No cmux pane found for this session: \($0.suffix(120))" } ?? "Focused the session's cmux pane" + if let failure { + self?.post("No cmux pane found for this session: \(failure.suffix(120))", .error) + } else { + self?.post("Focused the session's cmux pane", .success) + } } } } @@ -832,7 +875,7 @@ extension HubModel { if let url = self.timelineHostURL(event) { ExternalOpener.open(url) } else { - self.notice = "The repository's origin is not known yet; try again in a moment." + self.post("The repository's origin is not known yet; try again in a moment.", .warning) } } add("copy", "number", "Copy the sha") { PathOpener.copy(event.sha ?? "") } @@ -840,14 +883,14 @@ extension HubModel { guard let self, let timeline else { return } if case .commit(let detail)? = timeline.details[event.id] { guard let first = detail.prs.first, let ref = HubPRRef(first.ref)?.withPage(first.url) else { - self.notice = "No PR of this repository contains \(event.shortSha ?? "this commit")." + self.post("No PR of this repository contains \(event.shortSha ?? "this commit").", .warning) return } self.setMode(.prs) self.prs.request(ref) } else { if timeline.isExpanded(event) { timeline.loadDetail(event) } else { timeline.toggle(event) } - self.notice = "Reading the commit's PRs; open the row to pick one." + self.post("Reading the commit's PRs; open the row to pick one.", .success) } } if let repo = event.repo, let sha = event.sha { @@ -859,7 +902,7 @@ extension HubModel { if let url = self.timelineHostURL(event, compare: true) { ExternalOpener.open(url) } else { - self.notice = "No compare page: a new branch, or the origin is not known yet." + self.post("No compare page: a new branch, or the origin is not known yet.", .warning) } } add("pr", "arrow.triangle.pull", "Open the PR in the hub", disabled: event.pr == nil) { [weak self] in self?.openTimelinePR(event) } @@ -882,7 +925,7 @@ extension HubModel { add("resolve", resolved ? "checkmark.circle.fill" : "checkmark.circle", resolved ? "Reopen this thread" : "Resolve this thread") { [weak self, weak timeline] in guard let timeline, let url = event.pr?.url, let thread = event.threadId else { return } timeline.threadStore(for: url).resolve(thread: thread, resolved: !resolved) - self?.notice = resolved ? "Reopening the thread…" : "Resolving the thread…" + self?.post(resolved ? "Reopening the thread…" : "Resolving the thread…", .success) } add("host", "arrow.up.right.square", "Open the comment on the host") { [weak self] in if let url = self?.timelineHostURL(event) { ExternalOpener.open(url) } @@ -1144,18 +1187,18 @@ struct TimelineMain: View { PanelFindBar(find: find) ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + // A failed read says so in one sentence with Retry, above whatever page is still on screen (H7). + if let failure = timeline.error { + loadFailure(failure) + } if events.isEmpty, timeline.loading { SkeletonRows(count: 12, leading: .dot) .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Reading the activity") .padding(.top, 8) - } else if events.isEmpty { - Text(timeline.error ?? (timeline.events.isEmpty ? "Nothing happened in this range." : "Nothing matches these filters.")) - .font(.system(size: 13)) - .foregroundColor(ReviewPalette.dim) - .frame(maxWidth: .infinity) - .padding(.top, 40) + } else if events.isEmpty, timeline.error == nil { + emptyFeed } ForEach(days) { day in Section { @@ -1188,6 +1231,13 @@ struct TimelineMain: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + // A failed read lands after the click or refresh that asked for it, so VoiceOver hears it. + .onChange(of: timeline.error) { _, failure in + if failure != nil { GenAnnounce.post(timeline.events.isEmpty ? Self.loadFailedText : Self.refreshFailedText) } + } + .onChange(of: timeline.olderError) { _, failure in + if failure != nil { GenAnnounce.post(Self.olderFailedText) } + } // Only the flag is state: a resize step on the same side of the threshold changes nothing. .onGeometryChange(for: Bool.self, of: { $0.size.width < Self.compactWidth }) { compact = $0 } // The find re-runs when the shown rows change, a row folds open or closed, or a detail lands. @@ -1201,6 +1251,57 @@ struct TimelineMain: View { return events.map(\.rowID).joined(separator: "\n") + "\u{1}" + open.joined(separator: "\n") + (compact ? "\u{2}" : "") } + static let loadFailedText = "The activity could not be read." + static let refreshFailedText = "The activity could not be refreshed. The list shows the last page read." + static let olderFailedText = "Older activity could not be read." + + private func loadFailure(_ failure: String) -> some View { + NoticePill( + text: timeline.events.isEmpty ? Self.loadFailedText : Self.refreshFailedText, + more: failure, tone: .error, + fixes: [.button("Retry", symbol: "arrow.clockwise") { timeline.load(fresh: true) }] + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + + /// Nothing to show: why, and the way out (H16). Filters that hid every row offer Clear filters; a range that + /// could be longer offers the next longer one. + private var emptyFeed: some View { + let filtered = !timeline.events.isEmpty || timeline.hasFilters || !model.filter.trimmed.isEmpty + let wider = timeline.range.wider + return VStack(spacing: 0) { + EmptyState( + symbol: "clock.arrow.circlepath", + text: filtered ? "Nothing matches these filters." : "Nothing happened in this range." + ) + HStack(spacing: 10) { + if filtered { + GhostButton( + "Clear filters", symbol: "line.3.horizontal.decrease.circle", + tooltip: "Show every kind, project and author, and empty the filter field", + identifier: "timeline.empty.clearFilters" + ) { + timeline.clearFilters() + model.filter = "" + } + } + if let wider { + GhostButton( + "Show \(wider.title.lowercased())", symbol: "calendar", + tooltip: "Read a longer range: \(wider.title.lowercased())", + identifier: "timeline.empty.widenRange" + ) { + timeline.range = wider + } + } + } + } + .frame(maxWidth: .infinity) + .padding(.top, 24) + } + private func dayHeader(_ day: TimelineDay) -> some View { let title = day.title() return HStack { @@ -1249,12 +1350,8 @@ struct TimelineMain: View { if timeline.loadingOlder { ProgressView().controlSize(.small) } else if let failure = timeline.olderError { - Label(failure, systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 11)) - .foregroundColor(ReviewPalette.removed) - .lineLimit(2) - .truncationMode(.middle) - .textSelection(.enabled) + // "Load older" beside it is the retry; the CLI's own words wait behind Details, never cut. + NoticePill(text: Self.olderFailedText, more: failure, tone: .error) } if !timeline.truncated.isEmpty { Text("A page ends where its busiest source stopped reading: \(timeline.truncated.joined(separator: ", ")).") @@ -1292,8 +1389,8 @@ struct TimelineMain: View { } .titlebarLabel() Spacer() - if let notice = model.notice { - NoticePill(text: notice, isError: notice.contains("failed") || notice.hasPrefix("No ")) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } if !timeline.warnings.isEmpty { Image(systemName: "exclamationmark.triangle.fill") @@ -1545,7 +1642,7 @@ struct TimelineRowView: View { if let url = model.timelineHostURL(event) { ExternalOpener.open(url) } else { - model.notice = "The repository's origin is not known yet; try again in a moment." + model.post("The repository's origin is not known yet; try again in a moment.", .warning) } } label: { text @@ -1596,17 +1693,17 @@ struct TimelineRowView: View { case .resume(let session): LaunchPicker(mode: .resume(session)) { outcome in popover = nil - if let notice = outcome.notice { model.notice = notice } + model.postLaunchNotice(outcome) } case .newAgent(let cwd, let name, let prompt): LaunchPicker(mode: .new(cwd: cwd, name: name, prompt: prompt)) { outcome in popover = nil - if let notice = outcome.notice { model.notice = notice } + model.postLaunchNotice(outcome) } case .reply(let event): TimelineReplyForm(event: event, timeline: timeline) { notice in popover = nil - if let notice { model.notice = notice } + if let notice { model.post(notice.text, notice.tone) } } } } @@ -1647,7 +1744,8 @@ private extension View { struct TimelineReplyForm: View { let event: TimelineEvent @ObservedObject var timeline: HubTimelineModel - let done: (String?) -> Void + /// The note the reply leaves, with its tone; nil for Cancel. + let done: ((text: String, tone: NoticePill.Tone)?) -> Void @State private var text = "" var body: some View { @@ -1670,11 +1768,11 @@ struct TimelineReplyForm: View { Button("Cancel") { done(nil) }.keyboardShortcut(.cancelAction) Button("Reply") { guard let url = event.pr?.url, let thread = event.threadId else { - done("This comment names no thread to reply in.") + done((text: "This comment names no thread to reply in.", tone: .warning)) return } timeline.threadStore(for: url).reply(thread: thread, body: text, draft: false) - done("Replying on \(event.pr?.ref ?? "the PR")…") + done((text: "Replying on \(event.pr?.ref ?? "the PR")…", tone: .success)) } .keyboardShortcut(.defaultAction) .disabled(text.trimmed.isEmpty) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift index faec75e931..32e5a97180 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift @@ -92,6 +92,12 @@ struct HubRequest { /// (`--menu "Go/Pull Request…"` opens the palette with "pr " typed). Verifies the menu in a snapshot. var menu: String? var activate = true + /// Who asked for the place, for the history (`--actor user|agent|link`; a request without it counts as a link), + /// why (`--note`), the agent's session (`--from`) and the entry `tools hub nav open` stored (`--nav`). + var actor: String? + var note: String? + var from: String? + var nav: String? /// The file `--reveal` names, in the PR `--pr` names. var prReveal: PRReveal? { @@ -162,6 +168,10 @@ struct HubRequest { } index += 1 case "--menu": menu = value; index += 1 + case "--actor": actor = value; index += 1 + case "--note": note = value; index += 1 + case "--from": from = value; index += 1 + case "--nav": nav = value; index += 1 case "--no-activate": activate = false default: break } @@ -187,6 +197,8 @@ struct HubRequest { package func runHub(_ args: [String]) -> Never { PerfLog.phase("hub.launch") + // A relaunch's place is the one the person left, not a link's. + let resuming = args.contains(HubPlace.resumeFlag) // `--resume` (a rebuild's relaunch) opens the place the hub last showed (Hub/HubPlace.swift). let args = HubPlace.expand(args) let request = HubRequest(args) @@ -229,6 +241,11 @@ package func runHub(_ args: [String]) -> Never { AppMainMenu.install(hub: model) } model.initialMode = mode + if !request.isScripted { + // Who asked for the launch's place: the first time the hub shows it, the history entry carries it. + model.pendingNav = resuming ? nil : HubNavRequest(request) + MainActor.assumeIsolated { model.connectNavLog() } + } if let panes = request.panes { model.panes = panes } if let worktree = request.worktree { model.selectedWorktree = WorktreeCleanup.selection(for: worktree) @@ -339,7 +356,8 @@ package func runHub(_ args: [String]) -> Never { MainActor.assumeIsolated { HubVisibility.shared.watch(window) } HubSingleInstance.serve { args in let later = HubRequest(args) - model.apply(later) + // An agent's place while agents only add to History: the view and the focus stay where they are. + guard model.apply(later) else { return } if let path = later.menu { MainActor.assumeIsolated { AppMainMenu.perform(path) } } @@ -377,6 +395,7 @@ package func runHub(_ args: [String]) -> Never { } } model.loadSessions() + MainActor.assumeIsolated { model.startSessionRefresh() } // The hub is a live monitor: transcripts stream in while it sits behind other windows. App Nap // throttled the whole process there (main-queue work ran 0.5–1 s late and even a dedicated // userInteractive sampler thread woke only at the end of each "stall"). Only this face opts out. @@ -413,6 +432,25 @@ private final class HubAppDelegate: NSObject, NSApplicationDelegate { } } +/// A note in a hub pane's header and its tone, stored together by the code that knows whether it failed: the headers +/// used to guess a failure from words ("failed", "cmux:"), and a note without such a word showed a failure in green. +struct HubNotice: Equatable { + let text: String + let tone: NoticePill.Tone + + init(_ text: String, _ tone: NoticePill.Tone) { + self.text = text + self.tone = tone + } + + /// A note set as plain text (`model.notice = "…"`) by a pane not moved to `post(_:_:)` yet: the words the + /// headers read before, in one place. Goes away with the last `String` setter. + init(guessing text: String) { + let failed = text.hasPrefix("cmux:") || text.hasPrefix("Could not") || text.contains("failed") || text.contains("not in") + self.init(text, failed ? .error : .success) + } +} + /// What the left column lists: agent sessions, the worktrees (branches) they worked in, PRs, the /// sessions waiting for an answer (Hub/HubInbox.swift), or today's activity (Hub/HubTimeline.swift). enum HubMode: String, CaseIterable { @@ -604,7 +642,19 @@ final class HubModel: ObservableObject { private var worktreeSessions: [String: [HubSession]]? @Published var loadingWorktrees = false @Published var selectedWorktree: String? - @Published var notice: String? + /// The note in the panes' headers, with the tone the code that posted it knows (`post(_:_:)`). + @Published var headerNotice: HubNotice? + /// The note as plain text, for panes that still set a `String`: it gets the tone the headers used to guess. + var notice: String? { + get { headerNotice?.text } + set { headerNotice = newValue.map(HubNotice.init(guessing:)) } + } + + /// Shows `text` in the panes' headers with `tone`; any hub file in this module posts through it (Activity included). + func post(_ text: String, _ tone: NoticePill.Tone) { + headerNotice = HubNotice(text, tone) + } + /// Only the latest commit lookup may navigate, and any later selection invalidates it. var commitLinkRequest = UUID() /// ⌘⇧F / palette "grep": the find-in-files panel is open with this query ("" = empty field). @@ -650,9 +700,24 @@ final class HubModel: ObservableObject { /// PRs mode state (`tools hub pr list/show`). Main-actor: created on the main thread in `runHub`. let prs: PRsModel - /// Back and forward (Hub/HubNavigation.swift). Taken from the published selection itself, so - /// every path that changes it (rows, the palette, a handed-over `--session`, "Open") is recorded. + /// Back, forward and the History list (Hub/HubNavigation.swift). Taken from the published selection itself, so + /// every path that changes it (rows, the palette, a handed-over `--session`, "Open", a link) is recorded. @Published private(set) var history = HubNavHistory() + /// The History popover (⌘Y, the clock beside the arrows). + @Published var historyOpen = false + /// The commit a link or an Activity row shows in the Worktrees diff: part of that place in the history. + @Published private(set) var shownCommit: HubShownCommit? + /// The commit a link selected in a PR's review: part of that PR's place while it stays selected. + private var prLinkedCommit: (prID: String, commit: String)? + /// The place an agent or a link asked for, with who and why, until the hub shows it. + var pendingNav: HubNavRequest? + /// A `repo=…&commit=…` link's commit as written (one sha or `<from>..<to>`), for the place `showCommit` shows. + var commitLinkRaw: String? + /// `tools hub nav`; nil in a scripted run, which never reads or writes the log. + private var navStore: HubNavStore? + /// Entries made before the log was read back: written once it is, unless they merged into its last entry. + private var navUnsaved: [String]? + private var navLimitWatch: AnyCancellable? private var navRecorder: AnyCancellable? private var navRecordPending = false private var navRestoring = false @@ -674,7 +739,10 @@ final class HubModel: ObservableObject { if !panes.contains(tab) { panes = HubTab.allCases.filter { panes.contains($0) || $0 == tab } } - MainActor.assumeIsolated { startNavRecorder() } + MainActor.assumeIsolated { + startNavRecorder() + startNavLog() + } // A scripted run with the transcript pane settles on that pane's first page (see `select`). firstPageObserver = NotificationCenter.default.addObserver(forName: HubSessionDetailHost.firstPageDone, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { @@ -695,6 +763,7 @@ final class HubModel: ObservableObject { prs.$selectedID.map { _ in () }.eraseToAnyPublisher(), inbox.$selectedID.map { _ in () }.eraseToAnyPublisher(), agents.$selectedID.map { _ in () }.eraseToAnyPublisher(), + $shownCommit.map { _ in () }.eraseToAnyPublisher(), ] navRecorder = Publishers.MergeMany(changes).sink { [weak self] in if self?.settlingLaunch == false { @@ -712,9 +781,164 @@ final class HubModel: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } self.navRecordPending = false - MainActor.assumeIsolated { HubPlace.record(self.navEntry, tab: self.tab) } - guard !self.navRestoring else { return } - MainActor.assumeIsolated { self.history.visit(self.navEntry) } + MainActor.assumeIsolated { self.recordPlace() } + } + } + + /// The History setting's cap, followed at once. + @MainActor + private func startNavLog() { + let preferences = HubDefaults.preferences + history.setLimit(preferences.navigationLimit) + navLimitWatch = preferences.$navigationLimit.dropFirst().sink { [weak self] limit in + self?.history.setLimit(limit) + } + } + + /// The log (`tools hub nav`): read back once, off the main thread, and put behind what this window did since it + /// opened; every later entry is written to it. Only the live hub connects: a scripted run and a test model + /// neither read nor write the person's log. + @MainActor + func connectNavLog() { + guard navStore == nil, !HubDefaults.isolated else { return } + let store = HubNavStore(limit: HubDefaults.preferences.navigationLimit) + navStore = store + navUnsaved = [] + HubNavStore.queue.async { [weak self] in + let span = HubPerf.begin("nav.load") + var older: [NavigationEntry<HubNavTarget>] = [] + do { + older = try store.load() + span.end("\(older.count) entries") + } catch { + span.end("failed") + PerfLog.mark("hub.nav load failed: \(error)") + } + DispatchQueue.main.async { + MainActor.assumeIsolated { self?.adoptNavLog(older) } + } + } + } + + @MainActor + private func adoptNavLog(_ older: [NavigationEntry<HubNavTarget>]) { + let merged = Set(history.adoptOlder(older)) + let unsaved = navUnsaved ?? [] + navUnsaved = nil + for id in unsaved where !merged.contains(id) { + if let entry = history.entry(id) { + writeNav(entry) + } + } + } + + /// Takes the place on screen into the history: a place with nothing picked is provisional, a place an agent or a + /// link asked for carries who and why, anything else is the person's. + @MainActor + private func recordPlace() { + let place = navEntry + HubPlace.record(place, tab: tab) + guard !navRestoring else { return } + if let linked = prLinkedCommit, place.mode == .prs, place.selection != linked.prID { + prLinkedCommit = nil + } + guard place.selection != nil else { + history.visit(navTarget(place), title: navTitle(place), provisional: true) + return + } + if let asked = pendingNav { + let age = Date().timeIntervalSince(asked.asked) + if navRequestMatches(asked) { + pendingNav = nil + if asked.pr != nil, let commit = asked.commit, place.mode == .prs, let id = place.selection { + prLinkedCommit = (id, commit) + } + let target = navTarget(place) + let title = navTitle(target) + if asked.restoring, let id = asked.id { + persistNav(history.retarget(id, to: target, title: title)) + } else { + persistNav(history.visit(target, title: title, actor: asked.actor, agentSession: asked.from, + note: asked.note, id: asked.id)) + } + return + } + if age > HubNavRequest.patience { + pendingNav = nil + HubPerf.log("nav.request expired: \(asked.title) never showed") + } else if age < HubNavRequest.settle { + // The hub's own steps toward the asked place (a mode switch, the old row still selected). + return + } + } + persistNav(history.visit(navTarget(place), title: navTitle(navTarget(place)))) + } + + /// The place on screen with the commit shown in it. + @MainActor + func navTarget(_ place: HubNavEntry) -> HubNavTarget { + switch place.mode { + case .prs: + if let linked = prLinkedCommit, linked.prID == place.selection { + return HubNavTarget(place: place, commit: linked.commit) + } + case .worktrees: + if let shown = shownCommit, shown.repo == place.selection { + return HubNavTarget(place: place, commit: shown.commit, repo: shown.repo) + } + default: + break + } + return HubNavTarget(place: place) + } + + /// Whether the hub now shows the place a request asked for. + @MainActor + func navRequestMatches(_ asked: HubNavRequest) -> Bool { + if let pr = asked.pr { + return mode == .prs && prs.selected.map(pr.matches) == true + } + if let commit = asked.commit { + let from = HubCommitSpan(commit)?.from ?? commit.lowercased() + return mode == .worktrees && shownCommit.map { $0.commit.lowercased().hasPrefix(from) && selectedWorktree == $0.repo } == true + } + if let worktree = asked.worktree { + return mode == .worktrees && selectedWorktree == WorktreeCleanup.selection(for: worktree) + } + if asked.agent != nil || asked.mode == .agents, asked.session != nil || asked.agent != nil { + return mode == .agents && agents.selectedID != nil + } + if let session = asked.session { + return mode == .sessions && (selectedID == session || selected?.sessionId.hasPrefix(session) == true) + } + if let wanted = asked.mode { + return mode == wanted + } + return false + } + + /// Writes a new or changed entry to the log, off the main thread, and one line to app-perf.log. + @MainActor + private func persistNav(_ entry: NavigationEntry<HubNavTarget>?) { + guard let entry else { return } + HubPerf.log("nav \(entry.actor.rawValue) \(entry.title)\(entry.note.map { " (\($0))" } ?? "")") + writeNav(entry) + } + + @MainActor + private func writeNav(_ entry: NavigationEntry<HubNavTarget>) { + guard let store = navStore else { return } + if navUnsaved != nil { + if navUnsaved?.contains(entry.id) == false { navUnsaved?.append(entry.id) } + return + } + let keep = history.limit + HubNavStore.queue.async { + do { + try store.record(entry, keep: keep) + } catch { + PerfLog.mark("hub.nav record failed: \(error)") + } } } @@ -742,55 +966,100 @@ final class HubModel: ObservableObject { @MainActor func goBack() { if let entry = history.goBack() { - restore(entry) + restore(entry, how: "back") } } @MainActor func goForward() { if let entry = history.goForward() { - restore(entry) + restore(entry, how: "forward") + } + } + + /// Any entry of the History list. + @MainActor + func hop(to id: String) { + if let entry = history.hop(to: id) { + restore(entry, how: "hop") } } - /// Shows a place from the history with the same methods the rows use; the changes it makes are - /// not recorded again. + /// Shows a place from the history with the same methods the rows use; the changes it makes are not recorded + /// again. A place an agent named (link keys only) opens as its link would, and its entry takes the form of + /// the place the hub shows once it is there. @MainActor - private func restore(_ entry: HubNavEntry) { + private func restore(_ entry: NavigationEntry<HubNavTarget>, how: String) { navRestoring = true - HubPerf.log("nav.restore \(entry.mode.rawValue) \(entry.selection ?? "-")") + let target = entry.target + HubPerf.log("nav.\(how) \(entry.actor.rawValue) \(entry.title)") + if let place = target.place { + restorePlace(place, commit: target["commit"], repo: target["repo"]) + } else { + var asked = HubNavRequest(HubRequest(target.arguments)) + asked?.actor = entry.actor + asked?.id = entry.id + asked?.restoring = true + pendingNav = asked + navigate(HubRequest(target.arguments)) + } + // The recorder's turn for these changes is already queued, so it runs first and skips them. + DispatchQueue.main.async { [weak self] in self?.navRestoring = false } + } + + @MainActor + private func restorePlace(_ entry: HubNavEntry, commit: String?, repo: String?) { setMode(entry.mode) - if let id = entry.selection { - switch entry.mode { - case .sessions: - select(id) - case .worktrees: - if let worktree = worktrees.first(where: { $0.path == id }) { - selectWorktree(worktree) + guard let id = entry.selection else { return } + switch entry.mode { + case .sessions: + select(id) + case .worktrees: + if let worktree = worktrees.first(where: { $0.path == id }) { + selectWorktree(worktree) + } else { + selectedWorktree = id + shownCommit = nil + } + if let commit, let repo { + // Said at once, so the place on screen is the entry while the commit is looked up again. + shownCommit = HubShownCommit(repo: id, commit: commit) + openCommitLink(commit, repo: repo) + } + case .prs: + if let commit { + prLinkedCommit = (id, commit) + } + if let pr = prs.prs.first(where: { $0.id == id }) { + if let commit, let ref = HubPRRef(id) { + prs.request(ref, commit: commit) } else { - selectedWorktree = id - } - case .prs: - if let pr = prs.prs.first(where: { $0.id == id }) { prs.select(pr) - } else { - prs.showUnavailable(id) - notice = prs.state == "all" - ? "That PR is not in the PR list any more." - : "That PR is not in the \(prs.state) list any more; pick All to find it." - } - case .inbox: - inbox.selectedID = id == Self.wholeList ? nil : id - case .timeline: - break - case .agents: - if id != Self.wholeList, let key = AgentTree.split(id) { - agents.select(parent: key.parent, child: key.child) } + } else { + prs.showUnavailable(id) + post(prs.state == "all" + ? "That PR is not in the PR list any more." + : "That PR is not in the \(prs.state) list any more; pick All to find it.", .warning) + } + case .inbox: + inbox.selectedID = id == Self.wholeList ? nil : id + case .timeline: + break + case .agents: + if id != Self.wholeList, let key = AgentTree.split(id) { + agents.select(parent: key.parent, child: key.child) } } - // The recorder's turn for these changes is already queued, so it runs first and skips them. - DispatchQueue.main.async { [weak self] in self?.navRestoring = false } + } + + /// A place's title with the commit shown in it: "PR #563 Fix it · commit 0751d8a971". + @MainActor + func navTitle(_ target: HubNavTarget) -> String { + guard let place = target.place else { return HubNavRequest(HubRequest(target.arguments))?.title ?? "Hub" } + let name = navTitle(place) + guard let commit = target["commit"] else { return name } + return "\(name) · commit \(commit.split(separator: ".").filter { !$0.isEmpty }.map { String($0.prefix(10)) }.joined(separator: ".."))" } /// What a history entry shows, for the arrows' tooltips. @@ -948,6 +1217,7 @@ final class HubModel: ObservableObject { func selectWorktree(_ worktree: HubWorktree) { selectedWorktree = worktree.path + shownCommit = nil notice = nil if review?.home.path != worktree.path { let next = ReviewModel(repo: URL(fileURLWithPath: worktree.path), options: DiffViewOptions.remembered(), session: sessions(for: worktree).first?.sessionId) @@ -962,10 +1232,11 @@ final class HubModel: ObservableObject { /// `--repo` + `--commit` link. The checkout is selected before the mode opens, so a first walk of /// the worktrees includes it; a launch that has not settled yet opens the mode when it settles. @MainActor - func showCommit(repo: String, sha: String, title: String, base: String? = nil) { + func showCommit(repo: String, sha: String, title: String, base: String? = nil, link: String? = nil) { cancelSessionNavigation() commitLinkRequest = UUID() selectedWorktree = repo + shownCommit = HubShownCommit(repo: repo, commit: link ?? String(sha.prefix(12))) notice = nil if didSettleLaunch || initialMode != .worktrees { setMode(.worktrees) @@ -982,7 +1253,7 @@ final class HubModel: ObservableObject { func showChange(path: String, line: Int?) { tab = .changes guard let review else { - notice = "This session's folder is not on this Mac." + post("This session's folder is not on this Mac.", .warning) return } // An absolute path finds its file in whichever ticked root holds it. @@ -1151,7 +1422,7 @@ final class HubModel: ObservableObject { return } - notice = "No session \(wanted.prefix(8)) in the last \(Self.olderSessionHours / 24) days" + post("No session \(wanted.prefix(8)) in the last \(Self.olderSessionHours / 24) days", .warning) if settle { if let first = sessions.first { select(first.id) @@ -1192,9 +1463,35 @@ final class HubModel: ObservableObject { } } + /// Keeps the session list current while the hub is open: a refresh every minute while the window can be seen. + /// The list used to load once at launch, so a session that worked all afternoon still read "active 53m ago" + /// (Martin, 2026-10-25). A failed refresh logs and keeps the old list, with no error banner. + @MainActor + func startSessionRefresh(every seconds: Double = 60) { + sessionRefresh?.cancel() + sessionRefresh = Task { @MainActor [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(for: .seconds(seconds)) } catch { return } + await HubVisibility.shared.untilVisible() + guard let self, !self.loadingSessions else { continue } + let generation = self.sessionsGeneration + do { + let rows = try await self.readSessions(Self.recentHours) + guard generation == self.sessionsGeneration else { continue } + self.applySessions(rows, flash: false) + self.writeSessionCache(rows, Self.recentHours) + } catch { + PerfLog.mark("hub.sessions.refresh failed: \(error)") + } + } + } + } + + private var sessionRefresh: Task<Void, Never>? + /// Shows `rows`, sliding new ones in; over a list already on screen, the new and moved ones flash. @MainActor - private func applySessions(_ rows: [HubSession]) { + private func applySessions(_ rows: [HubSession], flash: Bool = true) { var fresh = rows .filter { !($0.archived ?? false) } .sorted { $0.mtime > $1.mtime } @@ -1207,6 +1504,11 @@ final class HubModel: ObservableObject { guard fresh != sessions else { return } let before = Dictionary(sessions.map { ($0.id, "\($0.mtime)") }, uniquingKeysWith: { first, _ in first }) let moved = SWR.changed(before: before, after: fresh.map { ($0.id, "\($0.mtime)") }) + // A periodic refresh moves rows without the change flash: a row whose only news is a later time is not news. + guard flash else { + withAnimation(SWR.animation) { sessions = fresh } + return + } withAnimation(SWR.animation) { sessions = fresh changedSessions = moved @@ -1390,13 +1692,17 @@ final class HubModel: ObservableObject { // openInTerminal blocks on the terminal host's CLI (up to its timeout): never on the main actor. let name = (path as NSString).lastPathComponent let starting = "Starting a new Claude session in \(name)…" - notice = starting + post(starting, .success) Task.detached(priority: .userInitiated) { let failure = AgentLauncher.openInTerminal(name: name, cwd: path, command: ["tools", "claude", "run"]) // Only over its own "Starting…" line: a newer notice (a copied id, say) stays on screen. await MainActor.run { - if self.notice == starting { - self.notice = failure ?? "Started a new Claude session in \(name)." + if self.headerNotice?.text == starting { + if let failure { + self.post(failure, .error) + } else { + self.post("Started a new Claude session in \(name).", .success) + } } } } @@ -1416,13 +1722,13 @@ final class HubModel: ObservableObject { if let pr = prs.selected { if (pr.localWorktree ?? pr.repoRoot) == nil { // No checkout to review in, so the detail shows no "Review with agent" to open. - notice = "\(pr.label) has no local checkout to review in." + post("\(pr.label) has no local checkout to review in.", .warning) } else { setMode(.prs) prs.reviewRequested = true } } else { - notice = "Select a PR first, then run review again." + post("Select a PR first, then run review again.", .warning) } case .toggleGlass: toggleGlass() @@ -1471,7 +1777,7 @@ final class HubModel: ObservableObject { // A fresh launch applies its flags before the first list loads. HubHandoffRequests.shared.pending = HubHandoffRequests.firstSelection } else { - notice = "No session to hand off: select one, or pass --session." + post("No session to hand off: select one, or pass --session.", .warning) } } } @@ -1498,8 +1804,33 @@ final class HubModel: ObservableObject { } } - /// A later `GenesisTools --hub …` handed to this hub: the same flags as a first launch. - func apply(_ request: HubRequest) { + /// A later `GenesisTools --hub …` handed to this hub: the same flags as a first launch. A place an agent or a + /// link asked for is recorded with who and why; while agents only add to History (Settings → Hub → History), + /// it goes into the list and the view stays. Returns false when the hub did not move, so the window stays back. + @discardableResult + func apply(_ request: HubRequest) -> Bool { + guard let asked = HubNavRequest(request) else { + navigate(request) + return true + } + if asked.actor != .user, MainActor.assumeIsolated({ HubDefaults.preferences.agentNavigation }) == .listOnly { + MainActor.assumeIsolated { + let entry = history.record(asked.target, title: asked.title, actor: asked.actor, agentSession: asked.from, + note: asked.note, id: asked.id) + persistNav(entry) + post("\(asked.actor == .agent ? "An agent" : "A link") added \(entry.title) to History (\(HubKeymap.label("hub.go.history")))", .success) + } + return false + } + pendingNav = asked + navigate(request) + // The same place again (another commit of the PR on screen) changes no selection: record it anyway. + DispatchQueue.main.async { [weak self] in self?.scheduleNavRecord() } + return true + } + + /// Shows the place a request names. + func navigate(_ request: HubRequest) { if request.session != nil || request.mode != nil || request.agent != nil || request.worktree != nil || request.pr != nil || request.commit != nil { commitLinkRequest = UUID() @@ -2102,9 +2433,34 @@ private struct SessionListView: View { @ObservedObject private var stuck = HubStuckStore.shared /// Whether the agent of an open, followed transcript works or waits (Hub/HubSessionDetail.swift); the same. @ObservedObject private var live = HubLiveActivityStore.shared + /// The keyboard focus of the list on screen: a click on a row or Tab gives it, then Up and Down move the + /// selection (`SidebarArrowKeys`). One state for every mode, since one list shows at a time. + @FocusState private var listFocused: Bool + /// The Inbox's order, the one its list shows (Hub/HubInbox.swift `InboxListView`), for the arrow keys. + @AppStorage("hub.inbox.sort") private var inboxSort = InboxSort.recent.rawValue + + /// The Activity filters' "All projects" row in the arrow keys' order; no project has this name. + private static let allProjects = "\u{0}all" private var mode: SessionGrouping { SessionGrouping(rawValue: grouping) ?? .time } + /// The sessions the arrow keys step through, as the list shows them: the agent processes entry, then every + /// row of an open group. + private static func sessionKeyOrder(_ sections: [(title: String, rows: [HubSession], managed: Bool)], collapsed: Set<String>) -> [String] { + [AgentProcs.selectionID] + sections + .filter { !($0.managed && collapsed.contains($0.title)) } + .flatMap { $0.rows.map(\.id) } + } + + /// The Inbox rows the arrow keys step through, in InboxListView's order: the waiting sessions, then the quiet + /// ones while one of them is selected (their fold is open then). + private static func inboxKeyOrder(_ inbox: HubInboxModel, sort: String, filter: String) -> [String] { + let rows = inbox.sorted(InboxSort(rawValue: sort) ?? .recent, filter: filter) + let fresh = rows.filter { !$0.isStale }.map(\.id) + let quiet = rows.filter(\.isStale).map(\.id) + return quiet.contains(inbox.selectedID ?? "") ? fresh + quiet : fresh + } + private var filterPlaceholder: String { switch model.mode { case .sessions: return "Filter sessions, projects, accounts…" @@ -2202,9 +2558,27 @@ private struct SessionListView: View { } else if model.mode == .prs { PRListView(model: model, prs: model.prs) } else if model.mode == .inbox { - InboxListView(model: model, inbox: model.inbox) + // The rows are Hub/HubInbox.swift's; the keys step through the same order. Read at the press: this + // view does not observe the Inbox. + let sort = inboxSort + ScrollViewReader { proxy in + InboxListView(model: model, inbox: model.inbox) + .modifier(SidebarArrowKeys( + ids: { Self.inboxKeyOrder(model.inbox, sort: sort, filter: model.filter) }, + selection: { model.inbox.selectedID }, + select: { model.inbox.selectedID = $0 }, + proxy: proxy, focused: $listFocused, focusOnClick: true)) + } } else if model.mode == .timeline { - TimelineListView(model: model, timeline: model.timeline) + // Up and Down pick the project above or below in the Projects filter (All, then each project). + ScrollViewReader { proxy in + TimelineListView(model: model, timeline: model.timeline) + .modifier(SidebarArrowKeys( + ids: { [Self.allProjects] + model.timeline.projects.map(\.name) }, + selection: { model.timeline.project ?? Self.allProjects }, + select: { model.timeline.project = $0 == Self.allProjects ? nil : $0 }, + proxy: proxy, focused: $listFocused, focusOnClick: true)) + } } else if model.mode == .agents { // Staging: the widget and Clicky entries exist only in the Preview app (main.swift). if NativeStaging.facesEnabled { @@ -2230,43 +2604,69 @@ private struct SessionListView: View { } AgentsListView(model: model, agents: model.agents) } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 2, pinnedViews: [.sectionHeaders]) { - // Hub/HubAgentProcs.swift: every agent session's process tree, orphans first. - AgentProcsEntry(model: model) - if model.sessions.isEmpty, model.loadingSessions { - SkeletonRows(count: 10, leading: .avatar) - .skeletonShimmer() - .accessibilityElement(children: .ignore) - .accessibilityLabel("Loading sessions") - } - ForEach(sections, id: \.title) { section in - Section { - if !(section.managed && prefs.collapsed.contains(section.title)) { - ForEach(section.rows) { session in - SessionRowView(session: session, selected: session.id == model.selectedID, stuck: stuck.verdicts[session.sessionId], work: live.work[session.sessionId]) - .rowButton { model.select(session.id) } - .swrFlash(model.changedSessions.contains(session.id), cornerRadius: 6) - .transition(SWR.rowTransition) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2, pinnedViews: [.sectionHeaders]) { + // Hub/HubAgentProcs.swift: every agent session's process tree, orphans first. + AgentProcsEntry(model: model) + .id(AgentProcs.selectionID) + if model.sessions.isEmpty, model.loadingSessions { + SkeletonRows(count: 10, leading: .avatar) + .skeletonShimmer() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading sessions") + } + ForEach(sections, id: \.title) { section in + Section { + if !(section.managed && prefs.collapsed.contains(section.title)) { + ForEach(section.rows) { session in + let activity = SessionRowActivity(work: live.work[session.sessionId], isLive: session.isLive) + let selected = session.id == model.selectedID + SessionRowView(session: session, selected: selected, stuck: stuck.verdicts[session.sessionId], activity: activity) + .rowButton { + listFocused = true + model.select(session.id) + } + // The dot's state in words for VoiceOver; the label is the row's text. + .accessibilityValue(Text(activity?.word ?? "")) + .accessibilityAddTraits(selected ? .isSelected : []) + .id(session.id) + .swrFlash(model.changedSessions.contains(session.id), cornerRadius: 6) + .transition(SWR.rowTransition) + } + } + } header: { + if section.managed { + GroupHeader( + title: section.title, + count: section.rows.count, + prefs: prefs, + allNames: sections.map(\.title), + path: mode == .harness ? nil : section.rows.first.map { projectRoot(of: $0.cwd) }.flatMap { $0.isEmpty ? nil : $0 } + ) + } else { + PlainGroupHeader(title: section.title, count: section.rows.count) } } - } header: { - if section.managed { - GroupHeader( - title: section.title, - count: section.rows.count, - prefs: prefs, - allNames: sections.map(\.title), - path: mode == .harness ? nil : section.rows.first.map { projectRoot(of: $0.cwd) }.flatMap { $0.isEmpty ? nil : $0 } - ) - } else { - PlainGroupHeader(title: section.title, count: section.rows.count) - } } + // The filter hid every session: say so, with the way back. Older matches may still show below. + if sections.isEmpty, !model.sessions.isEmpty, !model.filter.trimmed.isEmpty { + EmptyState( + symbol: "line.3.horizontal.decrease.circle", + text: "No session matches “\(model.filter.trimmed)”", + actionTitle: "Clear filter" + ) { model.filter = "" } + .padding(.top, 8) + } + HubHistorySection(model: model, history: history, shown: Set(sections.flatMap(\.rows).map(\.sessionId))) } - HubHistorySection(model: model, history: history, shown: Set(sections.flatMap(\.rows).map(\.sessionId))) + .padding(.bottom, 12) } - .padding(.bottom, 12) + .modifier(SidebarArrowKeys( + ids: { [collapsed = prefs.collapsed] in Self.sessionKeyOrder(sections, collapsed: collapsed) }, + selection: { model.selectedID }, + select: { model.select($0) }, + proxy: proxy, focused: $listFocused)) } // Polls `tools hub stuck` (every 2 min) for the live sessions while this list shows; a new live set restarts it. .task(id: HubStuck.watched(model.sessions)) { await stuck.watch(HubStuck.watched(model.sessions)) } @@ -2285,29 +2685,96 @@ private struct SessionListView: View { } } +/// Up and Down move a sidebar list's selection while the list has the keyboard focus; a click on a row or Tab +/// gives it. A text field with the focus keeps its own arrows, since the keys reach this view only through its +/// focus. One step per press, no key repeat: each selection loads its pane, and a held key would start a load per +/// repeat. The rows stay where they are unless the new one is out of sight (no anchor, no animation). +struct SidebarArrowKeys: ViewModifier { + /// The selectable rows in the order the list shows them, folded groups left out. Read at the press. + let ids: () -> [String] + /// Read at the press too: a list built in another file changes its selection without this view knowing. + let selection: () -> String? + let select: (String) -> Void + var proxy: ScrollViewProxy? + let focused: FocusState<Bool>.Binding + /// A click anywhere in the list gives it the focus as well, for a list whose rows are built in another file and + /// cannot set it themselves. The rows' own clicks still run. + var focusOnClick = false + + func body(content: Content) -> some View { + content + .focusable() + // The selected row is the focused look, as in the Agents list. + .focusEffectDisabled() + .focused(focused) + .simultaneousGesture(TapGesture().onEnded { focused.wrappedValue = true }, including: focusOnClick ? .all : .subviews) + .onKeyPress(.upArrow, phases: .down) { _ in move(-1) } + .onKeyPress(.downArrow, phases: .down) { _ in move(1) } + } + + private func move(_ step: Int) -> KeyPress.Result { + let ids = ids() + guard let first = ids.first, let last = ids.last else { return .ignored } + let next: String + if let current = selection(), let index = ids.firstIndex(of: current) { + guard ids.indices.contains(index + step) else { return .handled } + next = ids[index + step] + } else { + next = step > 0 ? first : last + } + select(next) + proxy?.scrollTo(next) + return .handled + } +} + +/// What a session row's dot means, in a word too: the dot alone said it in colour only (working and live are both +/// green, waiting is orange). +enum SessionRowActivity: Equatable { + case working, waiting, live + + /// The open transcript's live follow outranks the list's own live rule (a warm cache, a recent write). + init?(work: HubLiveActivityStore.Work?, isLive: Bool) { + switch work { + case .working: self = .working + case .waiting: self = .waiting + case nil: + guard isLive else { return nil } + self = .live + } + } + + var word: String { + switch self { + case .working: return "working" + case .waiting: return "waiting" + case .live: return "live" + } + } + + /// The same colours the open transcript's last row and header use. + var color: Color { + self == .waiting ? ReviewPalette.modified : ReviewPalette.added + } + + /// Working and waiting print their word on the row: only the open, followed session has them, so few rows carry + /// it. Live is every warm session; its dot stays the sign and VoiceOver reads the word. + var showsWord: Bool { self != .live } +} + /// The provider's letter on its colour (session rows, the Inbox). private struct SessionRowView: View { let session: HubSession let selected: Bool var stuck: StuckVerdict? - /// What the open transcript's live follow says, for the session it follows; nil for every other row. - var work: HubLiveActivityStore.Work? - - /// Green while the agent works or the session is live (a warm cache, a recent write), orange while it waits - /// for the user: the same activity the open transcript's last row and header show. - private var dotColor: Color? { - switch work { - case .working: return ReviewPalette.added - case .waiting: return ReviewPalette.modified - case nil: return session.isLive ? ReviewPalette.added : nil - } - } + /// The dot: working or waiting from the open transcript's follow, else live from the list; nil for a quiet row. + var activity: SessionRowActivity? var body: some View { HStack(alignment: .top, spacing: 9) { ZStack(alignment: .bottomTrailing) { ProviderBadge(provider: session.provider) - if let dotColor { + if let dotColor = activity?.color { Circle() .fill(dotColor) .frame(width: 7, height: 7) @@ -2339,6 +2806,13 @@ private struct SessionRowView: View { .layoutPriority(-1) } Spacer(minLength: 0) + if let activity, activity.showsWord { + // Whole, like the age; VoiceOver reads it from the row's value instead. + Text(activity.word) + .foregroundColor(activity.color) + .fixedSize() + .accessibilityHidden(true) + } // Whole: the project and account truncate first, the age read "21 sec. a…" before. LiveAgo(date: session.lastActivity, style: .brief).fixedSize() } @@ -2372,6 +2846,12 @@ struct SessionDetailView: View { @State private var fitting: Int? /// A pane that did not fit, shown as a drawer over the others from its rail. @State private var drawer: HubTab? + @GenReduceMotion private var reduceMotion + + /// Opens or closes the drawer on the edge panels' curve; at once under Reduce Motion. + private func setDrawer(_ tab: HubTab?) { + GenMotion.perform(expanding: tab != nil, reduceMotion: reduceMotion) { drawer = tab } + } /// The open panes, in order, that fit into `width` at their minimums; the rest become rails. /// The first pane always shows, however narrow the window. @@ -2418,7 +2898,7 @@ struct SessionDetailView: View { ForEach(railed, id: \.self) { tab in Rectangle().fill(ReviewPalette.hairline).frame(width: 1) PaneRail(tab: tab, open: drawer == tab) { - withAnimation(.snappy(duration: 0.25)) { drawer = drawer == tab ? nil : tab } + setDrawer(drawer == tab ? nil : tab) } } } @@ -2431,8 +2911,9 @@ struct SessionDetailView: View { .overlay(alignment: .leading) { Rectangle().fill(ReviewPalette.hairline).frame(width: 1) } .shadow(color: .black.opacity(0.45), radius: 18, x: -6) .padding(.trailing, CGFloat(railed.count) * (PaneRail.width + 1)) - .transition(.move(edge: .trailing).combined(with: .opacity)) - .onExitCommand { withAnimation(.snappy(duration: 0.2)) { drawer = nil } } + // Slides in from its rail; under Reduce Motion it appears in place. + .transition(reduceMotion ? .opacity : .move(edge: .trailing).combined(with: .opacity)) + .onExitCommand { setDrawer(nil) } } } } @@ -2530,8 +3011,8 @@ struct SessionDetailView: View { HStack(spacing: 8) { AgentChildTitle(agents: agent.agents, parent: agent.parent, node: node) Spacer() - if let notice = model.notice { - NoticePill(text: notice, isError: notice.hasPrefix("cmux:") || notice.contains("failed") || notice.contains("not in")) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } paneToggles AgentCopyIdButton(model: model, node: node) @@ -2575,12 +3056,13 @@ struct SessionDetailView: View { HubForecastChip(account: session.account) .layoutPriority(-1) } - if let notice = model.notice { - NoticePill(text: notice, isError: notice.hasPrefix("cmux:") || notice.contains("failed")) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } paneToggles IconButton(systemName: "doc.richtext", tooltip: "Copy as Markdown (json2md): session, changes, decisions, recent turns") { - Task { @MainActor in model.notice = await model.exportSession(session) } + // The export answers in words only (HubMarkdownExport, Hub/HubWorktrees.swift): its tone is guessed. + Task { @MainActor in model.headerNotice = HubNotice(guessing: await model.exportSession(session)) } } } } @@ -2611,7 +3093,7 @@ struct SessionDetailView: View { } Button { PathOpener.copy(session.sessionId) - model.notice = "Session id copied" + model.post("Session id copied", .success) } label: { chip("number", String(session.sessionId.prefix(8))) } @@ -2654,6 +3136,7 @@ private struct PaneToggle: View { @ObservedObject var model: HubModel let badge: PaneBadge @State private var targeted = false + @GenReduceMotion private var reduceMotion var body: some View { dragAndDrop(button) @@ -2703,8 +3186,9 @@ private struct PaneToggle: View { .strokeBorder(ReviewPalette.renamed, lineWidth: 1.5) .opacity(targeted ? 1 : 0) ) - .scaleEffect(targeted ? 1.06 : 1) - .animation(.snappy(duration: 0.18), value: targeted) + // Under Reduce Motion the drop target keeps its size and its outline appears at once. + .scaleEffect(targeted && !reduceMotion ? 1.06 : 1) + .animation(reduceMotion ? nil : .snappy(duration: 0.18), value: targeted) } .buttonStyle(.genHoverPlain()) .instantTooltip(open @@ -2723,8 +3207,15 @@ private struct PaneToggle: View { } .dropDestination(for: String.self) { items, _ in guard let dragged = items.first, let from = HubTab(rawValue: dragged) else { return false } - withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { - model.swapTabs(from, tab) + // A drop release may settle with a spring; under Reduce Motion the two swap at once. + if reduceMotion { + var instant = Transaction() + instant.disablesAnimations = true + withTransaction(instant) { model.swapTabs(from, tab) } + } else { + withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { + model.swapTabs(from, tab) + } } return true } isTargeted: { inside in diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift index d71f97d2ff..a4b9f34cba 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift @@ -120,12 +120,7 @@ enum WorktreeDiscovery { enum AgentLauncher { static func resumeCommand(for session: HubSession) -> [String]? { - switch session.provider { - case "claude": return ["tools", "claude", "resume", session.sessionId] - case "codex": return ["codex", "resume", session.sessionId] - case "grok": return ["grok", "--resume", session.sessionId] - default: return nil - } + CmuxCommandRunner.resumeCommand(provider: session.provider, sessionId: session.sessionId) } /// Runs `command` in `cwd` in a new workspace of the current terminal host (cmux today, see diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Review/PRThreadsPanel.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Review/PRThreadsPanel.swift index 44b3eef876..db487758d1 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Review/PRThreadsPanel.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Review/PRThreadsPanel.swift @@ -1048,7 +1048,7 @@ private struct PRThreadRow: View { } private func cardClicked() { - clicks.clicked(thread.id, clickCount: NSApp.currentEvent?.clickCount ?? 1) + clicks.clicked(thread.id, clickCount: MouseClick.current) } private var accent: Color { diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift index 9103d4a451..6a2ff2030d 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift @@ -2826,8 +2826,9 @@ private struct ScopeLink: View { } /// Codex's review source menu: what the diff compares. Its label is the chip the markdown viewer's scope picker shows -/// (GenesisKit `GitPickerChip`); "Pick a Commit…" opens the commit chooser (`gitCommitPopover`), with each commit's -/// author, date and line counts, a filter and the keyboard. +/// (GenesisKit `GitPickerChip`); "Pick Commit(s)…" opens the commit chooser (`gitCommitPopover`), with each commit's +/// author, date and line counts, a filter and the keyboard. Shift with the arrows or a Shift-click picks several +/// commits in a row, shown as one range from the parent of the oldest to the newest. struct ScopeMenu: View { @ObservedObject var model: ReviewModel @State private var pickingCommit = false @@ -2846,9 +2847,20 @@ struct ScopeMenu: View { .instantTooltip("What this diff compares: \(label)") // The list exists only while it is open: a closed chooser builds nothing (GenesisKit GitCommitPicker.swift). .gitCommitPopover(isPresented: $pickingCommit, choices: model.commits.map { GitCommitChoice($0) }, selection: shownCommit, - title: "Commits ahead of \(model.base ?? "the base")", details: GitCommitDetailsLoader(folder: model.repo)) { choice in - model.setScope(.commit(sha: choice.sha, title: choice.subject)) - } + title: "Commits ahead of \(model.base ?? "the base")", details: GitCommitDetailsLoader(folder: model.repo), + onPick: { choice in model.setScope(.commit(sha: choice.sha, title: choice.subject)) }, + onPickRange: { range in + if let scope = Self.rangeScope(range) { model.setScope(scope) } + }) + } + + /// Several commits of the list (newest first, as `model.commits` holds them) as one range: from the parent of the + /// oldest to the newest. One commit stays a commit scope. + static func rangeScope(_ range: [GitCommitChoice]) -> DiffScope? { + guard let newest = range.first, let oldest = range.last else { return nil } + guard range.count > 1 else { return .commit(sha: newest.sha, title: newest.subject) } + return .range(base: oldest.sha + "^", head: newest.sha, + label: "\(oldest.sha.prefix(7))..\(newest.sha.prefix(7)) (\(Plural.count(range.count, "commit")))") } /// The commit the diff shows now, for the chooser's check. @@ -2864,10 +2876,14 @@ struct ScopeMenu: View { // fills, not a greyed-out item. let turns = model.session != nil && model.remoteHead == nil let onCommit: Bool = { - if case .commit = model.scope { return true } - return false + switch model.scope { + case .commit: return true + // The PR's own range has its head among the commits too; the PR item checks that one. + case .range(_, let head, _, _): return model.scope != model.prScope && model.commits.contains { $0.sha == head } + default: return false + } }() - let committed: MenuButtonItem = .action(model.commits.isEmpty ? "No Commits Ahead of the Base" : "Pick a Commit…", + let committed: MenuButtonItem = .action(model.commits.isEmpty ? "No Commits Ahead of the Base" : "Pick Commit(s)…", checked: onCommit, enabled: !model.commits.isEmpty, run: pickCommit) let prItem: [MenuButtonItem] = model.prScope.map { scope in [.action("PR \(model.prLabel)", checked: model.scope == scope) { model.setScope(scope) }] diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift index 61f99e45d9..3815ed9dd4 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift @@ -122,10 +122,13 @@ struct StoredProposalsList: View { .foregroundColor(ReviewPalette.dim) case .found(let identity, let choices): if choices.isEmpty { - Text("No agent review stored for \(identity.label). An agent adds one with tools hub proposal push; Reload looks again.") + Text("No agent review is stored for \(identity.label) yet.") .font(.system(size: 11)) .foregroundColor(ReviewPalette.dim) .fixedSize(horizontal: false, vertical: true) + GhostButton("Look again", symbol: "arrow.clockwise", tooltip: "Look for a stored review of this PR again") { + model.lookupStoredProposals() + } } else { ForEach(choices) { choice in StoredProposalRow(choice: choice) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift index a3db85b9c9..b6b12e03ed 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift @@ -25,13 +25,6 @@ enum PRActivitySettingsPages { } } -/// The cadences a project can pick, in minutes (the CLI takes any whole minute from 1 to 60). -private let cadenceChoices = [1, 2, 3, 5, 10, 15, 20, 30, 45, 60] - -private func cadenceTitle(_ minutes: Int) -> String { - minutes == 1 ? "Every minute" : "Every \(minutes) minutes" -} - @MainActor struct PRActivitySettingsView: View { @ObservedObject var store: HubNotifyStore @@ -40,7 +33,7 @@ struct PRActivitySettingsView: View { VStack(alignment: .leading, spacing: 18) { if let status = store.status { content(status) - } else { + } else if store.busy || store.message == nil { NativeSettingsCard { HStack(spacing: 8) { ProgressView().controlSize(.small) @@ -48,11 +41,7 @@ struct PRActivitySettingsView: View { } } } - if let message = store.message { - NoticePill(text: message, isError: message.contains("exited") || message.contains("failed")) { - store.message = nil - } - } + HubNotifyNoticeView(store: store) } .onAppear { store.load() } } @@ -63,24 +52,28 @@ struct PRActivitySettingsView: View { NativeSettingsCard { NativeSettingsToggle( "PR notifications", - detail: "When this is off, no project is polled and nothing posts.", + detail: HubNotifyWords.enabledDetail, identifier: "prActivity.enabled", isOn: Binding(get: { config.enabled }, set: { store.set(["--enabled", $0 ? "on" : "off"]) })) Divider() - NativeSettingsRow(tickTitle(status), detail: pollLine(status)) { + NativeSettingsRow(status.daemonTitle, detail: pollLine(status)) { HStack(spacing: 8) { if store.busy { ProgressView().controlSize(.small) } - if status.daemonTask != true || status.daemonTaskEvery != status.daemonTaskWanted { - Button(status.daemonTask == true ? "Tick every minute" : "Add the daemon task") { + if let fix = status.daemonFixTitle { + // The tooltip says what happens; the command is one right-click away, for a terminal (O7). + Button(fix) { store.installDaemonTask() } .buttonStyle(.bordered).nativeSettingsPointer() - .instantTooltip("tools hub notify install: one daemon task that ticks every minute") + .instantTooltip(HubNotifyWords.installDetail) + .contextMenu { + Button("Copy Command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } + } .accessibilityIdentifier("prActivity.install") } Button("Check now") { store.pollNow() } .buttonStyle(.bordered).nativeSettingsPointer() - .instantTooltip("Poll every watched project now, ignoring cadences and backoff") + .instantTooltip(HubNotifyWords.pollNowDetail) .accessibilityIdentifier("prActivity.pollNow") } } @@ -108,12 +101,14 @@ struct PRActivitySettingsView: View { } NativeSettingsCard("Defaults", subtitle: "A project follows these until you change its own cadence or events.") { - NativeSettingsRow("Cadence", detail: "How often a project without its own cadence is polled.") { + NativeSettingsRow("Cadence", detail: HubNotifyWords.cadenceDetail) { Picker("Cadence", selection: Binding( get: { config.intervalMinutes }, set: { store.set(["--interval", String($0)]) } )) { - ForEach(cadenceOptions(including: config.intervalMinutes), id: \.self) { Text(cadenceTitle($0)).tag($0) } + ForEach(HubNotifyWords.cadenceOptions(including: config.intervalMinutes), id: \.self) { + Text(HubNotifyWords.cadenceTitle($0)).tag($0) + } } .pickerStyle(.menu).labelsHidden().frame(width: 170) .accessibilityIdentifier("prActivity.defaultCadence") @@ -121,14 +116,14 @@ struct PRActivitySettingsView: View { Divider() ForEach(HubNotifyEvent.allCases) { event in NativeSettingsToggle( - event.title, detail: eventDetail(event), identifier: "prActivity.event.\(event.rawValue)", + event.title, detail: event.detail, identifier: "prActivity.event.\(event.rawValue)", isOn: Binding( get: { config.events[event.rawValue] ?? false }, set: { store.set(["--event", "\(event.rawValue)=\($0 ? "on" : "off")"]) })) } Divider() NativeSettingsToggle( - "Only my PRs", detail: "Changes on PRs someone else opened stay silent.", + "Only my PRs", detail: HubNotifyWords.onlyMineDetail, identifier: "prActivity.onlyMine", isOn: Binding(get: { config.onlyMine }, set: { store.set(["--only-mine", $0 ? "on" : "off"]) })) } @@ -139,7 +134,7 @@ struct PRActivitySettingsView: View { .instantTooltip("Post one notification marked as a test; its click opens the hub's PRs") Button("Config file") { PathOpener.cursor(status.configPath) } .buttonStyle(.genHoverPlain()).font(.system(size: 12)) - .instantTooltip(status.configPath) + .instantTooltip("Open \((status.configPath as NSString).abbreviatingWithTildeInPath) in Cursor") Spacer(minLength: 0) } } @@ -148,34 +143,12 @@ struct PRActivitySettingsView: View { URL(fileURLWithPath: path).lastPathComponent } - private func tickTitle(_ status: HubNotifyStatus) -> String { - switch status.daemonTask { - case true?: - return status.daemonTaskEvery == status.daemonTaskWanted - ? "The daemon checks every minute" - : "The daemon task still runs \(status.daemonTaskEvery ?? "at an old cadence")" - case false?: return "Nothing polls yet: the daemon task is missing" - case nil: return "The daemon's task list is unreadable" - } - } - private func pollLine(_ status: HubNotifyStatus) -> String { let last = HubFormat.date(status.lastPollAt).map { "Last poll \($0.formatted(date: .omitted, time: .shortened))" } ?? "Never polled" return "\(last) · \(status.pollsLastHour) polls and \(status.requestsLastHour) host requests in the last hour" } - private func eventDetail(_ event: HubNotifyEvent) -> String { - switch event { - case .thread: return "Someone else opens a review thread or comment thread on a PR." - case .reviewRequested: return "You are added as a reviewer." - case .ciFailed: return "The checks of a PR's newest commit fail." - case .ciPassed: return "The checks of a PR's newest commit pass." - case .botReview: return "A review bot posts a review." - case .merged: return "A watched open PR is merged." - } - } - private func addProject() { let panel = NSOpenPanel() panel.canChooseDirectories = true @@ -188,11 +161,6 @@ struct PRActivitySettingsView: View { } } -/// `cadenceChoices`, plus the stored value when the config holds another one. -private func cadenceOptions(including value: Int) -> [Int] { - cadenceChoices.contains(value) ? cadenceChoices : (cadenceChoices + [value]).sorted() -} - @MainActor private struct PRActivityProjectRow: View { let path: String @@ -234,16 +202,16 @@ private struct PRActivityProjectRow: View { private var cadenceMenu: some View { let own = repo?.intervalMinutes return MenuButton(items: { - [MenuButtonItem.action("Default (\(cadenceTitle(config.intervalMinutes).lowercased()))", checked: own == nil) { + [MenuButtonItem.action("Default (\(HubNotifyWords.cadenceTitle(config.intervalMinutes).lowercased()))", checked: own == nil) { store.set(["--repo", path, "--reset-repo-interval"]) }, .divider] - + cadenceChoices.map { minutes in - .action(cadenceTitle(minutes), checked: own == minutes) { + + HubNotifyWords.cadenceOptions(including: own ?? config.intervalMinutes).map { minutes in + .action(HubNotifyWords.cadenceTitle(minutes), checked: own == minutes) { store.set(["--repo", path, "--interval", String(minutes)]) } } }) { - menuLabel(own.map(cadenceTitle) ?? "\(cadenceTitle(config.intervalMinutes)) (default)") + menuLabel(own.map(HubNotifyWords.cadenceTitle) ?? "\(HubNotifyWords.cadenceTitle(config.intervalMinutes)) (default)") } .fixedSize() .instantTooltip("How often this project is polled") @@ -252,32 +220,15 @@ private struct PRActivityProjectRow: View { private var eventsMenu: some View { MenuButton(items: { - HubNotifyEvent.allCases.map { event -> MenuButtonItem in - let own = repo?.events?[event.rawValue] - let effective = own ?? config.events[event.rawValue] ?? false - return .action("\(event.title)\(own == nil ? "" : " (this project)")", checked: effective) { - store.set(["--repo", path, "--event", "\(event.rawValue)=\(effective ? "off" : "on")"]) - } - } + [ - .divider, - .action("Follow the defaults", enabled: repo?.events != nil) { - store.set(["--repo", path, "--reset-repo-events"]) - }, - ] + HubNotifyWords.eventItems(path: path, repo: repo, config: config, store: store) }) { - menuLabel(eventsSummary) + menuLabel(HubNotifyWords.eventsSummary(repo: repo, config: config)) } .fixedSize() - .instantTooltip("Which events this project posts; the defaults apply until you change one here") + .instantTooltip(HubNotifyWords.eventsDetail) .accessibilityIdentifier("prActivity.repo.\(name).events") } - private var eventsSummary: String { - let on = HubNotifyEvent.allCases.filter { repo?.events?[$0.rawValue] ?? config.events[$0.rawValue] ?? false } - let label = on.count == HubNotifyEvent.allCases.count ? "All events" : Plural.count(on.count, "event") - return repo?.events == nil ? "\(label) (default)" : label - } - private func menuLabel(_ text: String) -> some View { HStack(spacing: 4) { Text(text).font(.system(size: 12)) @@ -290,11 +241,8 @@ private struct PRActivityProjectRow: View { @ViewBuilder private var stateLine: some View { if let state, let error = state.lastError { - Label( - "\(Plural.count(state.failures, "failed poll")) · next try \(HubFormat.date(state.nextAt)?.formatted(date: .omitted, time: .shortened) ?? "soon") · \(error)", - systemImage: "exclamationmark.circle" - ) - .font(.system(size: 11)).foregroundStyle(.orange).lineLimit(2) + // One sentence; the host's own error behind Details, whole (it was cut at two lines, H21). + NoticePill(text: state.problemText, more: error, tone: .warning) } else if let state, let ok = HubFormat.date(state.lastOkAt) { LiveAgo(date: ok, style: .ago) { "Polled \($0)" } .font(.system(size: 11)).foregroundStyle(.secondary) diff --git a/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift b/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift index 51b8f49029..ff3f80e15c 100644 --- a/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift +++ b/src/macos/GenesisTools/Sources/Parts/Markdown/MarkdownFace.swift @@ -50,6 +50,7 @@ package func runMarkdown(_ args: [String]) -> Never { } } _ = NSApplication.shared + HangWatch.start() WindowCycle.install() MarkdownViewerApp.run(MarkdownViewerAppConfiguration( appName: "GenesisTools", diff --git a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift index 2e532dbd1a..474519b774 100644 --- a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift @@ -51,6 +51,8 @@ private final class AgentWidgetDelegate: NSObject, NSApplicationDelegate { WidgetLaunch.start(args) }) coordinator?.settingsPresenter = { FeatureSettingsLaunch.open(pageID: $0) } + // The widget face is the one process that owns the global "open the widget" shortcut. + coordinator?.registersOpenShortcut = true coordinator?.model.openDestination = { session, mode, file in var args = [ "--hub", "--mode", "sessions", "--session", session.target.sessionId, @@ -103,8 +105,8 @@ private final class AgentWidgetDelegate: NSObject, NSApplicationDelegate { private func openSession(_ key: String) { coordinator?.openSession(key) } /// `expand <edge> [group]`, `module <id> <edge> [group]`, `hover <edge> [group]`, `unhover <edge> [group]`, - /// `collapse`, `select <session key>`, `settings [page]`, `passive on|off` (expanded panels leave the keyboard with - /// the app in front, for motion checks on the user's screen). Staging only (NativeStaging); logged for the recording. + /// `collapse`, `select <session key>`, `state`, `settings [page]`, `passive on|off` (expanded panels leave the keyboard with + /// the app in front, for motion checks on the user's screen), `video` and `recorder …` (the screen recording). Staging only (NativeStaging); logged for the recording. private func runTestCommand(_ command: String) { guard let coordinator else { return } let model = coordinator.model @@ -125,10 +127,38 @@ private final class AgentWidgetDelegate: NSObject, NSApplicationDelegate { if parts.count > 1, let target = surface(2) { model.openModule(parts[1], on: target) } case "hover", "unhover": if let target = surface(1) { model.hover(target, inside: parts.first == "hover") } + // What the inbox button and a rail session button do: open, or hide when already open. + case "inbox": if let target = surface(1) { model.toggleInboxOverview(on: target) } + case "session": if parts.count > 1, let target = surface(2) { model.toggleSession(parts[1], on: target) } case "collapse": model.collapse() case "select": if parts.count > 1 { model.select(parts[1]) } + // `state`: one app-perf.log line with the selection, whether it loads, its cards, the cache and the warm keys. + case "state": PerfLog.mark("widget.state " + model.selectionDescription) case "settings": coordinator.showSettings(pageID: parts.count > 1 ? parts[1] : "widgets.general") case "passive": coordinator.passivePanels = parts.count < 2 || parts[1] != "off" + // `hittest <x> <y>` in CoreGraphics screen points: which view AppKit hands a click there to. + case "hittest": + if parts.count > 2, let x = Double(parts[1]), let y = Double(parts[2]) { + PerfLog.mark("widget.hittest \(x),\(y): " + coordinator.hitTestDescription(cgPoint: CGPoint(x: x, y: y))) + } + // What the video button does: open the recording picker, or stop and paste a running recording. + case "video": model.toggleVideoCapture() + // `recorder window <id>`, `recorder display <index>`, `recorder stop|discard|state`: the picker and controls + // without a click, so a check never takes the user's focus. `state` logs one line to app-perf.log. + case "recorder": + let recorder = model.screenRecorder + switch parts.dropFirst().first { + case "window": + let id = parts.count > 2 ? UInt32(parts[2]) : nil + recorder.choose { targets in targets.windows.first { $0.id == id }?.target } + case "display": + let index = parts.count > 2 ? Int(parts[2]) : nil + recorder.choose { targets in targets.displays.first { $0.index == index }?.target } + case "stop": recorder.stopAndUse() + case "discard": recorder.discard() + default: break + } + PerfLog.mark("widget.recorder \(command): \(recorder.stateDescription)") default: NSLog("widget.test: unknown command %@", command) } } @@ -200,6 +230,9 @@ package func runAgentWidget(_ args: [String]) -> Never { exit(0) } let app = NSApplication.shared + // Stalls and main-thread spins land in app-perf.log and logs/hangs/. The widget is where the 2026-10-23 + // spins happened, and only the hub, review and relay faces watched for them. + HangWatch.start() let delegate = AgentWidgetDelegate(args: args, descriptor: descriptor) app.delegate = delegate app.setActivationPolicy(.accessory) diff --git a/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift b/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift index 1793b7277d..dcf2161766 100644 --- a/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift +++ b/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift @@ -65,7 +65,8 @@ final class DaemonSettingsTests: XCTestCase { let plugin = HubSettingsPlugin(notify: store) XCTAssertTrue(plugin.settingsReady(page: "general")) XCTAssertFalse(plugin.settingsReady(page: PRActivitySettingsPages.pageID), "no notify status yet") - store.message = "tools hub notify status exited 1" + store.message = HubNotifyNotice(text: "The notification settings could not be read.", tone: .error, + more: "tools hub notify status exited 1") XCTAssertTrue(plugin.settingsReady(page: PRActivitySettingsPages.pageID), "an error the page shows ends the wait") } diff --git a/src/macos/GenesisTools/Tests/HubKeymapTests.swift b/src/macos/GenesisTools/Tests/HubKeymapTests.swift index af6a764efa..62b3d269ab 100644 --- a/src/macos/GenesisTools/Tests/HubKeymapTests.swift +++ b/src/macos/GenesisTools/Tests/HubKeymapTests.swift @@ -84,8 +84,10 @@ final class HubKeymapTests: XCTestCase { func seed() { var history = HubNavHistory() - history.visit(HubNavEntry(mode: .sessions, selection: "fixture-a")) - history.visit(HubNavEntry(mode: .sessions, selection: "fixture-b")) + for id in ["fixture-a", "fixture-b"] { + let target = HubNavTarget(place: HubNavEntry(mode: .sessions, selection: id)) + history.visit(target, title: id) + } model.replaceHistory(history) } func press(_ code: UInt16, _ character: String, _ flags: NSEvent.ModifierFlags = .command) -> Bool { @@ -98,11 +100,11 @@ final class HubKeymapTests: XCTestCase { for character in ["ú", "[", "x", ")"] { seed() XCTAssertTrue(press(33, character), "⌘ on key 33 types \(character)") - XCTAssertEqual(model.history.current?.selection, "fixture-a", character) + XCTAssertEqual(model.history.current?.target.place?.selection, "fixture-a", character) } seed() XCTAssertFalse(press(33, "ú", [.command, .option]), "another modifier set is not Back") - XCTAssertEqual(model.history.current?.selection, "fixture-b") + XCTAssertEqual(model.history.current?.target.place?.selection, "fixture-b") XCTAssertFalse(press(0, "a"), "⌘A is Select All, not a hub-window command") } diff --git a/src/macos/GenesisTools/Tests/HubLogicTests.swift b/src/macos/GenesisTools/Tests/HubLogicTests.swift index d7f3136f11..785ce14f46 100644 --- a/src/macos/GenesisTools/Tests/HubLogicTests.swift +++ b/src/macos/GenesisTools/Tests/HubLogicTests.swift @@ -147,6 +147,59 @@ final class HubLogicTests: XCTestCase { } /// `commit=<from>..<to>`: a run of a PR's commits, both ends included, in either order. + /// A link may name commits an agent made but did not push yet (MR !7555, 2026-10-23): the forge does not list + /// them, the PR's checkout has them, so the range comes from the checkout instead of the whole MR. + func testALinkRangeOfUnpushedCommitsResolvesFromTheCheckout() throws { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("hub-local-range-\(UUID().uuidString)").path + try FileManager.default.createDirectory(atPath: repo, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: repo) } + func git(_ args: String...) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false"] + args + let out = Pipe() + process.standardOutput = out + try process.run() + let data = out.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0, "git \(args.joined(separator: " "))") + return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + _ = try git("init", "-q", "-b", "main") + var shas: [String] = [] + for index in 0 ..< 5 { + _ = try git("commit", "-q", "--allow-empty", "-m", "commit \(index)") + shas.append(try git("rev-parse", "HEAD")) + } + let pushedHead = shas[2] + let pushed = Set(shas[1 ... 2]) + let range = try XCTUnwrap(try? HubLocalCommits.resolve( + ends: [String(shas[2].prefix(10)), String(shas[4].prefix(10))], repo: repo, head: pushedHead, pushed: pushed).get()) + XCTAssertEqual(range, HubLocalCommits(first: shas[2], last: shas[4], count: 3, unpushed: 2, title: "commit 4")) + let reversed = try? HubLocalCommits.resolve(ends: [shas[4], shas[2]], repo: repo, head: pushedHead, pushed: pushed).get() + XCTAssertEqual(reversed?.first, shas[2], "either order") + XCTAssertEqual(try? HubLocalCommits.resolve(ends: [shas[3]], repo: repo, head: pushedHead).get().unpushed, 1) + XCTAssertEqual(try? HubLocalCommits.resolve(ends: [shas[4]], repo: repo, head: pushedHead).get().unpushed, 1, + "an unpushed commit before the link is not the link's") + XCTAssertEqual(try? HubLocalCommits.resolve(ends: [shas[3], shas[4]], repo: repo, head: shas[1]).get().unpushed, 2) + guard case .failure(.offBranch) = HubLocalCommits.resolve(ends: [shas[0], shas[4]], repo: repo, head: pushedHead, pushed: pushed) else { + return XCTFail("a first commit from before the PR would show the base's changes") + } + guard case .failure(.unknown("0123456789")) = HubLocalCommits.resolve(ends: ["0123456789"], repo: repo, head: pushedHead) else { + return XCTFail("an end the checkout lacks is named") + } + _ = try git("checkout", "-q", "-b", "side", shas[0]) + _ = try git("commit", "-q", "--allow-empty", "-m", "side") + let side = try git("rev-parse", "HEAD") + guard case .failure(.unrelated) = HubLocalCommits.resolve(ends: [side, shas[4]], repo: repo, head: pushedHead) else { + return XCTFail("ends on two lines are refused") + } + guard case .failure(.offBranch) = HubLocalCommits.resolve(ends: [side], repo: repo, head: pushedHead) else { + return XCTFail("a commit off the PR's line is refused") + } + } + func testACommitRangeLinkShowsItsCommitsFromTheParentOfTheFirst() throws { XCTAssertEqual(HubCommitSpan("abcdef0"), HubCommitSpan(from: "abcdef0")) XCTAssertEqual(HubCommitSpan("67AD51671E..3cac936e2f"), HubCommitSpan(from: "67ad51671e", to: "3cac936e2f")) diff --git a/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift b/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift index 64d5fa5a12..9d777e8d27 100644 --- a/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift +++ b/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift @@ -79,17 +79,17 @@ final class HubMenuButtonTests: XCTestCase { ] var opened = 0 var items = MenuButtonPresenter.menu(ScopeMenu.items(model: model) { opened += 1 }).items - let pick = items.first { $0.title == "Pick a Commit…" } + let pick = items.first { $0.title == "Pick Commit(s)…" } XCTAssertEqual(pick?.isEnabled, true) XCTAssertEqual(pick?.state, .off) XCTAssertNil(pick?.submenu, "the chooser is a popover built when it opens, not a submenu") let menu = MenuButtonPresenter.menu(ScopeMenu.items(model: model) { opened += 1 }) - menu.performActionForItem(at: menu.items.firstIndex { $0.title == "Pick a Commit…" } ?? -1) + menu.performActionForItem(at: menu.items.firstIndex { $0.title == "Pick Commit(s)…" } ?? -1) XCTAssertEqual(opened, 1) model.scope = .commit(sha: model.commits[1].sha, title: model.commits[1].subject) items = MenuButtonPresenter.menu(ScopeMenu.items(model: model)).items - XCTAssertEqual(items.first { $0.title == "Pick a Commit…" }?.state, .on) + XCTAssertEqual(items.first { $0.title == "Pick Commit(s)…" }?.state, .on) XCTAssertEqual(GitCommitChoice(model.commits[0]).relative, "2 hours ago", "the git words show until the details arrive") } diff --git a/src/macos/GenesisTools/Tests/HubNavigationTests.swift b/src/macos/GenesisTools/Tests/HubNavigationTests.swift index 4f11dc806b..9e1e996d09 100644 --- a/src/macos/GenesisTools/Tests/HubNavigationTests.swift +++ b/src/macos/GenesisTools/Tests/HubNavigationTests.swift @@ -163,75 +163,63 @@ final class HubNavigationTests: XCTestCase { } } - private func place(_ mode: HubMode, _ selection: String?) -> HubNavEntry { - HubNavEntry(mode: mode, selection: selection) + private func place(_ mode: HubMode, _ selection: String?) -> HubNavTarget { + HubNavTarget(place: HubNavEntry(mode: mode, selection: selection)) } - // MARK: History - - func testBackAndForwardWalkThePlacesInOrder() { - var history = HubNavHistory() - history.visit(place(.sessions, "a")) - history.visit(place(.prs, "pr1")) - history.visit(place(.sessions, "b")) - XCTAssertTrue(history.canGoBack) - XCTAssertFalse(history.canGoForward) - - XCTAssertEqual(history.goBack(), place(.prs, "pr1")) - XCTAssertEqual(history.goBack(), place(.sessions, "a")) - XCTAssertNil(history.goBack(), "nothing before the first place") - XCTAssertEqual(history.current, place(.sessions, "a")) - - XCTAssertEqual(history.goForward(), place(.prs, "pr1")) - XCTAssertEqual(history.goForward(), place(.sessions, "b")) - XCTAssertNil(history.goForward()) - } - - func testANewPlaceAfterGoingBackDropsTheForwardOnes() { - var history = HubNavHistory() - history.visit(place(.sessions, "a")) - history.visit(place(.sessions, "b")) - _ = history.goBack() - history.visit(place(.worktrees, "/repo")) - XCTAssertFalse(history.canGoForward) - XCTAssertEqual(history.back, [place(.sessions, "a")]) + // MARK: History (the engine's own rules are GenesisKit's NavigationHistoryTests) + + func testAPlaceKeepsItsSelectionAndCommitAndALinkPlaceOpensAsItsFlags() throws { + let shown = HubNavTarget(place: HubNavEntry(mode: .worktrees, selection: "/src/app"), commit: "0751d8a971", repo: "/src/app") + XCTAssertEqual(shown.place, HubNavEntry(mode: .worktrees, selection: "/src/app")) + XCTAssertEqual(shown["commit"], "0751d8a971") + let data = try JSONEncoder().encode(shown) + XCTAssertEqual(try JSONSerialization.jsonObject(with: data) as? [String: String], + ["mode": "worktrees", "selection": "/src/app", "commit": "0751d8a971", "repo": "/src/app"], + "a flat object, the target of tools hub nav") + XCTAssertEqual(try JSONDecoder().decode(HubNavTarget.self, from: data), shown) + + let named = try JSONDecoder().decode(HubNavTarget.self, from: Data(##"{"pr":"#563","commit":"abcdef0","snapshot":"/tmp/x.png"}"##.utf8)) + XCTAssertNil(named.place, "an agent's place has no selection of the hub's own") + XCTAssertEqual(named.arguments, ["--pr", "#563", "--commit", "abcdef0"], "unknown keys never become flags") } - func testTheSamePlaceTwiceIsOneEntry() { - var history = HubNavHistory() - history.visit(place(.sessions, "a")) - history.visit(place(.sessions, "a")) - XCTAssertFalse(history.canGoBack) + /// The wire to `tools hub nav` (src/hub/commands/nav.ts): what `list --json` prints, and `record`'s flags. + func testTheStoreReadsTheListAndWritesFreeTextAsFlagEqualsValue() throws { + let listed = Data(##"{"entries":[{"id":"x2","at":1792949067131,"actor":"agent","title":"PR #563","target":{"pr":"563"},"note":"why","from":"s-1"},{"id":"x1","at":1792949066674,"actor":"robot","title":"s","target":{"mode":"sessions","selection":"claude:a"}}],"keep":50,"path":"/x/navigation.db"}"##.utf8) + let entries = try HubNavStore.decode(listed) + XCTAssertEqual(entries.map(\.id), ["x2", "x1"]) + XCTAssertEqual(entries[0].actor, .agent) + XCTAssertEqual(entries[0].note, "why") + XCTAssertEqual(entries[0].agentSession, "s-1") + XCTAssertEqual(entries[0].at.timeIntervalSince1970, 1_792_949_067.131, accuracy: 0.001) + XCTAssertEqual(entries[1].actor, .user, "an unknown actor reads as the person's") + XCTAssertEqual(entries[1].target.place, HubNavEntry(mode: .sessions, selection: "claude:a")) + + let args = try HubNavStore.recordArguments( + NavigationEntry(id: "e", at: Date(timeIntervalSince1970: 2), target: place(.prs, "p"), title: "-x title", + actor: .agent, agentSession: "s", note: "--why"), keep: 20) + XCTAssertEqual(Array(args.prefix(3)), ["hub", "nav", "record"]) + XCTAssertTrue(args.contains("--title=-x title")) + XCTAssertTrue(args.contains("--note=--why")) + XCTAssertTrue(args.contains("--at=2000")) + XCTAssertTrue(args.contains("--keep=20")) + XCTAssertTrue(args.contains("--from=s")) } - func testAPlaceWithNothingPickedIsReplacedByWhatFollows() { - var history = HubNavHistory() - history.visit(place(.sessions, nil)) - history.visit(place(.prs, nil)) - XCTAssertFalse(history.canGoBack, "the launch's empty Sessions mode is no place to go back to") - - history.visit(place(.prs, "pr1")) - history.visit(place(.worktrees, nil)) - history.visit(place(.sessions, "a")) - XCTAssertEqual(history.back, [place(.prs, "pr1")], "a mode left while its list loaded is not a stop") - - // Out to a loading mode and straight back is the place before it, not a second copy of it. - history.visit(place(.worktrees, nil)) - history.visit(place(.sessions, "a")) - XCTAssertEqual(history.back, [place(.prs, "pr1")]) - XCTAssertEqual(history.current, place(.sessions, "a")) - - _ = history.goBack() - XCTAssertEqual(history.forward, [place(.sessions, "a")]) - } - - func testTheHistoryKeepsOnlyTheNewestPlaces() { - var history = HubNavHistory() - for index in 0...(HubNavHistory.limit + 20) { - history.visit(place(.sessions, "s\(index)")) - } - XCTAssertEqual(history.back.count, HubNavHistory.limit) - XCTAssertEqual(history.back.first, place(.sessions, "s20")) + func testARequestCarriesWhoAndWhyAndALinkWithoutAnActorIsALink() throws { + let request = HubRequest(["--pr", "acme/app#563", "--commit", "0751d8a971", "--actor", "agent", "--note", "why", + "--from", "session-1", "--nav", "entry-1"]) + let asked = try XCTUnwrap(HubNavRequest(request)) + XCTAssertEqual(asked.actor, .agent) + XCTAssertEqual(asked.note, "why") + XCTAssertEqual(asked.from, "session-1") + XCTAssertEqual(asked.id, "entry-1") + XCTAssertEqual(asked.title, "PR acme/app#563 · commit 0751d8a971") + XCTAssertEqual(HubNavRequest(HubRequest(["--session", "abc"]))?.actor, .link) + XCTAssertNil(HubNavRequest(HubRequest(["--palette", "gt pr"])), "an overlay is no place") + XCTAssertEqual(HubLink.arguments(fromLink: "genesis-tools://hub?pr=1&actor=agent¬e=a%20b&from=s&nav=n&menu=Go"), + ["--pr", "1", "--actor", "agent", "--note", "a b", "--from", "s", "--nav", "n"]) } func testTheModelRecordsEachPickAndWalksBackAndForward() { @@ -241,13 +229,15 @@ final class HubNavigationTests: XCTestCase { XCTAssertFalse(model.history.canGoBack, "the launch's empty selection is not a place") model.selectedID = "claude:two" drainMainQueue() - XCTAssertEqual(model.history.back, [place(.sessions, "claude:one")]) + XCTAssertEqual(model.history.backEntry?.target, place(.sessions, "claude:one")) + XCTAssertEqual(model.history.current?.actor, .user) MainActor.assumeIsolated { model.goBack() } drainMainQueue() XCTAssertEqual(model.selectedID, "claude:one") - XCTAssertEqual(model.history.forward, [place(.sessions, "claude:two")]) - XCTAssertEqual(model.history.current, place(.sessions, "claude:one"), "the restore itself is not recorded") + XCTAssertEqual(model.history.forwardEntry?.target, place(.sessions, "claude:two")) + XCTAssertEqual(model.history.current?.target, place(.sessions, "claude:one"), "the restore itself is not recorded") + XCTAssertEqual(model.history.entries.count, 2) MainActor.assumeIsolated { model.goForward() } drainMainQueue() @@ -255,6 +245,86 @@ final class HubNavigationTests: XCTestCase { XCTAssertFalse(model.history.canGoForward) } + /// Martin, 2026-10-25: what an agent opened replaced the place he was on, and he could not get back. + @MainActor + func testAnAgentsPlaceIsAddedWithItsNoteAndBackReturns() async throws { + let a = HubSession(sessionId: "fixture-a"), b = HubSession(sessionId: "fixture-b"), c = HubSession(sessionId: "fixture-c") + let model = isolatedModel(wanted: a.sessionId) + let initial = expectation(description: "initial list") + model.readSessions = { _ in [a, b, c] } + model.writeSessionCache = { _, _ in initial.fulfill() } + model.loadSessions() + await fulfillment(of: [initial], timeout: 2) + await drain() + model.select(b.id) + await drain() + XCTAssertEqual(model.history.current?.target, place(.sessions, b.id)) + + XCTAssertTrue(model.apply(HubRequest(["--session", "fixture-c", "--actor", "agent", "--note", "the failing run", + "--from", "agent-session", "--nav", "cli-id"]))) + await drain() + XCTAssertEqual(model.selectedID, c.id) + let current = try XCTUnwrap(model.history.current) + XCTAssertEqual(current.id, "cli-id", "the entry tools hub nav open stored, not a second one") + XCTAssertEqual(current.target, place(.sessions, c.id)) + XCTAssertEqual(current.actor, .agent) + XCTAssertEqual(current.note, "the failing run") + XCTAssertEqual(current.agentSession, "agent-session") + XCTAssertEqual(model.history.entries.map(\.target), [place(.sessions, a.id), place(.sessions, b.id), place(.sessions, c.id)], + "nothing was replaced") + + model.goBack() + await drain() + XCTAssertEqual(model.selectedID, b.id) + // The same place again from an agent: its note, no new entry. + XCTAssertTrue(model.apply(HubRequest(["--session", "fixture-b", "--actor", "agent", "--note", "look again"]))) + await drain() + XCTAssertEqual(model.history.current?.note, "look again") + XCTAssertEqual(model.history.entries.count, 3) + } + + private func drain() async { + for _ in 0..<3 { + await withCheckedContinuation { continuation in DispatchQueue.main.async { continuation.resume() } } + } + } + + func testWhileAgentsOnlyAddToHistoryTheViewStays() throws { + let preferences = MainActor.assumeIsolated { HubDefaults.preferences } + MainActor.assumeIsolated { preferences.agentNavigation = .listOnly } + defer { MainActor.assumeIsolated { preferences.agentNavigation = .navigate } } + let model = HubModel(wantedSession: nil, tab: .transcript) + model.selectedID = "claude:one" + drainMainQueue() + let shown = model.apply(HubRequest(["--pr", "acme/app#9", "--actor", "agent", "--note", "later", "--nav", "cli-id"])) + drainMainQueue() + XCTAssertFalse(shown) + XCTAssertEqual(model.mode, .sessions) + XCTAssertEqual(model.selectedID, "claude:one") + XCTAssertEqual(model.history.current?.target, place(.sessions, "claude:one")) + let added = try XCTUnwrap(model.history.entry("cli-id")) + XCTAssertEqual(added.note, "later") + XCTAssertEqual(added.target["pr"], "acme/app#9") + XCTAssertNil(model.history.offset(of: "cli-id"), "in the list, not on the back or forward stack") + // A person's own request still navigates. + XCTAssertTrue(model.apply(HubRequest(["--mode", "prs", "--actor", "user"]))) + XCTAssertEqual(model.mode, .prs) + } + + func testAHopShowsAnyPlaceAndBackReturns() throws { + let model = HubModel(wantedSession: nil, tab: .transcript) + for id in ["claude:one", "claude:two", "claude:three"] { + model.selectedID = id + drainMainQueue() + } + let first = try XCTUnwrap(model.history.entries.first) + MainActor.assumeIsolated { model.hop(to: first.id) } + drainMainQueue() + XCTAssertEqual(model.selectedID, "claude:one") + XCTAssertEqual(model.history.forwardEntry?.target, place(.sessions, "claude:two")) + XCTAssertEqual(model.history.entries.count, 3) + } + // Martin, 2026-10-06: Agents at parent X, child Y; a banner opens PR 123; back must land on X / Y again. func testBackFromAPrOpenedByABannerReturnsToTheAgentChild() { let model = HubModel(wantedSession: nil, tab: .transcript) @@ -269,14 +339,14 @@ final class HubNavigationTests: XCTestCase { model.prs.selectedID = "github.com/acme/app#123" } drainMainQueue() - XCTAssertEqual(model.history.back.last, place(.agents, child)) + XCTAssertEqual(model.history.backEntry?.target, place(.agents, child)) MainActor.assumeIsolated { model.goBack() } drainMainQueue() XCTAssertEqual(model.mode, .agents) let selected = MainActor.assumeIsolated { model.agents.selectedID } XCTAssertEqual(selected, child) - XCTAssertEqual(model.history.forward, [place(.prs, "github.com/acme/app#123")]) + XCTAssertEqual(model.history.forwardEntry?.target, place(.prs, "github.com/acme/app#123")) } private func drainMainQueue() { diff --git a/src/macos/GenesisTools/Tests/HubNotifyTests.swift b/src/macos/GenesisTools/Tests/HubNotifyTests.swift index b99881c5b8..821cc5fc92 100644 --- a/src/macos/GenesisTools/Tests/HubNotifyTests.swift +++ b/src/macos/GenesisTools/Tests/HubNotifyTests.swift @@ -68,11 +68,52 @@ final class HubNotifyTests: XCTestCase { let notDue = """ {"at":"2026-09-24T21:43:00.326Z","skipped":"not due: the last poll ran at 2026-09-24T21:42:59.184Z, every 3 min","dryRun":false,"repos":[],"items":[],"posted":0,"requests":0,"elapsedMs":2} """ - XCTAssertEqual(HubNotifyStore.pollSummary(Data(polled.utf8)), "Polled: 0 events, 0 posted, 1 requests") - XCTAssertEqual( - HubNotifyStore.pollSummary(Data(notDue.utf8)), - "Poll skipped: not due: the last poll ran at 2026-09-24T21:42:59.184Z, every 3 min" - ) + let done = HubNotifyStore.pollSummary(Data(polled.utf8)) + XCTAssertEqual(done.text, "Polled: 0 events, 0 posted, 1 request") + XCTAssertEqual(done.tone, .success) + XCTAssertNil(done.more) + // A skip is one sentence with its tone; the CLI's own reason waits behind Details (H5, H6). + let skipped = HubNotifyStore.pollSummary(Data(notDue.utf8)) + XCTAssertEqual(skipped.text, "The check did not run.") + XCTAssertEqual(skipped.tone, .warning) + XCTAssertEqual(skipped.more, "not due: the last poll ran at 2026-09-24T21:42:59.184Z, every 3 min") + let unreadable = HubNotifyStore.pollSummary(Data("not json".utf8)) + XCTAssertEqual(unreadable.tone, .warning) + XCTAssertEqual(unreadable.more, "not json") + } + + /// The bell and the PR activity page name the daemon task's state and its fix in the same words (H8, H17). + func testTheDaemonTaskStateAndItsFixReadTheSameEverywhere() throws { + let missing = try HubNotifyStatus.decode(Data(firstRunJSON.utf8)) + XCTAssertEqual(missing.daemonTitle, "Nothing polls yet: the daemon task is missing") + XCTAssertEqual(missing.daemonFixTitle, "Add the daemon task") + let ticking = try HubNotifyStatus.decode(Data(polledJSON.utf8)) + XCTAssertEqual(ticking.daemonTitle, "The daemon checks every minute") + XCTAssertNil(ticking.daemonFixTitle) + let stale = try HubNotifyStatus.decode(Data(firstRunJSON + .replacingOccurrences(of: "\"daemonTask\":false", with: "\"daemonTask\":true") + .replacingOccurrences(of: "\"daemonTaskEvery\":null", with: "\"daemonTaskEvery\":\"every 5 minutes\"").utf8)) + XCTAssertEqual(stale.daemonTitle, "The daemon task still runs every 5 minutes") + XCTAssertEqual(stale.daemonFixTitle, "Tick every minute") + let unread = try HubNotifyStatus.decode(Data(firstRunJSON + .replacingOccurrences(of: "\"daemonTask\":false", with: "\"daemonTask\":null").utf8)) + XCTAssertNil(unread.daemonFixTitle, "an unreadable task list offers no fix: the task may be there") + } + + /// One cadence list for the bell and the page; a stored value outside it stays pickable. + func testTheCadenceChoicesKeepAStoredValueOutsideTheList() { + XCTAssertEqual(HubNotifyWords.cadenceOptions(including: 3), HubNotifyWords.cadenceChoices) + XCTAssertEqual(HubNotifyWords.cadenceOptions(including: 7), [1, 2, 3, 5, 7, 10, 15, 20, 30, 45, 60]) + XCTAssertEqual(HubNotifyWords.cadenceTitle(1), "Every minute") + XCTAssertEqual(HubNotifyWords.cadenceTitle(15), "Every 15 minutes") + } + + /// A failing project reads as one sentence; the host's error is not in it. + func testAFailingProjectIsOneSentenceWithoutTheRawError() throws { + let status = try HubNotifyStatus.decode(Data(polledJSON.utf8)) + let failing = try XCTUnwrap(status.repos.values.first { $0.path == "/tmp/gt/shop" }) + XCTAssertTrue(failing.problemText.hasPrefix("2 polls failed. The next try is at "), failing.problemText) + XCTAssertFalse(failing.problemText.contains("no such host")) } /// The argv a banner click hands the hub (`openHubCommand` in notify-poll.ts, pinned by notify.test.ts). diff --git a/src/macos/GenesisTools/Tests/HubTimelineTests.swift b/src/macos/GenesisTools/Tests/HubTimelineTests.swift index 6c9542ee92..95772ee852 100644 --- a/src/macos/GenesisTools/Tests/HubTimelineTests.swift +++ b/src/macos/GenesisTools/Tests/HubTimelineTests.swift @@ -55,6 +55,24 @@ final class HubTimelineTests: XCTestCase { XCTAssertEqual(TimelineRange.allCases.prefix(3), [.lastHour, .last24h, .today]) } + /// An empty feed's "Show …" button reads the next range that covers more; the longest and a custom one have none. + func testAWiderRangeCoversMoreAndTheLongestHasNone() { + let now = date("2026-03-02T14:00:00Z") + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .gmt + for range in TimelineRange.allCases { + guard let wider = range.wider else { continue } + let narrow = range.interval(now: now, lastVisit: nil, customFrom: now, customTo: now, calendar: calendar) + let wide = wider.interval(now: now, lastVisit: nil, customFrom: now, customTo: now, calendar: calendar) + XCTAssertLessThanOrEqual(wide.start, narrow.start, "\(range) widens to \(wider)") + XCTAssertGreaterThan(wide.duration, narrow.duration, "\(range) widens to \(wider)") + } + XCTAssertEqual(TimelineRange.lastHour.wider, .last24h) + XCTAssertEqual(TimelineRange.last7.wider, .last30) + XCTAssertNil(TimelineRange.last30.wider) + XCTAssertNil(TimelineRange.custom.wider) + } + func testSinceLastVisitFallsBackToTheLast24HoursWithoutAnEarlierVisit() { let now = date("2026-03-02T14:00:00Z") let visited = date("2026-02-27T09:30:00Z") diff --git a/src/macos/GenesisTools/Tests/PermissionsModelTests.swift b/src/macos/GenesisTools/Tests/PermissionsModelTests.swift index 57bc948ff6..36c63f84b1 100644 --- a/src/macos/GenesisTools/Tests/PermissionsModelTests.swift +++ b/src/macos/GenesisTools/Tests/PermissionsModelTests.swift @@ -8,13 +8,15 @@ import XCTest final class PermissionsModelTests: XCTestCase { private final class FakeSystem: PermissionSystem, @unchecked Sendable { var statuses: [PermissionKind: PermissionStatus] = [:] + /// What a request answers; unset is a refusal for Downloads and a grant for the rest. + var answers: [PermissionKind: PermissionStatus] = [:] private(set) var requests: [PermissionKind] = [] func status(_ kind: PermissionKind) -> PermissionStatus { statuses[kind] ?? .notDetermined } func request(_ kind: PermissionKind) async -> PermissionStatus { requests.append(kind) - return kind == .downloadsFolder ? .denied : .granted + return answers[kind] ?? (kind == .downloadsFolder ? .denied : .granted) } func openSettings(_ kind: PermissionKind) -> Bool { true } @@ -53,4 +55,61 @@ final class PermissionsModelTests: XCTestCase { XCTAssertEqual(model.rows.first { $0.kind == .downloadsFolder }?.state, .denied) XCTAssertTrue(model.lastMessage?.contains("Downloads folder") == true) } + + /// The window refreshes when the user comes back from System Settings: the answered probe runs again (macOS + /// answered it before, so it does not prompt) and the "macOS refused" line goes once the grant is fixed. + func testARefreshAfterTheGrantIsFixedClearsTheRefusal() async throws { + let system = FakeSystem() + system.statuses[.downloadsFolder] = .unknown("asks on first use") + let model = PermissionsModel(access: PermissionAccess(system: system)) + model.refresh() + let downloads = try XCTUnwrap(model.rows.first { $0.kind == .downloadsFolder }) + model.request(downloads) + for _ in 0 ..< 200 where model.busy != nil { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertNotNil(model.lastMessage) + XCTAssertNil(model.busyTitle) + + system.answers[.downloadsFolder] = .granted + model.refresh() + for _ in 0 ..< 200 where model.lastMessage != nil { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertNil(model.lastMessage) + XCTAssertEqual(model.rows.first { $0.kind == .downloadsFolder }?.state, .granted) + XCTAssertEqual(system.requests, [.downloadsFolder, .downloadsFolder]) + } + + /// Coming back to the app reads the rows every time, but runs the answered probes again at most every 10 s, as the + /// permission center does (`PermissionAccess.activationRecheckInterval`). + func testActivationRerunsTheAnsweredProbesAtMostEvery10Seconds() async throws { + let system = FakeSystem() + system.statuses[.downloadsFolder] = .unknown("asks on first use") + let model = PermissionsModel(access: PermissionAccess(system: system)) + model.refresh() + model.request(try XCTUnwrap(model.rows.first { $0.kind == .downloadsFolder })) + for _ in 0 ..< 200 where model.busy != nil { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(system.requests.count, 1) + + let start = Date() + model.refreshOnActivation(now: start) + for _ in 0 ..< 200 where system.requests.count < 2 { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(system.requests.count, 2, "the first activation reruns the answered probe") + try await Task.sleep(for: .milliseconds(50)) + + model.refreshOnActivation(now: start.addingTimeInterval(3)) + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(system.requests.count, 2, "a second activation within 10 s runs no probe") + + model.refreshOnActivation(now: start.addingTimeInterval(11)) + for _ in 0 ..< 200 where system.requests.count < 3 { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(system.requests.count, 3, "after 10 s the next activation runs it again") + } } diff --git a/src/macos/GenesisTools/Tests/TestFocusTests.swift b/src/macos/GenesisTools/Tests/TestFocusTests.swift new file mode 100644 index 0000000000..1151aafb90 --- /dev/null +++ b/src/macos/GenesisTools/Tests/TestFocusTests.swift @@ -0,0 +1,19 @@ +import AppKit +import GenesisKitTestHooks +import XCTest + +/// A test run never takes the user's keyboard focus (GenesisKitTestHooks, loaded with this bundle). +@MainActor +final class TestFocusTests: XCTestCase { + func testAWindowAskedToBecomeKeyIsOnlyOrderedFront() { + _ = NSApplication.shared + let window = NSWindow(contentRect: NSRect(x: -2000, y: -2000, width: 40, height: 40), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let before = GenesisKitTestHooksRefusedFocus() + window.makeKeyAndOrderFront(nil) + XCTAssertFalse(window.isKeyWindow, "a test window must not take the keyboard") + XCTAssertEqual(GenesisKitTestHooksRefusedFocus() - before, 1) + } +} diff --git a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift index 90034ef535..c9a8e40c4b 100644 --- a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift +++ b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift @@ -304,6 +304,10 @@ final class VaultUnlockTests: XCTestCase { let content = VaultWindowContent.build(params, caller: who, session: VaultSessionInfo()) let fact = try XCTUnwrap(content.details.first { $0.label == "Command" }) XCTAssertFalse(content.purpose.contains(token)) + XCTAssertEqual(content.purpose, "To run this command:", "a purpose that only runs the command shows it as a block") + let shownCommand = try XCTUnwrap(content.command) + XCTAssertTrue(shownCommand.hasPrefix("tools claude config add work --token ")) + XCTAssertFalse(shownCommand.contains(token)) XCTAssertFalse((fact.help ?? "").contains(token)) vaultAudit("prompted", params: params, caller: who, session: VaultSessionInfo(), shown: content) for path in [GateAudit.path, (VaultGrants.directory as NSString).appendingPathComponent("audit.jsonl")] { @@ -603,3 +607,26 @@ final class VaultUnlockTests: XCTestCase { XCTAssertNotEqual(VaultWindowLock.path(for: "vault|/a|x.ts"), VaultWindowLock.path(for: "vault|/a|y.ts")) } } + +/// The route approval card (Sources/Host/RouteApproval.swift) takes the keyboard when it appears, so a Return meant for +/// another app must not run a command: Allow has no key, only Esc answers (Deny). UX catalogue Q1 and Q5. +@MainActor +final class RouteApprovalCardTests: XCTestCase { + func testAllowIsClickOnlyAndEscDenies() throws { + _ = NSApplication.shared + let card = RouteApprovalCard(argv: ["/usr/bin/true", "--flag"], open: nil) + let buttons = Self.buttons(in: try XCTUnwrap(card.contentView)) + let allow = try XCTUnwrap(buttons.first { $0.accessibilityLabel() == "Allow" }, "VoiceOver reads Allow alone") + let deny = try XCTUnwrap(buttons.first { $0.accessibilityLabel() == "Deny" }, "VoiceOver reads Deny, not Deny esc") + XCTAssertEqual(allow.keyEquivalent, "", "Return must not allow") + XCTAssertEqual(deny.keyEquivalent, "\u{1b}") + XCTAssertFalse(buttons.contains { $0.keyEquivalent == "\r" }, "no button answers Return") + XCTAssertEqual(card.accessibilitySubrole(), .dialog) + XCTAssertEqual(card.title, "Approval needed: Run true") + } + + private static func buttons(in view: NSView) -> [NSButton] { + let own: [NSButton] = (view as? NSButton).map { [$0] } ?? [] + return own + view.subviews.flatMap { buttons(in: $0) } + } +} diff --git a/src/macos/GenesisTools/Tests/WindowCycleTests.swift b/src/macos/GenesisTools/Tests/WindowCycleTests.swift index 826516e7fc..7b6710dbf1 100644 --- a/src/macos/GenesisTools/Tests/WindowCycleTests.swift +++ b/src/macos/GenesisTools/Tests/WindowCycleTests.swift @@ -79,6 +79,10 @@ final class WindowCycleTests: XCTestCase { XCTAssertTrue(WindowCycle.cameByCommandTab(sinceFlags: 0.05, sinceMouse: 4, sinceKey: 0.2)) XCTAssertFalse(WindowCycle.cameByCommandTab(sinceFlags: 2, sinceMouse: 4, sinceKey: 0.1), "a typed key (a launcher)") XCTAssertFalse(WindowCycle.cameByCommandTab(sinceFlags: 0.3, sinceMouse: 0.02, sinceKey: 5), "a click (Dock, link, notification)") + XCTAssertTrue(WindowCycle.cameByCommandTab(sinceFlags: 1.65, sinceMouse: 8.92, sinceKey: 0.29, commandHeld: true), + "activated while ⌘ is still down, right after Tab (logged 2026-10-25 15:13)") + XCTAssertFalse(WindowCycle.cameByCommandTab(sinceFlags: 1.65, sinceMouse: 8.92, sinceKey: 0.29, commandHeld: false), + "the same timing with ⌘ up is a typed key") } /// Any process can post the raise and focus notifications: a payload that does not fit is a plain raise or is diff --git a/src/macos/GenesisTools/web/diff-viewer/glide.test.ts b/src/macos/GenesisTools/web/diff-viewer/glide.test.ts index d695384686..12d9063108 100644 --- a/src/macos/GenesisTools/web/diff-viewer/glide.test.ts +++ b/src/macos/GenesisTools/web/diff-viewer/glide.test.ts @@ -6,7 +6,7 @@ import { createGlide, GLIDE_SETTLE_MS } from "./glide"; * A CodeView stand-in: an instant scroll lands at once, a smooth one only on `settle()` (the * animation frames), and the timers run on `advance()`. */ -function fakeView(start: number, places: Record<string, number>) { +function fakeView(start: number, places: Record<string, number>, reducedMotion = false) { let scrollTop = start; let smooth: number | null = null; let now = 0; @@ -50,6 +50,7 @@ function fakeView(start: number, places: Record<string, number>) { timers.push({ id, at: now + ms, run }); return id; }, + reducedMotion: () => reducedMotion, clearTimeout(id) { const index = timers.findIndex((timer) => timer.id === id); @@ -62,6 +63,7 @@ function fakeView(start: number, places: Record<string, number>) { return { glideTo, logs, + smoothPending: () => smooth !== null, scrollTop: () => scrollTop, readerScroll(top: number) { scrollTop = top; @@ -116,4 +118,15 @@ describe("glideTo", () => { expect(view.scrollTop()).toBe(9_000); }); + + test("under Reduce Motion the view lands on the target at once, with no glide", () => { + const view = fakeView(0, { "Filters.tsx:36": 2_986 }, true); + + view.glideTo({ type: "line", id: "Filters.tsx", lineNumber: 36, side: "additions", align: "center" }); + + expect(view.scrollTop()).toBe(2_986); + expect(view.smoothPending()).toBe(false); + view.advance(GLIDE_SETTLE_MS); + expect(view.logs).toEqual([]); + }); }); diff --git a/src/macos/GenesisTools/web/diff-viewer/glide.ts b/src/macos/GenesisTools/web/diff-viewer/glide.ts index 6b0b602bcb..4c51ff81c4 100644 --- a/src/macos/GenesisTools/web/diff-viewer/glide.ts +++ b/src/macos/GenesisTools/web/diff-viewer/glide.ts @@ -14,6 +14,11 @@ export interface GlideDeps { log: (message: string) => void; setTimeout: (callback: () => void, ms: number) => number; clearTimeout: (id: number) => void; + /** + * True while the system asks for reduced motion: the view lands on the target at once, with no glide. The + * default reads `prefers-reduced-motion`, which WebKit takes from the system's Reduce Motion, on each glide. + */ + reducedMotion?: () => boolean; } /** How long a glide may take before the view lands on its target at once. */ @@ -27,6 +32,9 @@ export const GLIDE_SETTLE_MS = 400; */ export function createGlide(deps: GlideDeps): (target: GlideTarget) => void { const { viewer } = deps; + const reducedMotion = + deps.reducedMotion ?? + (() => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); // Only the latest glide may land. A drafts-list click glides to the file and then to the card's // line; the file's check, still pending, would put the view back on the file's top whenever the // line lies between the file's top and where that glide started. @@ -42,7 +50,8 @@ export function createGlide(deps: GlideDeps): (target: GlideTarget) => void { const destination = viewer.getScrollTop(); const distance = destination - start; - if (Math.abs(distance) < 1) { + // Under Reduce Motion the instant scroll above is the whole move: travel is what it asks to drop. + if (Math.abs(distance) < 1 || reducedMotion()) { return; } diff --git a/src/macos/lib/kit-fixtures.ts b/src/macos/lib/kit-fixtures.ts index f5403ab230..cba40d3619 100644 --- a/src/macos/lib/kit-fixtures.ts +++ b/src/macos/lib/kit-fixtures.ts @@ -9,16 +9,19 @@ import { SafeJSON } from "@genesiscz/utils/json"; */ export const KIT_FIXTURES_DIR = join(import.meta.dir, "..", "GenesisKit", "Sources", "GenesisKitFixtures", "Disk"); -export function kitFixturePath(name: string): string { - return join(KIT_FIXTURES_DIR, name); +/** `Disk/` holds the Disk Manager contracts; `Git/` the git rules both languages implement (worktree renames). */ +export type KitFixtureFolder = "Disk" | "Git"; + +export function kitFixturePath(name: string, folder: KitFixtureFolder = "Disk"): string { + return join(KIT_FIXTURES_DIR, "..", folder, name); } -export function readKitFixture(name: string): string { - return readFileSync(kitFixturePath(name), "utf8"); +export function readKitFixture(name: string, folder: KitFixtureFolder = "Disk"): string { + return readFileSync(kitFixturePath(name, folder), "utf8"); } -export function readKitFixtureJson(name: string): unknown { - return SafeJSON.parse(readKitFixture(name), { strict: true }); +export function readKitFixtureJson(name: string, folder: KitFixtureFolder = "Disk"): unknown { + return SafeJSON.parse(readKitFixture(name, folder), { strict: true }); } /** The lines of a `contract-*.ndjson` fixture, parsed. */ diff --git a/src/macos/lib/permissions/app.test.ts b/src/macos/lib/permissions/app.test.ts index 7aacc198ac..fdfa4dc92d 100644 --- a/src/macos/lib/permissions/app.test.ts +++ b/src/macos/lib/permissions/app.test.ts @@ -28,6 +28,8 @@ import { readRunningExecutableInodes, runningExecutableInodes, runStreaming, + sourceHash, + sourceRootsFor, stampAppToolsPath, } from "./app"; import { @@ -1141,3 +1143,53 @@ describe("swift build progress", () => { expect(exitedAt - (seen[0]?.at ?? exitedAt)).toBeGreaterThan(250); }); }); + +describe("an install is stale only when the code it links changed", () => { + function sourceTree(): string { + const root = mkdtempSync(join(tmpdir(), "app-source-roots-")); + const app = join(root, "GenesisTools"); + const files = [ + "GenesisTools/Package.swift", + "GenesisTools/Sources/Host/Host.swift", + "GenesisTools/Sources/GenesisTools/main.swift", + "GenesisTools/Sources/Parts/Hub/Hub.swift", + "GenesisTools/web/diff-viewer/index.ts", + "GenesisKit/Sources/GenesisKitBase/Base.swift", + "GenesisKit/Sources/GenesisKit/Kit.swift", + ]; + + for (const file of files) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), `// ${file}\n`); + } + + return app; + } + + it("a host-only install ignores the parts, but sees its own sources and GenesisKitBase", () => { + const app = sourceTree(); + + try { + const host = sourceHash(app, []); + const hub = sourceHash(app, ["features", "hub"]); + writeFileSync(join(app, "Sources/Parts/Hub/Hub.swift"), "// changed\n"); + writeFileSync(join(app, "../GenesisKit/Sources/GenesisKit/Kit.swift"), "// changed\n"); + + expect(sourceHash(app, [])).toBe(host); + expect(sourceHash(app, ["features", "hub"])).not.toBe(hub); + + writeFileSync(join(app, "../GenesisKit/Sources/GenesisKitBase/Base.swift"), "// changed\n"); + expect(sourceHash(app, [])).not.toBe(host); + } finally { + rmSync(dirname(app), { recursive: true, force: true }); + } + }); + + it("a full build keeps the inputs it always had, so existing installs are not rebuilt", () => { + const all = sourceRootsFor(["clicky", "features", "hub", "markdown", "widget"]); + + expect(all).toContain("../GenesisKit/Sources"); + expect(all).toContain("ClickyLibrary.json"); + expect(sourceRootsFor([])).not.toContain("web"); + }); +}); diff --git a/src/macos/lib/permissions/app.ts b/src/macos/lib/permissions/app.ts index 906a0f3d4c..f296083b35 100644 --- a/src/macos/lib/permissions/app.ts +++ b/src/macos/lib/permissions/app.ts @@ -46,8 +46,11 @@ import { unpackClickyLibrary, } from "../clicky-library"; import { + ALL_PART_IDS, + APP_PARTS, bundleStepsFor, installedParts, + isFullBuild, type PartsChoice, partForFace, partsEnvValue, @@ -79,6 +82,47 @@ const SOURCE_ROOTS = [ "../GenesisKit/Package.swift", "../GenesisKit/Sources", ]; +/** + * What the host alone compiles: its own folders and GenesisKitBase (`GENESIS_KIT_BASE_ONLY`). A permissions-only + * install must not count as stale when a part it does not link changes, or every routine update rebuilt it. + */ +const HOST_SOURCE_ROOTS = [ + "Package.swift", + "Info.plist", + "Sources/Host", + "Sources/GenesisTools", + "scripts/AppIcon.icns", + "../GenesisKit/Package.swift", + "../GenesisKit/Sources/GenesisKitBase", +]; + +/** + * The inputs of a build with these parts. Every part links the whole GenesisKit; `web` and the Clicky pin are + * bundle inputs. A full build keeps `SOURCE_ROOTS` exactly, so its hash matches the manifests written before this. + */ +export function sourceRootsFor(parts: readonly string[]): readonly string[] { + if (isFullBuild(parts)) { + return SOURCE_ROOTS; + } + + if (parts.length === 0) { + return HOST_SOURCE_ROOTS; + } + + const partFolders = APP_PARTS.filter((part) => parts.includes(part.id)).map( + (part) => `Sources/Parts/${part.id.charAt(0).toUpperCase()}${part.id.slice(1)}` + ); + const clicky = bundleStepsFor(parts).has("clicky-library") ? ["ClickyLibrary.json"] : []; + + return [ + ...HOST_SOURCE_ROOTS.filter((root) => root !== "../GenesisKit/Sources/GenesisKitBase"), + ...partFolders, + "web", + ...clicky, + "../GenesisKit/Sources", + ]; +} + /** Browser half of the diff renderer (PierreWebDiffRenderer.swift): bundled into Contents/Resources/diff-viewer. */ const DIFF_VIEWER_SOURCE = "web/diff-viewer"; /** Which GitHub release asset holds Clicky's sound library (10 MB, kept out of the repository; clicky-library.ts). */ @@ -311,10 +355,10 @@ export function swiftFileListHash(sourceDir = APP_SOURCE_DIR): string { } /** Every file under the source roots, so a new Swift file or a re-rendered icon marks the build stale. */ -export function sourceHash(sourceDir = APP_SOURCE_DIR): string { +export function sourceHash(sourceDir = APP_SOURCE_DIR, parts: readonly string[] = ALL_PART_IDS): string { const hash = createHash("sha256"); - for (const root of SOURCE_ROOTS) { + for (const root of sourceRootsFor(parts)) { const full = join(sourceDir, root); if (!existsSync(full)) { @@ -431,10 +475,11 @@ export function appStatus(): AppStatus { const built = existsSync(launcherPath); const manifest = built ? readManifest() : undefined; const signature = built ? readSignature(bundlePath) : undefined; + const parts = installedParts(manifest); const stale = built && manifest !== undefined && - (manifest.sourceHash !== sourceHash() || + (manifest.sourceHash !== sourceHash(APP_SOURCE_DIR, parts) || (manifest.sourceToolsPath !== undefined && manifest.sourceToolsPath !== APP_TOOLS_PATH)); return { @@ -444,7 +489,7 @@ export function appStatus(): AppStatus { manifest, signature, stale, - parts: installedParts(manifest), + parts, identityStable: signature !== undefined && !signature.adhoc, }; } @@ -1271,7 +1316,7 @@ async function stageAndInstall(options: StageAndInstallOptions): Promise<BuildRe const signature = readSignature(stagedBundle) ?? { authority: "adhoc", adhoc: true }; const manifest: AppManifest = { builtAt: new Date().toISOString(), - sourceHash: sourceHash(), + sourceHash: sourceHash(APP_SOURCE_DIR, parts), ...(await readSourceInfo()), sourceToolsPath: APP_TOOLS_PATH, signedWith: signature.authority, diff --git a/src/mcp-manager/README.md b/src/mcp-manager/README.md index 19ba60fd3e..7e59992e44 100644 --- a/src/mcp-manager/README.md +++ b/src/mcp-manager/README.md @@ -714,6 +714,39 @@ A harness's own "Authenticate" button cannot do this: it runs Dynamic Client Reg against the gateway's origin, which serves no OAuth metadata and answers 404. Claude Code reports that as `Dynamic Client Registration rejected (HTTP 404)`. +### Servers without dynamic registration (HubSpot) + +Some servers publish no `registration_endpoint`, so mcp-manager cannot register an OAuth client +by itself. HubSpot's official MCP (`https://mcp.hubspot.com`) is one. Create the app by hand at the +provider (HubSpot: Development, then MCP Connectors, then Create) and enter this redirect URL +exactly: + +``` +http://127.0.0.1:8320/callback +``` + +Then store the app and log in: + +```bash +tools mcp-manager install hubspot https://mcp.hubspot.com -t http +tools mcp-manager auth client hubspot --client-id <id> # secret: hidden prompt, or pipe it on stdin +tools mcp-manager auth login hubspot +tools mcp-manager auth status hubspot # prints the redirect URL again +``` + +`auth client` sets `auth.policy` to `static-client` and stores the client ID and secret in the +GenesisTools vault (`mcp/<server>/static-client-*`). The secret is never an argument and never +goes into the unified config, a provider config or a log. `auth logout` keeps the stored app; +running `auth client` again replaces it. + +The login uses the stored app with PKCE S256 and sends `client_secret` in the POST body +(`client_secret_post`), for the code exchange and for every refresh. The callback binds port 8320 +(`WEB_SERVICES["mcp-oauth-callback"]`), not a random port, because the provider matches the +redirect URL exactly. A different loopback URL is possible with `auth client --redirect-uri +http://127.0.0.1:<port>/<path>`; the app at the provider must then list that one. When the +gateway gets a request for such a server and no app is stored, its 401 names the `auth client` +command and the redirect URL instead of opening a browser. + **Config changes need no restart.** The gateway re-reads the unified config on every request, so adding or editing a server is picked up immediately and sessions already connected keep their streams. Reconnect a harness only for a server it has never connected to: Claude Code diff --git a/src/mcp-manager/commands/auth.ts b/src/mcp-manager/commands/auth.ts index 3aa079f229..6bba922154 100644 --- a/src/mcp-manager/commands/auth.ts +++ b/src/mcp-manager/commands/auth.ts @@ -9,8 +9,8 @@ import { createBoxTable, formatDotStatus, renderCliHeader } from "@genesiscz/uti import pc from "picocolors"; import { CLIENT_NAME_DEFAULT } from "../lib/auth/constants.ts"; import { type AuthTokenState, describeAuthStatus } from "../lib/auth/display.ts"; -import { DynamicClientRegistrationError, loginMcpServer } from "../lib/auth/login.ts"; -import { isGatewayOauth, serverAuth } from "../lib/auth/policy.ts"; +import { DynamicClientRegistrationError, loginMcpServer, staticRedirectUriFor } from "../lib/auth/login.ts"; +import { isGatewayOauth, policyFor, serverAuth } from "../lib/auth/policy.ts"; import { CLIENT_NAME_ABORT, clientNameSelectOptions, @@ -18,9 +18,10 @@ import { oauthClientPresetFor, suggestedLoginCommand, } from "../lib/auth/presets.ts"; -import { deleteServerTokens } from "../lib/auth/secrets.ts"; +import { deleteServerTokens, hasStaticClient, writeStaticClient } from "../lib/auth/secrets.ts"; import { deleteAuthStatus, readAuthStatus } from "../lib/auth/status.ts"; import { accessTokenForRequest, peekAccessToken } from "../lib/auth/tokens.ts"; +import { staticClientRefusal } from "../lib/gateway/auto-login.ts"; import { ensureGatewayUp } from "../lib/gateway/ensure.ts"; import { runLogin } from "../lib/gateway/login-runner.ts"; import { clearPendingLogin, takeLivePendingLogin, writePendingLogin } from "../lib/gateway/login-state.ts"; @@ -85,6 +86,15 @@ export async function authLogin( return; } + const staticRefusal = await staticClientRefusal(name, server); + + if (staticRefusal) { + ui.err(staticRefusal); + process.exitCode = 1; + + return; + } + const clientName = await resolveClientName(name, server.url ?? server.httpUrl, opts.clientName); if (clientName === undefined) { @@ -223,6 +233,116 @@ function printDcrFailure(view: DcrFailureView): void { } } +/** + * Store a pre-registered OAuth app for a server without dynamic registration (HubSpot). + * + * The secret comes from a hidden prompt, or from stdin when stdin is not a terminal. It is + * never an argument, so it stays out of shell history and `ps`, and it goes to the vault only: + * the unified config gets the policy and the redirect URL, which are not secret. + */ +export async function authClient( + serverName: string | undefined, + opts: { clientId?: string; redirectUri?: string } = {} +): Promise<void> { + const config = await readUnifiedConfig(); + const server = serverName ? config.mcpServers[serverName] : undefined; + + if (!serverName || !server) { + logger.error(`Unknown server '${serverName ?? ""}'. Add it with ${toolCommand("mcp-manager install")} first.`); + process.exitCode = 1; + + return; + } + + if (!(server.url ?? server.httpUrl)) { + logger.error(`${serverName} has no HTTP url. A static OAuth client only applies to a remote server.`); + process.exitCode = 1; + + return; + } + + const clientId = opts.clientId?.trim(); + + if (!clientId) { + logger.error( + `--client-id is required: ${toolCommand("mcp-manager auth client", serverName, "--client-id", "<id>")}` + ); + process.exitCode = 1; + + return; + } + + const redirectUri = opts.redirectUri?.trim() || staticRedirectUriFor(server); + const redirectProblem = loopbackRedirectProblem(redirectUri); + + if (redirectProblem) { + logger.error(redirectProblem); + process.exitCode = 1; + + return; + } + + const clientSecret = await readClientSecret(serverName); + + if (clientSecret === undefined) { + return; + } + + if (!clientSecret) { + logger.error("The client secret is empty. Pipe it on stdin or type it at the hidden prompt."); + process.exitCode = 1; + + return; + } + + await writeStaticClient(serverName, { clientId, clientSecret }); + setGlobalOptions({ yes: true }); + server.auth = { + ...server.auth, + kind: "oauth", + gateway: true, + policy: "static-client", + redirectUri, + }; + await writeUnifiedConfig(config); + ui.ok(`stored the OAuth client for ${serverName}`); + ui.kv("client", clientId); + ui.kv("redirect", redirectUri); + ui.dim("The provider must list this exact redirect URL."); + ui.dim(`next: ${toolCommand("mcp-manager auth login", serverName)}`); +} + +async function readClientSecret(server: string): Promise<string | undefined> { + if (!process.stdin.isTTY) { + return (await Bun.stdin.text()).trim(); + } + + const typed = await p.password({ message: `Client secret for ${server}` }); + + if (p.isCancel(typed) || typeof typed !== "string") { + return undefined; + } + + return typed.trim(); +} + +/** The callback listener binds this URL, so it must be a loopback http URL with an explicit port. */ +function loopbackRedirectProblem(redirectUri: string): string | undefined { + let url: URL; + + try { + url = new URL(redirectUri); + } catch { + return `--redirect-uri is not a URL: ${redirectUri}`; + } + + if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port) { + return `--redirect-uri must be http://127.0.0.1:<port>/<path> (loopback, explicit port), not ${redirectUri}`; + } + + return undefined; +} + export async function authLogout(serverName: string | undefined): Promise<void> { if (!serverName) { logger.error("server name required"); @@ -270,6 +390,7 @@ export async function authStatus(serverName: string | undefined): Promise<void> const names = serverName ? [serverName] : Object.keys(config.mcpServers).sort(); const table = createBoxTable(["SERVER", "AUTH", "TOKEN", "EXPIRES"]); const loginNeeded: string[] = []; + const staticClients: Array<{ name: string; redirectUri: string; stored: boolean }> = []; let rows = 0; let issuer: string | undefined; @@ -304,6 +425,14 @@ export async function authStatus(serverName: string | undefined): Promise<void> loginNeeded.push(name); } + if (policyFor(server) === "static-client") { + staticClients.push({ + name, + redirectUri: staticRedirectUriFor(server), + stored: await hasStaticClient(name), + }); + } + if (serverName) { const status = await readAuthStatus(name); issuer = "issuer" in status ? status.issuer : undefined; @@ -326,6 +455,16 @@ export async function authStatus(serverName: string | undefined): Promise<void> ui.dim(`issuer ${issuer}`); } + for (const client of staticClients) { + ui.dim(`${client.name} redirect URL (register it with the provider): ${client.redirectUri}`); + + if (!client.stored) { + ui.warn( + `${client.name} has no stored OAuth client: ${toolCommand("mcp-manager auth client", client.name, "--client-id", "<id>")}` + ); + } + } + for (const name of loginNeeded) { ui.dim(`login: ${suggestCommand("tools mcp-manager", { replaceCommand: ["auth", "login", name] })}`); } diff --git a/src/mcp-manager/commands/index.ts b/src/mcp-manager/commands/index.ts index 88e1d5148c..d36a4012a9 100644 --- a/src/mcp-manager/commands/index.ts +++ b/src/mcp-manager/commands/index.ts @@ -2,7 +2,7 @@ * Re-export all commands */ -export { authLogin, authLogout, authRefresh, authStatus } from "./auth.js"; +export { authClient, authLogin, authLogout, authRefresh, authStatus } from "./auth.js"; export { backupAllConfigs } from "./backup.js"; export { openConfig } from "./config.js"; export { configJson } from "./config-json.js"; diff --git a/src/mcp-manager/index.ts b/src/mcp-manager/index.ts index 0649d68d55..3d31f1af5b 100644 --- a/src/mcp-manager/index.ts +++ b/src/mcp-manager/index.ts @@ -17,6 +17,7 @@ handleReadmeFlag(import.meta.url); import { runTool } from "@genesiscz/utils/cli"; import { + authClient, authLogin, authLogout, authRefresh, @@ -47,6 +48,7 @@ import { syncFromProviders, syncServers, } from "./commands/index.js"; +import { STATIC_CLIENT_REDIRECT_URI_DEFAULT } from "./lib/auth/constants.ts"; import { setGlobalOptions } from "./utils/config.utils.js"; /** @@ -307,6 +309,19 @@ auth.command("login [server]") }); }); +auth.command("client <server>") + .description( + "Store a pre-registered OAuth app for a server without dynamic registration (secret: hidden prompt or stdin)" + ) + .requiredOption("--client-id <id>", "OAuth client ID from the provider's app") + .option( + "--redirect-uri <url>", + `Loopback redirect URL registered with the provider (default ${STATIC_CLIENT_REDIRECT_URI_DEFAULT})` + ) + .action(async (server, cmdOptions) => { + await authClient(server, { clientId: cmdOptions.clientId, redirectUri: cmdOptions.redirectUri }); + }); + auth.command("logout [server]").action(async (server) => { await authLogout(server); }); diff --git a/src/mcp-manager/lib/auth/constants.ts b/src/mcp-manager/lib/auth/constants.ts index f439ee1fdf..e301d09db7 100644 --- a/src/mcp-manager/lib/auth/constants.ts +++ b/src/mcp-manager/lib/auth/constants.ts @@ -11,6 +11,12 @@ export const DEFAULT_GATEWAY_PORT = WEB_SERVICES["mcp-gateway"].port; export const ACCESS_SKEW_MS = 60_000; +/** + * Redirect for servers with a pre-registered OAuth app (no dynamic registration). The + * provider matches the redirect URL exactly, so the callback cannot use a random port. + */ +export const STATIC_CLIENT_REDIRECT_URI_DEFAULT = `http://127.0.0.1:${WEB_SERVICES["mcp-oauth-callback"].port}/callback`; + export const CLIENT_NAME_DEFAULT = "Genesis Tools (mcp-manager)"; export const REDACTED = "•••"; diff --git a/src/mcp-manager/lib/auth/login.ts b/src/mcp-manager/lib/auth/login.ts index 57d696173c..1a3023e832 100644 --- a/src/mcp-manager/lib/auth/login.ts +++ b/src/mcp-manager/lib/auth/login.ts @@ -2,6 +2,7 @@ import type { UnifiedMCPServerConfig } from "@app/mcp-manager/utils/providers/ty import { startCallbackListener } from "@genesiscz/utils/ai/oauth/callback-server"; import { presentAuthorizationUrl } from "@genesiscz/utils/ai/oauth/login-ui"; import { generatePkcePair } from "@genesiscz/utils/ai/oauth/pkce"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { @@ -11,12 +12,13 @@ import { startDeviceFlow, } from "@genesiscz/utils/oauth/device-flow"; import type { DeviceFlowConfig } from "@genesiscz/utils/oauth/types"; +import { STATIC_CLIENT_REDIRECT_URI_DEFAULT } from "./constants.ts"; import { discoverMcp } from "./discovery.ts"; import { mcpFetchChecked, readJsonRecord } from "./fetch.ts"; import { clientNameFor, policyFor, serverAuth } from "./policy.ts"; import { type DcrFailureView, describeDcrFailure } from "./presets.ts"; import { safeTokenErrorCode } from "./redact.ts"; -import { replaceServerTokens } from "./secrets.ts"; +import { readStaticClient, replaceServerTokens, type StaticClient } from "./secrets.ts"; import { writeAuthStatus } from "./status.ts"; import { assertDiscoveryTarget } from "./url-policy.ts"; @@ -30,6 +32,21 @@ export class DynamicClientRegistrationError extends Error { } } +/** Thrown when a static-client server has no stored app. The gateway turns it into a 401 that names the fix. */ +export class StaticClientMissingError extends Error { + constructor(server: string, redirectUri: string) { + super( + `${server} has no stored OAuth client. Create an app at the provider with the redirect URL ${redirectUri}, then run ${toolCommand("mcp-manager auth client", server, "--client-id", "<id>")}` + ); + this.name = "StaticClientMissingError"; + } +} + +/** The fixed callback a static-client server's provider must have registered. */ +export function staticRedirectUriFor(config: UnifiedMCPServerConfig): string { + return serverAuth(config)?.redirectUri ?? STATIC_CLIENT_REDIRECT_URI_DEFAULT; +} + export interface LoginOptions { server: string; config: UnifiedMCPServerConfig; @@ -57,6 +74,21 @@ function form(body: Record<string, string>): URLSearchParams { return new URLSearchParams(body); } +async function registerDynamicClient( + registrationEndpoint: string | undefined, + redirectUri: string, + clientName: string, + hint: { server: string; mcpUrl: string } +): Promise<{ client_id: string; client_secret?: string }> { + if (!registrationEndpoint) { + throw new Error( + `${hint.mcpUrl} has no registration_endpoint, so it needs a pre-registered OAuth app. Run ${toolCommand("mcp-manager auth client", hint.server, "--client-id", "<id>")}` + ); + } + + return registerClient(registrationEndpoint, redirectUri, clientName, hint); +} + async function registerClient( registrationEndpoint: string, redirectUri: string, @@ -209,38 +241,49 @@ export async function loginMcpServer(options: LoginOptions): Promise<LoginResult const policy = policyFor(options.config); const clientName = options.clientName?.trim() || clientNameFor(options.config); + // A static client is a pre-registered app: the provider matches the redirect URL + // exactly, so the callback binds the one fixed port that app was registered with. + let staticClient: StaticClient | undefined; + const staticRedirectUri = policy === "static-client" ? staticRedirectUriFor(options.config) : undefined; + + if (staticRedirectUri) { + staticClient = await readStaticClient(options.server); + + if (!staticClient) { + throw new StaticClientMissingError(options.server, staticRedirectUri); + } + } + const pkce = await generatePkcePair(); const listener = await startCallbackListener({ - redirectUri: "http://127.0.0.1:0/callback", - port: 0, + redirectUri: staticRedirectUri ?? "http://127.0.0.1:0/callback", + port: staticRedirectUri ? undefined : 0, timeoutMs: 10 * 60 * 1000, verifyState: (state) => (state === pkce.state ? undefined : "state mismatch"), brand: { app: "Genesis Tools", product: "mcp-manager" }, }); if (!listener) { - throw new Error("Could not bind a loopback callback port"); + throw new Error( + staticRedirectUri + ? `Could not bind ${staticRedirectUri}. Is another login holding that port? The provider only accepts this exact redirect URL.` + : "Could not bind a loopback callback port" + ); } - const redirectUri = `http://127.0.0.1:${listener.port}/callback`; + const redirectUri = staticRedirectUri ?? `http://127.0.0.1:${listener.port}/callback`; // Registration lives INSIDE the try that owns the listener. It throws on three // ordinary paths — a static-client policy, a server with no registration_endpoint, // and the Figma 403 this PR exists for — and outside the try each of those left the // loopback port bound for the full 10-minute timeout after the command returned. try { - if (policy === "static-client") { - throw new Error(`${options.server} policy is static-client; store a client-id first`); - } - - if (!as.registration_endpoint) { - throw new Error(`${as.issuer} has no registration_endpoint`); - } - - const registered = await registerClient(as.registration_endpoint, redirectUri, clientName, { - server: options.server, - mcpUrl, - }); + const registered = staticClient + ? { client_id: staticClient.clientId, client_secret: staticClient.clientSecret } + : await registerDynamicClient(as.registration_endpoint, redirectUri, clientName, { + server: options.server, + mcpUrl, + }); const clientId = registered.client_id; if (options.device || policy === "device-code") { diff --git a/src/mcp-manager/lib/auth/secrets.ts b/src/mcp-manager/lib/auth/secrets.ts index 5b526066e6..e097cd5594 100644 --- a/src/mcp-manager/lib/auth/secrets.ts +++ b/src/mcp-manager/lib/auth/secrets.ts @@ -90,11 +90,57 @@ export async function readServerTokenSnapshot( expiresAt: expiresNumber !== undefined && Number.isFinite(expiresNumber) ? expiresNumber : undefined, hasRefresh: snapshot.has(secretPath(server, "refresh-token")), refreshToken: options.includeRefreshToken ? await snapshot.get(secretPath(server, "refresh-token")) : undefined, - clientId: options.includeClient ? await snapshot.get(secretPath(server, "client-id")) : undefined, - clientSecret: options.includeClient ? await snapshot.get(secretPath(server, "client-secret")) : undefined, + // A stored static client wins over the slot a login wrote: `auth client` may have + // rotated the secret since, and a refresh must post the current one. + clientId: options.includeClient + ? ((await snapshot.get(secretPath(server, "static-client-id"))) ?? + (await snapshot.get(secretPath(server, "client-id")))) + : undefined, + clientSecret: options.includeClient + ? ((await snapshot.get(secretPath(server, "static-client-secret"))) ?? + (await snapshot.get(secretPath(server, "client-secret")))) + : undefined, }; } +export interface StaticClient { + clientId: string; + clientSecret?: string; +} + +/** + * A pre-registered OAuth app for a server without dynamic registration (HubSpot). + * + * Kept apart from the `client-id` / `client-secret` slots a login writes, because a + * logout or a fresh login clears those, and this app was typed in by hand once. + */ +export async function writeStaticClient(server: string, client: StaticClient): Promise<void> { + await withServerCredentialsLock(server, async () => { + await writeSecret(secretPath(server, "static-client-id"), client.clientId); + + if (client.clientSecret) { + await writeSecret(secretPath(server, "static-client-secret"), client.clientSecret); + } else { + await deleteSecret(secretPath(server, "static-client-secret")); + } + }); +} + +export async function readStaticClient(server: string): Promise<StaticClient | undefined> { + const snapshot = secretSnapshot(); + const clientId = await snapshot.get(secretPath(server, "static-client-id")); + + if (!clientId) { + return undefined; + } + + return { clientId, clientSecret: await snapshot.get(secretPath(server, "static-client-secret")) }; +} + +export async function hasStaticClient(server: string): Promise<boolean> { + return hasSecret(secretPath(server, "static-client-id")); +} + /** * Patch write: an omitted field is left alone. Correct for a refresh, which only ever * learns a new access token (and sometimes a rotated refresh token). diff --git a/src/mcp-manager/lib/gateway/auto-login.test.ts b/src/mcp-manager/lib/gateway/auto-login.test.ts index fa335f375d..0c74319cba 100644 --- a/src/mcp-manager/lib/gateway/auto-login.test.ts +++ b/src/mcp-manager/lib/gateway/auto-login.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { join } from "node:path"; -import { autoLoginRefusal, createLoginLauncher } from "./auto-login.ts"; +import { autoLoginRefusal, createLoginLauncher, staticClientRefusal } from "./auto-login.ts"; import { loginSpawnArgs } from "./login-runner.ts"; import { gatewayRepoRoot } from "./service.ts"; @@ -393,6 +393,47 @@ describe("auto-login refuses a preset that needs an interactive client_name", () }); }); +describe("auto-login refuses a static-client server with no stored app", () => { + const crm = { + type: "http" as const, + url: "https://mcp.crm.example", + auth: { kind: "oauth" as const, gateway: true, policy: "static-client" as const }, + }; + + test("no stored app: the refusal names auth client and the redirect URL", async () => { + const reason = await staticClientRefusal("crm", crm, async () => false); + + expect(reason).toContain("auth client crm --client-id <id>"); + expect(reason).toContain("http://127.0.0.1:8320/callback"); + }); + + test("a custom redirect URL is the one named", async () => { + const reason = await staticClientRefusal( + "crm", + { ...crm, auth: { ...crm.auth, redirectUri: "http://127.0.0.1:9123/cb" } }, + async () => false + ); + + expect(reason).toContain("http://127.0.0.1:9123/cb"); + }); + + test("a stored app lets the login start", async () => { + expect(await staticClientRefusal("crm", crm, async () => true)).toBeUndefined(); + }); + + test("a dynamic-registration server never asks the store", async () => { + const reason = await staticClientRefusal( + "shop", + { type: "http", url: "https://mcp.shop.example/mcp", auth: { kind: "oauth", gateway: true } }, + async () => { + throw new Error("store must not be read"); + } + ); + + expect(reason).toBeUndefined(); + }); +}); + describe("the gateway spawns mcp-manager directly", () => { test("login argv is the tool entrypoint, not the tools wrapper", () => { const args = loginSpawnArgs("wisprflow"); diff --git a/src/mcp-manager/lib/gateway/auto-login.ts b/src/mcp-manager/lib/gateway/auto-login.ts index af0d07a8c2..258714bfa3 100644 --- a/src/mcp-manager/lib/gateway/auto-login.ts +++ b/src/mcp-manager/lib/gateway/auto-login.ts @@ -16,8 +16,10 @@ import type { UnifiedMCPServerConfig } from "@app/mcp-manager/utils/providers/types.js"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { logger } from "@genesiscz/utils/logger"; -import { serverAuth } from "../auth/policy.ts"; +import { staticRedirectUriFor } from "../auth/login.ts"; +import { policyFor, serverAuth } from "../auth/policy.ts"; import { oauthClientPresetFor } from "../auth/presets.ts"; +import { hasStaticClient } from "../auth/secrets.ts"; export type LoginRequestOutcome = "started" | "in-flight" | "cooling-down"; @@ -79,6 +81,23 @@ export function autoLoginRefusal( return `${name} needs an interactive client_name. Run ${toolCommand("mcp-manager auth login", name)}`; } +/** + * A static-client server (no dynamic registration, HubSpot) cannot log in until its + * pre-registered app is stored. Opening a browser would only fail after the redirect, + * so the caller names the `auth client` command and the redirect URL instead. + */ +export async function staticClientRefusal( + name: string, + server: UnifiedMCPServerConfig, + stored: (server: string) => Promise<boolean> = hasStaticClient +): Promise<string | undefined> { + if (policyFor(server) !== "static-client" || (await stored(name))) { + return undefined; + } + + return `${name} needs a pre-registered OAuth app. Register the redirect URL ${staticRedirectUriFor(server)} with the provider, then run ${toolCommand("mcp-manager auth client", name, "--client-id", "<id>")}`; +} + export function createLoginLauncher(deps: LoginLauncherDeps): LoginLauncher { const now = deps.now ?? Date.now; const cooldownMs = deps.cooldownMs ?? DEFAULT_COOLDOWN_MS; diff --git a/src/mcp-manager/lib/gateway/login-runner.ts b/src/mcp-manager/lib/gateway/login-runner.ts index 14c9d9308a..09c2a8b404 100644 --- a/src/mcp-manager/lib/gateway/login-runner.ts +++ b/src/mcp-manager/lib/gateway/login-runner.ts @@ -23,7 +23,7 @@ import { logger } from "@genesiscz/utils/logger"; import { sendNotification } from "@genesiscz/utils/macos/notifications"; import { genesisToolsDir } from "@genesiscz/utils/storage/root"; import { serverAuth } from "../auth/policy.ts"; -import { autoLoginRefusal, createLoginLauncher, type LoginLauncher } from "./auto-login.ts"; +import { autoLoginRefusal, createLoginLauncher, type LoginLauncher, staticClientRefusal } from "./auto-login.ts"; import { pendingLoginPath, readPendingLogin, takeLivePendingLogin, writePendingLogin } from "./login-state.ts"; import { gatewayRepoRoot } from "./service.ts"; @@ -56,7 +56,8 @@ export async function runLogin( throw new Error(`Unknown server '${server}'`); } - const refusal = autoLoginRefusal(server, unified, clientNameOverride); + const refusal = + autoLoginRefusal(server, unified, clientNameOverride) ?? (await staticClientRefusal(server, unified)); if (refusal) { throw new Error(refusal); diff --git a/src/mcp-manager/lib/gateway/server.ts b/src/mcp-manager/lib/gateway/server.ts index e7c0687a7c..0b0d6c2d00 100644 --- a/src/mcp-manager/lib/gateway/server.ts +++ b/src/mcp-manager/lib/gateway/server.ts @@ -7,7 +7,7 @@ import { isGatewayOauth, serverAuth } from "../auth/policy.ts"; import { gatewayBaseUrl, gatewayListen } from "../auth/project.ts"; import { ensureGatewayClientToken } from "../auth/secrets.ts"; import { accessTokenForRequest } from "../auth/tokens.ts"; -import { autoLoginRefusal, type LoginLauncher } from "./auto-login.ts"; +import { autoLoginRefusal, type LoginLauncher, staticClientRefusal } from "./auto-login.ts"; import { headersToClient, headersToUpstream, @@ -54,8 +54,12 @@ function jsonRpcError(message: string, status = 401): Response { * its own "Authenticate" button cannot use it here, because that button registers an * OAuth client against the GATEWAY's origin, which serves no metadata and answers 404. */ -function loginRequiredResponse(name: string, server: UnifiedMCPServerConfig, launcher: LoginLauncher): Response { - const refusal = autoLoginRefusal(name, server); +async function loginRequiredResponse( + name: string, + server: UnifiedMCPServerConfig, + launcher: LoginLauncher +): Promise<Response> { + const refusal = autoLoginRefusal(name, server) ?? (await staticClientRefusal(name, server)); if (refusal) { logger.info({ server: name }, "gateway skipped auto-login that cannot finish unattended"); @@ -211,7 +215,7 @@ export async function startGatewayServer( const resource = auth?.resource ?? upstreamUrl.replace(/\/+$/, ""); const tokenEndpoint = auth?.tokenEndpoint; const diagnostic = isDiagnosticRequest(request); - const loginRequired = (): Response => + const loginRequired = (): Response | Promise<Response> => diagnostic ? loginNeededResponse(name) : loginRequiredResponse(name, unified, launcher); if (!tokenEndpoint) { diff --git a/src/mcp-manager/utils/providers/types.ts b/src/mcp-manager/utils/providers/types.ts index 4bdea72b12..4b64ee1d43 100644 --- a/src/mcp-manager/utils/providers/types.ts +++ b/src/mcp-manager/utils/providers/types.ts @@ -26,6 +26,8 @@ export interface McpServerAuth { authorizationServer?: string; tokenEndpoint?: string; clientName?: string; + /** Fixed loopback redirect for a static-client server; it must match the one registered with the provider. */ + redirectUri?: string; } export interface McpGatewayListen { diff --git a/src/ms-teams/lib/decode.test.ts b/src/ms-teams/lib/decode.test.ts index 4d90a96bdf..e8499eadb4 100644 --- a/src/ms-teams/lib/decode.test.ts +++ b/src/ms-teams/lib/decode.test.ts @@ -36,3 +36,29 @@ describe("foldTeamsText", () => { expect(foldTeamsText("Ada Lovelace")).toBe("ada lovelace"); }); }); + +describe("readDumpSummary", () => { + test("returns the counts and names a store the dump could not find", async () => { + const { readDumpSummary } = await import("./ingest"); + const warnings: string[] = []; + const { out } = await import("@genesiscz/utils/logger"); + const original = out.log.warn; + out.log.warn = (message: string) => { + warnings.push(message); + }; + try { + const counts = readDumpSummary( + 'log line\n{"ok": true, "counts": {"conversations": 458, "replychains": 0}, "missing": {"replychains": "no store"}}\n' + ); + expect(counts).toEqual({ conversations: 458, replychains: 0 }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('"replychains"'); + expect(readDumpSummary('{"ok": true, "counts": {"conversations": 1}, "missing": {}}')).toEqual({ + conversations: 1, + }); + expect(warnings).toHaveLength(1); + } finally { + out.log.warn = original; + } + }); +}); diff --git a/src/ms-teams/lib/ingest.ts b/src/ms-teams/lib/ingest.ts index d911b18d27..9c1b92db2b 100644 --- a/src/ms-teams/lib/ingest.ts +++ b/src/ms-teams/lib/ingest.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; -import { logger } from "@genesiscz/utils/logger"; +import { logger, out } from "@genesiscz/utils/logger"; import { withFileLock } from "@genesiscz/utils/storage"; import { cacheDbPath, dumpIdbScript, venvDir, venvPython } from "./paths"; import { liveIdbMtimeMs, snapshotTeamsIdb } from "./snapshot"; @@ -142,15 +142,28 @@ async function runDump(idb: string, blob: string, out: string): Promise<Record<s throw new Error(`IndexedDB dump failed:\n${result.stderr || result.stdout}`); } + return readDumpSummary(result.stdout); +} + +/** + * The dump script's last line: the records per store, and the stores it could not find. A missing store is a + * warning on stderr, not a silent 0: Teams renamed `replychains` to `replychains-2` and every sync ingested 0 + * messages while the summary looked fine (2026-10-23). + */ +export function readDumpSummary(stdout: string): Record<string, number> { + let parsed: { counts?: Record<string, number>; missing?: Record<string, string> }; try { - const parsed = SafeJSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}", { strict: true }) as { - counts?: Record<string, number>; - }; - return parsed.counts ?? {}; + parsed = SafeJSON.parse(stdout.trim().split("\n").at(-1) ?? "{}", { strict: true }); } catch (err) { - log.debug({ err, stdout: result.stdout }, "[ms-teams] dump summary was not JSON"); + log.debug({ err, stdout }, "[ms-teams] dump summary was not JSON"); return {}; } + + for (const [store, reason] of Object.entries(parsed.missing ?? {})) { + out.log.warn(`Teams store "${store}" was not found, so it read nothing: ${reason}. Teams may have renamed it.`); + } + + return parsed.counts ?? {}; } async function spawnOk(cmd: string[], timeout = 60_000): Promise<{ ok: boolean; stdout: string; stderr: string }> { diff --git a/src/ms-teams/native/dump_idb.py b/src/ms-teams/native/dump_idb.py index a6ab091044..8f72131852 100644 --- a/src/ms-teams/native/dump_idb.py +++ b/src/ms-teams/native/dump_idb.py @@ -10,12 +10,14 @@ from pathlib import Path +# Each dump's database and its store names, newest first: Teams renames a store when its schema changes +# (2026-10-23: "replychains" became "replychains-2"), and an older Teams build keeps the old name. STORES = { - "conversations": ("Teams:conversation-manager:", "conversations"), - "replychains": ("Teams:replychain-manager:", "replychains"), - "profiles": ("Teams:profiles:", "profiles"), - "calls": ("Teams:call-history-manager:", "call-history"), - "activity": ("Teams:activity-manager:", "feed-items"), + "conversations": ("Teams:conversation-manager:", ("conversations",)), + "replychains": ("Teams:replychain-manager:", ("replychains-2", "replychains")), + "profiles": ("Teams:profiles:", ("profiles",)), + "calls": ("Teams:call-history-manager:", ("call-history",)), + "activity": ("Teams:activity-manager:", ("feed-items",)), } @@ -48,16 +50,19 @@ def find_database(wrapped, needle: str): return None -def dump_store(wrapped, needle: str, store_name: str, out_path: Path) -> int: +def dump_store(wrapped, needle: str, store_names, out_path: Path, missing: dict, label: str) -> int: db = find_database(wrapped, needle) if db is None: out_path.write_text("", encoding="utf-8") - print(f"missing database containing {needle!r}", file=sys.stderr) + missing[label] = f"no database containing {needle!r}" + print(missing[label], file=sys.stderr) return 0 names = list(db.object_store_names) - if store_name not in names: + store_name = next((name for name in store_names if name in names), None) + if store_name is None: out_path.write_text("", encoding="utf-8") - print(f"{needle!r} has stores {names}, not {store_name!r}", file=sys.stderr) + missing[label] = f"{needle!r} has stores {names}, none of {list(store_names)}" + print(missing[label], file=sys.stderr) return 0 store = db.get_object_store_by_name(store_name) n = 0 @@ -92,9 +97,10 @@ def main() -> int: blob = args.blob if Path(args.blob).exists() else None wrapped = WrappedIndexDB(args.idb, blob) counts = {} - for label, (needle, store_name) in STORES.items(): - counts[label] = dump_store(wrapped, needle, store_name, out / f"{label}.jsonl") - print(json.dumps({"ok": True, "counts": counts})) + missing = {} + for label, (needle, store_names) in STORES.items(): + counts[label] = dump_store(wrapped, needle, store_names, out / f"{label}.jsonl", missing, label) + print(json.dumps({"ok": True, "counts": counts, "missing": missing})) return 0 diff --git a/src/question/README.md b/src/question/README.md index 09fa769cb5..5885b064da 100644 --- a/src/question/README.md +++ b/src/question/README.md @@ -348,7 +348,7 @@ Decision states: `open`, `drafted`, `answered` (stored, waiting for delivery), ` - **The user answered in chat.** The agent records it in the same turn, so the inbox stops asking: `question_update { id, state: "acknowledged", option, answer: "<the user's words or a short copy>" }` (CLI: `tools question update` with the same JSON on stdin). `open` and `drafted` may move straight to `acknowledged` when the row then has `option` or `answer`. Nothing is delivered: every send reads `answered` rows only. - **A later decision replaced it.** `question_update { id, state: "superseded", supersededBy: "d_<n>_<session>", comment: "<why>" }`. `superseded` is legal from `open`, `drafted`, `sent` and `acknowledged`, for decisions only, and is terminal. It needs `supersededBy` or a `comment`. `supersededBy` must name another stored decision (any session). Unlike `supersedes` on a post, the old item keeps its text and number; the widget card links to the new one. -- **`answeredVia`** on a stored row says who stored the answer: `inbox` (the move to `answered`: widget, hub, CLI `answer`) or `agent` (a move from `open` or `drafted` to `acknowledged`). The store sets it. +- **`answeredVia`** on a stored row says who stored the answer: `inbox` (the move to `answered`: widget, hub, CLI `answer`), `agent` (a move from `open` or `drafted` to `acknowledged`), or `automatic_heuristic` (the decision Stop hook read an unambiguous answer from the chat, `decisions.chatAnswers record` in `tools agents hooks`; `recordChatAnswers`). The store sets it; no `question_update` can claim `automatic_heuristic`. ## Set up on a fresh clone diff --git a/src/question/commands/inbox.ts b/src/question/commands/inbox.ts index 2d078dee93..50606ccf8b 100644 --- a/src/question/commands/inbox.ts +++ b/src/question/commands/inbox.ts @@ -14,7 +14,7 @@ import { type InboxAnswerResult, } from "../lib/inbox/answer"; import { dismissDecision, draftDecision, sendDrafts } from "../lib/inbox/drafts"; -import { loadInbox, loadSessionDecisions, waitingBlock } from "../lib/inbox/load"; +import { loadInbox, loadSessionDecisions, loadSessionInbox, waitingBlock } from "../lib/inbox/load"; import type { AskDeps } from "../lib/pending/ask"; import { attachImageFiles } from "../lib/pending/form"; import type { AskAnswer } from "../lib/pending/types"; @@ -158,8 +158,17 @@ export function registerInboxCommand(program: Command, deps: InboxCommandDeps = .option("--hours <n>", "Sessions active in the last N hours", "72") .option("--session <id>", "Every decision of this one session, in every state: { sessionId, decisions }") .option("--json", "Print { sessions, scanned, elapsedMs }") - .action(async (options: { hours?: string; session?: string; json?: boolean }) => { + .option( + "--all-items", + "With --session --json: also its todos, its newest Q→A entries and its pending forms: { sessionId, decisions, todos, entries, forms }" + ) + .action(async (options: { hours?: string; session?: string; json?: boolean; allItems?: boolean }) => { if (options.session) { + if (options.json && options.allItems) { + out.result(await loadSessionInbox(options.session)); + return; + } + const decisions = await loadSessionDecisions(options.session); if (options.json) { @@ -324,8 +333,13 @@ export function registerInboxCommand(program: Command, deps: InboxCommandDeps = .option("--resume-target <label>", "The resume dialog reopened the session here: wait for its pane, then type") .option("--wait-live <seconds>", "Wait up to this long for the session's pane to be live before typing") .option("--dry-run", "Print the message and the route; change nothing") + .option( + "--in-session", + "The session sends it itself: record the answers as sent through its prompt, type nothing, print the message to submit" + ) .action(async (_flags: unknown, command: Command) => { - const flags: AnswerFlags & { resumeTarget?: string; waitLive?: string } = command.optsWithGlobals(); + const flags: AnswerFlags & { resumeTarget?: string; waitLive?: string; inSession?: boolean } = + command.optsWithGlobals(); try { if (!flags.session) { @@ -343,6 +357,7 @@ export function registerInboxCommand(program: Command, deps: InboxCommandDeps = ? { waitLiveMs: Math.round(waitSeconds * 1000) } : {}), dryRun: flags.dryRun === true, + ...(flags.inSession ? { inSession: true } : {}), }, storeDeps() ) diff --git a/src/question/lib/decisions/chat-answers.ts b/src/question/lib/decisions/chat-answers.ts new file mode 100644 index 0000000000..28448a4350 --- /dev/null +++ b/src/question/lib/decisions/chat-answers.ts @@ -0,0 +1,244 @@ +import type { NativeHistoryEntry } from "@genesiscz/utils/agent-sessions/types"; +import { isWrapperUserText } from "@genesiscz/utils/agent-sessions/user-text"; + +/** + * Answers to `❓ DECISION N` that the user typed in the chat instead of the hub. The decision Stop hook reads the + * session through its provider (`NativeSessionReader.readBackward`, back to the oldest open ask), and this finds, per + * open decision, what the user's messages AFTER that decision was asked say about it. Pure text: no transcript format. + * + * An answer is unambiguous only in a form nobody types by accident, at the start of a line or after a `,` or `;`: + * `D14 b)`, `14 b)`, `14 b`, `DECISION 14: b`, `D14 b - because…`, `D14: my own words`; or a bare `b)` opening a + * message right after a reply that asked exactly one decision. Two different answers, a letter the decision does not + * have, or `D14` named without an option is ambiguous: the hook names those for the agent to record or leave. + */ + +/** An open decision as the finder needs it. */ +export interface OpenDecisionRef { + number: number; + /** The option letters it offers, `a` to `c` for three options; none for a free-text decision. */ + letters: readonly string[]; + /** + * When it was asked (its post, or its latest revision): only user entries at or after it count. Entries without + * a time (Grok's chat history) count from the first assistant entry that shows `❓ DECISION N` instead. + */ + askedAt?: string; +} + +export interface Evidence { + text: string; + /** Where it is in the read; a backward read counts back from the end of the file (-1 is the last line). */ + line: number; + timestamp?: string; +} + +export interface ChatAnswer { + number: number; + /** The letters picked, in order (`"b"`, `"ac"`); absent for an answer in own words only. */ + option?: string; + /** The user's words after the letters, or the whole own-words answer. */ + answer: string; + evidence: Evidence; +} + +export interface AmbiguousAnswer { + number: number; + reason: "several answers" | "unknown option" | "mentioned without an option"; + evidence: Evidence; +} + +export interface ChatAnswers { + unambiguous: ChatAnswer[]; + ambiguous: AmbiguousAnswer[]; +} + +interface Found { + option?: string; + answer: string; + invalid: boolean; +} + +/** The text of a `❓ DECISION N` ask, as the reply format and the harvest write it. */ +const ASK = /❓\s*\**\s*DECISION\s+(\d+)\b/g; +/** A compaction or continuation summary is the harness's text about the past, not the user answering now. */ +const SUMMARY = /^This session is being continued from a previous conversation/; +/** An attached image's own entry (`[Image: source: /var/…/clipboard-….png]`): an attachment, not the user's words. */ +const IMAGE_ONLY = /^\[Image(?: #\d+)?: source: [^\]\n]*\]$/; +/** Where a second answer starts on the same line: `D14 b, D15 a` or `14 b); 15 a)`. */ +const NEXT_ANSWER = /\s*[,;]\s*(?=(?:D|DECISION\s*)?#?\d+\s*[:.)\-–—]?\s*\(?[a-z]{1,4}\b)/i; +const LEAD = /^\s*[-–—:,]\s*/; + +function asksIn(text: string): number[] { + return [...text.matchAll(ASK)].map((match) => Number(match[1])); +} + +function found(letters: string, rest: string, allowed: readonly string[]): Found { + const option = [...new Set(letters.toLowerCase())].join(""); + + return { + option, + answer: rest.replace(LEAD, "").trim(), + invalid: [...option].some((letter) => !allowed.includes(letter)), + }; +} + +/** What one segment of a user line says about decision `n` (digits only, so it goes into the patterns as it is). */ +function answerIn(segment: string, n: number, allowed: readonly string[]): Found | undefined { + const head = `^\\s*(?:[-*]\\s+)?`; + // `D14 b)`, `14 b)`, `DECISION 14 - (b)`, `D14 ac)`: the letters close with `)`. + const closed = new RegExp(`${head}(?:D|DECISION\\s*)?#?${n}\\s*[:.)\\-–—]?\\s*\\(?([a-z]{1,4})\\)(.*)$`, "i").exec( + segment + ); + + if (closed) { + return found(closed[1] ?? "", closed[2] ?? "", allowed); + } + + // `D14 b`, `DECISION 14: b - because`: a single letter after the D prefix, not the start of a word. + const prefixed = new RegExp(`${head}(?:D|DECISION\\s*)#?${n}\\s*[:.\\-–—]?\\s*([a-z])(?![a-z])(.*)$`, "i").exec( + segment + ); + + if (prefixed) { + return found(prefixed[1] ?? "", prefixed[2] ?? "", allowed); + } + + // `14 b` alone on its line or segment. + const bare = new RegExp(`${head}#?${n}\\s*[:.\\-–—]?\\s*([a-z]{1,4})\\s*$`, "i").exec(segment); + + if (bare) { + return found(bare[1] ?? "", "", allowed); + } + + // `D14: my own words`. + const own = new RegExp(`${head}D(?:ECISION)?\\s*#?${n}\\s*:\\s*(.+)$`, "i").exec(segment); + + return own?.[1] ? { answer: own[1].trim(), invalid: false } : undefined; +} + +function mentions(text: string, n: number): boolean { + return new RegExp(`\\bD(?:ECISION)?\\s*#?${n}\\b`, "i").test(text); +} + +/** The user's own words: harness wrappers and summaries are not the user answering. */ +export function userText(entry: NativeHistoryEntry): string | undefined { + if (entry.role !== "user") { + return undefined; + } + + const text = entry.text.trim(); + + if (!text || isWrapperUserText(text) || SUMMARY.test(text) || IMAGE_ONLY.test(text)) { + return undefined; + } + + return text; +} + +function time(value: string | undefined): number | undefined { + const parsed = value ? Date.parse(value) : Number.NaN; + + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** Whether `entry` comes after decision `n` was asked: by time when both have one, else after its first ask. */ +function afterAsk(entry: NativeHistoryEntry, index: number, askedAt: number | undefined, firstAsk: number): boolean { + const at = time(entry.timestamp); + + if (askedAt !== undefined && at !== undefined) { + return at >= askedAt; + } + + return firstAsk >= 0 && index > firstAsk; +} + +/** Per open decision, what the user's messages after its ask say about it. `entries` are in conversation order. */ +export function chatAnswers(entries: readonly NativeHistoryEntry[], open: readonly OpenDecisionRef[]): ChatAnswers { + const unambiguous: ChatAnswer[] = []; + const ambiguous: AmbiguousAnswer[] = []; + + for (const decision of open) { + const askedAt = time(decision.askedAt); + const firstAsk = entries.findIndex( + (entry) => entry.role === "assistant" && asksIn(entry.text).includes(decision.number) + ); + const hits: { found: Found; evidence: Evidence }[] = []; + let mentioned: Evidence | undefined; + // The decisions the newest assistant entry that asked any asked: a bare `b)` answers one asked alone. + let lastAsked: number[] = []; + + for (const [index, entry] of entries.entries()) { + if (entry.role === "assistant") { + const asked = asksIn(entry.text); + lastAsked = asked.length > 0 ? asked : lastAsked; + continue; + } + + const text = userText(entry); + + if (!text || !afterAsk(entry, index, askedAt, firstAsk)) { + continue; + } + + const evidence = (line: string): Evidence => ({ + text: line.trim(), + line: entry.line, + ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), + }); + const lines = text.split("\n"); + let answered = false; + + for (const line of lines) { + for (const segment of line.split(NEXT_ANSWER)) { + const hit = answerIn(segment, decision.number, decision.letters); + + if (hit) { + hits.push({ found: hit, evidence: evidence(segment) }); + answered = true; + } + } + } + + const first = lines.find((line) => line.trim() !== "") ?? ""; + const bare = /^\s*\(?([a-z]{1,4})\)(.*)$/i.exec(first); + const alone = lastAsked.length === 1 && lastAsked[0] === decision.number; + + if (!answered && bare && alone && !/DECISION/.test(text)) { + hits.push({ found: found(bare[1] ?? "", bare[2] ?? "", decision.letters), evidence: evidence(first) }); + answered = true; + } + + if (!answered && mentions(text, decision.number)) { + mentioned = evidence(lines.find((line) => mentions(line, decision.number)) ?? first); + } + } + + const last = hits.at(-1); + + if (!last) { + if (mentioned) { + ambiguous.push({ number: decision.number, reason: "mentioned without an option", evidence: mentioned }); + } + + continue; + } + + if (hits.some((hit) => hit.found.invalid)) { + ambiguous.push({ number: decision.number, reason: "unknown option", evidence: last.evidence }); + continue; + } + + if (new Set(hits.map((hit) => hit.found.option ?? `words:${hit.found.answer}`)).size > 1) { + ambiguous.push({ number: decision.number, reason: "several answers", evidence: last.evidence }); + continue; + } + + unambiguous.push({ + number: decision.number, + ...(last.found.option ? { option: last.found.option } : {}), + answer: last.found.answer, + evidence: last.evidence, + }); + } + + return { unambiguous, ambiguous }; +} diff --git a/src/question/lib/decisions/deliver.ts b/src/question/lib/decisions/deliver.ts index 0a7fde8e49..809e7c3ad9 100644 --- a/src/question/lib/decisions/deliver.ts +++ b/src/question/lib/decisions/deliver.ts @@ -43,7 +43,8 @@ const { log } = logger.scoped("question-deliver"); * How the answers reach the agent: typed into its cmux pane, steered into its Codex worker, sent * as the first prompt of a resumed session, or left for its next prompt. */ -export type DeliveryChannel = "cmux" | "codex" | "claude-peer" | "resume" | "queued"; +/** `prompt`: the session sent the answers itself, through its own prompt (the mods Decisions sidebar). */ +export type DeliveryChannel = "cmux" | "codex" | "claude-peer" | "resume" | "queued" | "prompt"; export interface DeliveryResult { channel: DeliveryChannel; @@ -76,6 +77,33 @@ function liveClaudePeers(session: string, sourceHome?: string): ClaudeLiveSessio }); } +/** A receiver this much more recent than every other copy of the session is the copy in use. */ +export const ACTIVE_RECEIVER_LEAD_MS = 10 * 60_000; + +/** + * One Claude session resumed in two terminals has two live receivers. The one whose registry entry moved last, by + * at least `ACTIVE_RECEIVER_LEAD_MS`, is the copy the user works in (the other sat idle for hours on 2026-10-24, and + * every send was queued). Closer than that, nothing tells them apart: the reason names both processes. + */ +export function pickClaudePeer( + peers: readonly ClaudeLiveSession[] +): { peer: ClaudeLiveSession; others: number } | { reason: string } { + const ranked = [...peers].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + const [first, second] = ranked; + if (!first) { + return { reason: "No native Claude receiver claims this session." }; + } + + if (!second || (first.updatedAt ?? 0) - (second.updatedAt ?? 0) >= ACTIVE_RECEIVER_LEAD_MS) { + return { peer: first, others: ranked.length - 1 }; + } + + const pids = ranked.map((peer) => `pid ${peer.pid}`).join(", "); + return { + reason: `${ranked.length} Claude terminals run this session (${pids}), active within 10 minutes of each other; none was picked. Close the extra one, then send again.`, + }; +} + /** Where a send would go, resolved before anything is typed. */ export type DeliveryTarget = | { @@ -311,15 +339,21 @@ export async function resolveDeliveryTarget( ? [] : liveClaudePeers(session, sourceHome); const matching = peers.filter((peer) => peer.sessionId === session); - if (matching.length > 1) { - return { kind: "none", reason: "Several native Claude receivers claim this session; none was picked." }; - } - const peer = matching[0]; - if (peer) { + if (matching.length > 0) { + const picked = pickClaudePeer(matching); + if ("reason" in picked) { + return { kind: "none", reason: picked.reason }; + } + + const { peer, others } = picked; if (sourceHome && workerSourceHome(dirname(dirname(peer.file))) !== workerSourceHome(sourceHome)) { return { kind: "none", reason: "The native Claude receiver belongs to another source home." }; } - return { kind: "claude-peer", label: "original Claude session", peer }; + const label = + others > 0 + ? `the Claude terminal active last (pid ${peer.pid}; ${others} older copy idle)` + : "original Claude session"; + return { kind: "claude-peer", label, peer }; } } @@ -457,11 +491,12 @@ export async function deliverToSession( const current = deps.claudePeers ? deps.claudePeers(sourceHome).filter((peer) => peer.sessionId === session) : liveClaudePeers(session, sourceHome); + const again = pickClaudePeer(current); if ( - current.length !== 1 || - current[0].pid !== target.peer.pid || - current[0].socketPath !== target.peer.socketPath || - current[0].file !== target.peer.file + "reason" in again || + again.peer.pid !== target.peer.pid || + again.peer.socketPath !== target.peer.socketPath || + again.peer.file !== target.peer.file ) { return undelivered("The native Claude receiver changed before delivery; no message was written."); } diff --git a/src/question/lib/decisions/read.ts b/src/question/lib/decisions/read.ts index fa05d1eba0..c39c721f11 100644 --- a/src/question/lib/decisions/read.ts +++ b/src/question/lib/decisions/read.ts @@ -199,11 +199,16 @@ export function decisionsMarkdown(rows: DecisionRecord[]): string { } /** Throws before calling `send` when nothing is answered and unsent. */ +/** The rows a send delivers: answered, with an answer, and not reserved by a queue. */ +export function dueDecisions(rows: DecisionRecord[]): DecisionRecord[] { + return rows.filter((row) => row.state === "answered" && !row.delivery?.queueId && hasAnswer(row)); +} + export function deliverDecisions( rows: DecisionRecord[], send: (text: string) => void ): { text: string; numbers: number[] } { - const due = rows.filter((row) => row.state === "answered" && !row.delivery?.queueId && hasAnswer(row)); + const due = dueDecisions(rows); if (due.length === 0) { throw new Error("nothing to send"); @@ -234,7 +239,7 @@ export function decisionLine(row: DecisionRecord): string { return `DECISION ${row.number}: ${answer}`.trimEnd(); } -function decisionLines(rows: DecisionRecord[]): string { +export function decisionLines(rows: DecisionRecord[]): string { return rows.map(decisionLine).join("\n"); } @@ -302,7 +307,8 @@ export interface StopHookConfig { blocksUsed?: number; } -const DECISION_MARKER = /❓\s*\**\s*DECISION\s+(\d+)/g; +/** `❓ DECISION 21`, `❓ **DECISION 21**`, and the linked form a later mention uses, `❓ [DECISION 21](…)`. */ +const DECISION_MARKER = /❓\s*\**\s*\[?\s*\**\s*DECISION\s+(\d+)/g; /** The `❓ DECISION N` numbers a reply mentions, in order, each once. */ export function mentionedDecisions(reply: string): number[] { @@ -336,6 +342,156 @@ export function stopHookVerdict( return { action: "block", reason, missing }; } +/** + * What makes a question a decision for the user: one of these whole words in any case, or ❓. + * "decided" and "chose" are left out on purpose, because they report a choice already made. + */ +const DECISION_CUE = /\b(?:decisions?|decide|choose)\b|❓/i; +/** A sentence that ends in "?": the mark before a space, a closing bracket, quote or emphasis, or the line end. */ +const QUESTION_END = /\?(?=[\s)\]"'*_]|$)/; +const FENCE = /^\s*(`{3,}|~{3,})/; +const QUOTED_LINE = /^\s*>/; +const INLINE_CODE = /`[^`\n]*`/g; +/** Text in quotation marks: a dialog's own question or a tab name the reply quotes, not the reply asking. */ +const QUOTED_TEXT = /"[^"\n]*"|“[^”\n]*”/g; +const QUESTION_LINE_MAX = 200; + +/** A decision named any way a reply names one: `DECISION 21`, `D21`, or a link to `…/decision/21`. */ +const DECISION_REF = /\bDECISION\s*#?(\d+)\b|\bD(\d+)\b|\/decision\/(\d+)\b/g; +/** How many prose lines above a question still belong to it (a `❓ DECISION N — title` line, then the question). */ +const QUESTION_LEAD_LINES = 3; + +/** The reply's own prose, line by line: fenced code blocks, `>` quoted lines and inline code are left out. */ +function proseLines(reply: string): string[] { + const lines: string[] = []; + let fence: string | null = null; + + for (const line of reply.split("\n")) { + const marker = FENCE.exec(line)?.[1]; + + if (fence) { + if (marker && marker[0] === fence[0] && marker.length >= fence.length) { + fence = null; + } + + continue; + } + + if (marker) { + fence = marker; + continue; + } + + if (!QUOTED_LINE.test(line)) { + lines.push(line.replace(INLINE_CODE, "")); + } + } + + return lines; +} + +/** + * The line of a reply that asks the user to decide, or null. The prose (see `proseLines`) must hold a decision cue + * (`DECISION_CUE`) and a sentence that ends in "?", both outside quotation marks: a reply that quotes a dialog's + * "Discard 0:21 of recording?" and names the "Decisions" tab asks nothing (2026-10-23). The line returned is the first + * question that carries the cue, else the first question. + */ +export function decisionQuestion(reply: string): string | null { + const found = questionLine(reply); + + if (!found) { + return null; + } + + const trimmed = found.lines[found.index]?.trim() ?? ""; + + return trimmed.length > QUESTION_LINE_MAX ? `${trimmed.slice(0, QUESTION_LINE_MAX - 1)}…` : trimmed; +} + +/** + * The decision numbers the reply's question names (`decisionQuestion`): on its line, or on the lines just above it in + * the same paragraph. The question nudge checks them against the decision log, so a question about a decision posted + * in an earlier turn is known as logged. + */ +export function decisionQuestionRefs(reply: string): number[] { + const found = questionLine(reply); + + if (!found) { + return []; + } + + const near: string[] = []; + + for (let index = found.index; index >= 0 && index >= found.index - QUESTION_LEAD_LINES; index--) { + const line = found.lines[index] ?? ""; + + if (index < found.index && !line.trim()) { + break; + } + + near.push(line); + } + + const numbers = [...near.join("\n").matchAll(DECISION_REF)].map((match) => + Number(match[1] ?? match[2] ?? match[3]) + ); + + return [...new Set(numbers)]; +} + +/** The prose lines of a reply and the index of the line that asks the user to decide, or null. */ +function questionLine(reply: string): { lines: string[]; index: number } | null { + const lines = proseLines(reply); + const own = (line: string) => line.replace(QUOTED_TEXT, ""); + + if (!lines.some((line) => DECISION_CUE.test(own(line)))) { + return null; + } + + const asks = (line: string) => QUESTION_END.test(own(line)); + const cued = lines.findIndex((line) => asks(line) && DECISION_CUE.test(own(line))); + const index = cued >= 0 ? cued : lines.findIndex(asks); + + return index >= 0 ? { lines, index } : null; +} + +export interface QuestionNudgeConfig { + questionNudge: "off" | "warn" | "block"; + maxBlocksPerSession: number; + blocksUsed?: number; +} + +/** + * The question nudge for a reply whose `question` asks the user to decide (`decisionQuestion`). `loggedThisTurn`: + * the session posted a decision after the turn's prompt, or the question names a decision already in the session's + * log, from any turn (`decisionQuestionRefs`). Past `maxBlocksPerSession` blocks it only warns. + */ +export function questionNudgeVerdict( + config: QuestionNudgeConfig, + question: string, + loggedThisTurn: boolean +): { action: "off" | "warn" | "block" | "allow"; reason?: string } { + if (config.questionNudge === "off") { + return { action: "off" }; + } + + if (loggedThisTurn) { + return { action: "allow" }; + } + + const reason = + `Your reply asks the user to decide ("${question}"), but this session logged no decision ` + + "this turn, so it does not reach the user's widget inbox. If it is a decision for the user, post it with " + + 'question_post as an item of type "decision" (pass `number` when your reply numbers it) and keep working. ' + + "If it is not a decision for the user, say so in one line and stop."; + + if (config.questionNudge === "warn" || (config.blocksUsed ?? 0) >= config.maxBlocksPerSession) { + return { action: "warn", reason }; + } + + return { action: "block", reason }; +} + const OPTION_LINE = /^\s*(?:[-*]\s*)?\**([a-z])\)\**\s*(.+)$/; const MARKER_LINE = /❓\s*\**\s*DECISION\s+(\d+)\**\s*(?:[—–:-]+\s*(.*))?$/; /** The reply text kept as one decision's context; the whole reply stays readable in the transcript. */ diff --git a/src/question/lib/decisions/schema.ts b/src/question/lib/decisions/schema.ts index 9bc348499a..c37df7eea9 100644 --- a/src/question/lib/decisions/schema.ts +++ b/src/question/lib/decisions/schema.ts @@ -19,8 +19,11 @@ export const DECISION_STATES = [ "superseded", ] as const; -/** How an answer reached the row: the user in the widget, hub or CLI, or an agent copying a chat answer. */ -export const ANSWERED_VIA = ["inbox", "agent"] as const; +/** + * How an answer reached the row: the user in the widget, hub or CLI, an agent copying a chat answer, or the decision + * Stop hook reading an unambiguous chat answer from the transcript (`recordChatAnswers`). + */ +export const ANSWERED_VIA = ["inbox", "agent", "automatic_heuristic"] as const; /** * The rule every agent-facing text repeats (server instructions, question_post and question_update descriptions): @@ -172,8 +175,13 @@ export type PostDecisionsInput = z.infer<typeof postDecisionsInputSchema>; export type DecisionPatch = z.infer<typeof decisionPatchSchema>; export type DecisionUpdate = z.infer<typeof decisionUpdateInputSchema>; -/** Parses or throws one readable error that names every bad field. */ -export function parseDecisionInput<T>(schema: z.ZodType<T>, value: unknown, label: string): T { +/** + * Parses or throws one readable error that names every bad field. + * + * The type parameter is the schema itself, and the result its `z.output`. Inferring `T` from a `z.ZodType<T>` + * parameter instead makes the checker walk the whole schema type against ZodType at every call. + */ +export function parseDecisionInput<S extends z.ZodType>(schema: S, value: unknown, label: string): z.output<S> { const result = schema.safeParse(value); if (!result.success) { diff --git a/src/question/lib/decisions/send.ts b/src/question/lib/decisions/send.ts index b47e4a39bc..15dae0ec15 100644 --- a/src/question/lib/decisions/send.ts +++ b/src/question/lib/decisions/send.ts @@ -44,6 +44,7 @@ export async function sendAnsweredDecisions({ deliveryKey, ids, dryRun = false, + inSession = false, files = decisionFiles(), deps = {}, }: { @@ -53,6 +54,11 @@ export async function sendAnsweredDecisions({ deliveryKey?: string; ids?: readonly string[]; dryRun?: boolean; + /** + * The session delivers the message itself, through its own prompt (the mods Decisions sidebar): nothing is + * typed, the answers are recorded as sent through `prompt`, and the caller submits the returned `text`. + */ + inSession?: boolean; files?: { file: string; events: string }; deps?: DeliverDeps; }): Promise<SendResult> { @@ -87,6 +93,12 @@ export async function sendAnsweredDecisions({ emit: async (text, numbers) => { delivery.text = text; delivery.numbers = numbers; + + if (inSession) { + delivery.route = { channel: "prompt", delivered: true, target: "the session's own prompt" }; + return; + } + const route = await deliverToSession( { session, provider: provider ?? undefined, sourceHome, text, deliveryKey }, deps diff --git a/src/question/lib/decisions/store.test.ts b/src/question/lib/decisions/store.test.ts index bc1cc580d4..08aeb21168 100644 --- a/src/question/lib/decisions/store.test.ts +++ b/src/question/lib/decisions/store.test.ts @@ -13,22 +13,26 @@ import { } from "@app/hub/lib/widget/tasks"; import { runAsCaller } from "@genesiscz/utils/agent/runtime"; import { listSessionMessages } from "@genesiscz/utils/agent-sessions/message-queue"; +import type { NativeHistoryEntry } from "@genesiscz/utils/agent-sessions/types"; import { withTimeout } from "@genesiscz/utils/async"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { withFileLock } from "@genesiscz/utils/storage/file-lock"; import { answerGitWithoutSpawning } from "@genesiscz/utils/test/no-git"; import { z } from "zod"; +import { chatAnswers } from "./chat-answers"; import { DeliveryUnknownError, deliverToSession, resolveDeliveryTarget } from "./deliver"; import { livePaneTargets, noPaneTargets } from "./deliver.fixtures"; import { decisionPayload, validateQuestionItems } from "./items"; import { decisionLine, + decisionQuestion, decisionsMarkdown, deliverDecisions, listSessions, mentionedDecisions, parseDecisionBlocks, + questionNudgeVerdict, sendSessionDecisions, sessionAnswers, staleCrossings, @@ -552,6 +556,55 @@ describe("decision store", () => { "block" ); }); + + test("a decision question needs a cue and a question in the reply's own prose", () => { + expect(decisionQuestion("Done. One decision is yours.\nShould I keep the cache?")).toBe( + "Should I keep the cache?" + ); + expect(decisionQuestion("Intro?\n❓ Which store do you want: **sqlite?**")).toBe( + "❓ Which store do you want: **sqlite?**" + ); + expect(decisionQuestion("Choose one, see https://x.example/?a=1 for details.")).toBeNull(); + expect(decisionQuestion("Tests pass. Should I push?")).toBeNull(); + expect(decisionQuestion("I decided on sqlite. Should I push?")).toBeNull(); + expect(decisionQuestion("Done.\n```\nconst decision = ask('choose?');\n```")).toBeNull(); + expect(decisionQuestion("Done. The `decision` field stays.\nShould I push?")).toBeNull(); + expect(decisionQuestion("Martin wrote:\n> which decision do you want?\nDone.")).toBeNull(); + expect( + decisionQuestion( + '- Pause worked, then it asked "Discard 0:21 of recording?". Discard closed it.\nThis happened on the Decisions tab.' + ) + ).toBeNull(); + expect(decisionQuestion('❓ DECISION 5: Should I rename "foo" to "bar"?')).toBe( + '❓ DECISION 5: Should I rename "foo" to "bar"?' + ); + expect(decisionQuestion(`Decide: ${"x".repeat(300)}?`)).toHaveLength(200); + }); + + test("the question nudge is off, allows a turn that logged a decision, warns, or blocks", () => { + const question = "Should I keep the cache?"; + + expect(questionNudgeVerdict({ questionNudge: "off", maxBlocksPerSession: 2 }, question, false)).toEqual({ + action: "off", + }); + expect(questionNudgeVerdict({ questionNudge: "block", maxBlocksPerSession: 2 }, question, true)).toEqual({ + action: "allow", + }); + expect(questionNudgeVerdict({ questionNudge: "warn", maxBlocksPerSession: 2 }, question, false).action).toBe( + "warn" + ); + expect( + questionNudgeVerdict({ questionNudge: "block", maxBlocksPerSession: 2, blocksUsed: 2 }, question, false) + .action + ).toBe("warn"); + + const block = questionNudgeVerdict({ questionNudge: "block", maxBlocksPerSession: 2 }, question, false); + + expect(block.action).toBe("block"); + expect(block.reason).toContain('("Should I keep the cache?")'); + expect(block.reason).toContain('question_post as an item of type "decision"'); + expect(block.reason).toContain("say so in one line and stop"); + }); }); function scratch(): { file: string; events: string; dir: string } { @@ -1162,8 +1215,41 @@ describe("delivery routes", () => { await resolveDeliveryTarget(request, { nativeWorkers: () => [], claudePeers: () => [peer, peer] }) ).toMatchObject({ kind: "none", - reason: "Several native Claude receivers claim this session; none was picked.", + reason: expect.stringMatching(/^2 Claude terminals run this session \(pid 42, pid 42\).*none was picked/), + }); + }); + + test("a session resumed in two terminals goes to the one active last, unless both moved recently", async () => { + const base = { + sessionId: "original-session", + name: null, + cwd: null, + status: "idle", + kind: null, + }; + const stale = { ...base, pid: 41, socketPath: "/fixture/41.sock", file: "/fixture/home/sessions/41.json" }; + const active = { ...base, pid: 42, socketPath: "/fixture/42.sock", file: "/fixture/home/sessions/42.json" }; + const request = { session: "original-session", provider: "claude", sourceHome: "/fixture/home" }; + const now = 1_800_000_000_000; + const picked = await resolveDeliveryTarget(request, { + nativeWorkers: () => [], + claudePeers: () => [ + { ...stale, updatedAt: now - 17 * 3600_000 }, + { ...active, updatedAt: now - 2 * 60_000 }, + ], }); + expect(picked).toMatchObject({ kind: "claude-peer", peer: { pid: 42 } }); + expect(picked.kind === "claude-peer" ? picked.label : "").toContain("1 older copy idle"); + + expect( + await resolveDeliveryTarget(request, { + nativeWorkers: () => [], + claudePeers: () => [ + { ...stale, updatedAt: now - 3 * 60_000 }, + { ...active, updatedAt: now - 60_000 }, + ], + }) + ).toMatchObject({ kind: "none", reason: expect.stringContaining("pid 42, pid 41") }); }); test("automatic replies never type into a lone cwd or screen-text match", async () => { @@ -2321,3 +2407,93 @@ describe("Widget Tasks use the canonical TODO ledger", () => { ).rejects.toThrow("longer than the widget shows (a 180-character title)"); }); }); + +describe("chatAnswers", () => { + const at = (minute: number) => `2026-10-25T16:${String(minute).padStart(2, "0")}:00.000Z`; + const entry = (role: NativeHistoryEntry["role"], text: string, minute?: number): NativeHistoryEntry => ({ + line: -1, + role, + text, + paths: [], + commits: [], + ...(minute === undefined ? {} : { timestamp: at(minute) }), + }); + const two = ["a", "b"]; + + test("reads the forms people type, several on one line, and only after the ask", () => { + const found = chatAnswers( + [ + entry("user", "D14 a) before it was asked", 1), + entry("assistant", "❓ DECISION 14 …\n❓ DECISION 15 …\n❓ DECISION 16 …", 2), + entry("user", "D14 b - smaller, D15 a\n16 b", 3), + entry("assistant", "Recap: [❓ DECISION 14](link) is decided.", 4), + ], + [ + { number: 14, letters: two, askedAt: at(2) }, + { number: 15, letters: two, askedAt: at(2) }, + { number: 16, letters: two, askedAt: at(2) }, + ] + ); + + expect(found.ambiguous).toEqual([]); + expect(found.unambiguous.map((answer) => [answer.number, answer.option, answer.answer])).toEqual([ + [14, "b", "smaller"], + [15, "a", ""], + [16, "b", ""], + ]); + }); + + test("calls two answers, a letter past the options, or a bare mention ambiguous", () => { + const found = chatAnswers( + [ + entry("assistant", "❓ DECISION 1 ❓ DECISION 2 ❓ DECISION 3", 1), + entry("user", "D1 a)", 2), + entry("user", "actually D1 b) and\n2 z)\nD3 needs more thought", 3), + ], + [ + { number: 1, letters: two, askedAt: at(1) }, + { number: 2, letters: two, askedAt: at(1) }, + { number: 3, letters: two, askedAt: at(1) }, + ] + ); + + // `actually D1 b)` does not open its line, so D1 has one answer; `2 z)` is past b; D3 is named without one. + expect(found.unambiguous.map((answer) => [answer.number, answer.option])).toEqual([[1, "a"]]); + expect(found.ambiguous.map((item) => [item.number, item.reason])).toEqual([ + [2, "unknown option"], + [3, "mentioned without an option"], + ]); + }); + + test("takes a bare `b)` only after a reply that asked one decision, and skips harness text", () => { + const one = chatAnswers( + [ + entry("assistant", "❓ DECISION 7 — Keep?\n- a) yes\n- b) no", 1), + entry("user", "<system-reminder>D7 a)</system-reminder>", 2), + entry("user", "b) no, drop it", 3), + ], + [{ number: 7, letters: two, askedAt: at(1) }] + ); + const several = chatAnswers( + [entry("assistant", "❓ DECISION 7 ❓ DECISION 8", 1), entry("user", "b)", 2)], + [{ number: 7, letters: two, askedAt: at(1) }] + ); + + expect(one.unambiguous).toMatchObject([{ number: 7, option: "b", answer: "no, drop it" }]); + expect(several).toEqual({ unambiguous: [], ambiguous: [] }); + }); + + test("counts from the first ask when the entries carry no time (Grok), and takes own words", () => { + const found = chatAnswers( + [ + entry("user", "D4: before"), + entry("assistant", "❓ DECISION 4 — Name it"), + entry("user", "D4: call it Atlas"), + ], + [{ number: 4, letters: [], askedAt: at(1) }] + ); + + expect(found.unambiguous).toMatchObject([{ number: 4, answer: "call it Atlas" }]); + expect(found.unambiguous[0]?.option).toBeUndefined(); + }); +}); diff --git a/src/question/lib/decisions/store.ts b/src/question/lib/decisions/store.ts index 9047e666bf..b3240dd933 100644 --- a/src/question/lib/decisions/store.ts +++ b/src/question/lib/decisions/store.ts @@ -8,6 +8,7 @@ import { gatherHarnessPoster, } from "@genesiscz/utils/agent/runtime"; import { createTranscriptAnchor, type TranscriptAnchor } from "@genesiscz/utils/agent/source-anchor"; +import { formatLocalDateTimeStamp } from "@genesiscz/utils/date"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { readJsonlRows } from "@genesiscz/utils/jsonl"; @@ -870,6 +871,7 @@ function applyUpdates({ now, signal, beforeUpdate, + answeredVia, }: { file: string; events: string; @@ -877,6 +879,8 @@ function applyUpdates({ now: () => string; signal?: AbortSignal; beforeUpdate?: (row: DecisionRecord) => void; + /** Who recorded a chat answer, in place of `agent`; never reachable from `question_update`. */ + answeredVia?: AnsweredVia; }): Promise<DecisionRecord[]> { signal?.throwIfAborted(); return withDecisionsLock(file, () => { @@ -893,7 +897,13 @@ function applyUpdates({ } beforeUpdate?.(row); - byId.set(id, patched(row, patch, ts, byId)); + const next = patched(row, patch, ts, byId); + + if (answeredVia && next.answeredVia === "agent") { + next.answeredVia = answeredVia; + } + + byId.set(id, next); } signal?.throwIfAborted(); @@ -913,6 +923,81 @@ function applyUpdates({ }); } +/** An answer the decision Stop hook read from the chat (`chatAnswers`), to record without the agent. */ +export interface ChatAnswerRecord { + id: string; + option?: string; + /** The user's words beside the letters, or the whole answer when it names no letter. */ + answer: string; + /** The user's line the answer was read from; kept as a comment, so the hub shows where it came from. */ + evidence: { text: string; timestamp?: string }; +} + +class MovedOn extends Error {} + +/** + * Records answers the user typed in the chat, found by a heuristic (`answeredVia: "automatic_heuristic"`). Each + * decision moves from open or drafted to acknowledged, as an agent's own `question_update` of a chat answer does, so + * nothing is delivered again. One transition per answer: a decision that moved on meanwhile (answered in the hub, + * recorded by the agent) stays as it is and is not returned. + */ +export async function recordChatAnswers({ + file, + events, + answers, + now = () => new Date().toISOString(), +}: { + file: string; + events: string; + answers: ChatAnswerRecord[]; + now?: () => string; +}): Promise<DecisionRecord[]> { + const recorded: DecisionRecord[] = []; + + for (const item of answers) { + // The hub shows comments as written, so the time is local, never the transcript's UTC stamp. + const when = item.evidence.timestamp + ? ` (${formatLocalDateTimeStamp(item.evidence.timestamp, { seconds: false })})` + : ""; + + try { + const [row] = await applyUpdates({ + file, + events, + updates: [ + { + id: item.id, + state: "acknowledged", + ...(item.option ? { option: item.option } : {}), + ...(item.answer ? { answer: item.answer } : {}), + comment: `Read from the chat by the decision Stop hook: "${item.evidence.text}"${when}`, + }, + ], + now, + answeredVia: "automatic_heuristic", + beforeUpdate: (current) => { + if (!SUPERSEDABLE.has(current.state)) { + throw new MovedOn(`${current.id} is ${current.state}`); + } + }, + }); + + if (row) { + recorded.push(row); + } + } catch (error) { + if (error instanceof MovedOn) { + logger.debug({ id: item.id, reason: error.message }, "[decisions] chat answer not recorded: moved on"); + continue; + } + + logger.warn({ error, id: item.id }, "[decisions] chat answer not recorded"); + } + } + + return recorded; +} + export interface HarvestedDecision { number: number; prompt: string; diff --git a/src/question/lib/inbox/answer.ts b/src/question/lib/inbox/answer.ts index 46564e6b09..36abf4eff5 100644 --- a/src/question/lib/inbox/answer.ts +++ b/src/question/lib/inbox/answer.ts @@ -1,7 +1,7 @@ import { logger } from "@genesiscz/utils/logger"; import type { DeliverDeps, DeliveryResult } from "../decisions/deliver"; import { decisionLine } from "../decisions/read"; -import { sendAnsweredDecisions } from "../decisions/send"; +import { type SendResult, sendAnsweredDecisions } from "../decisions/send"; import { type DecisionRecord, type HarvestedDecision, @@ -213,10 +213,14 @@ export async function answerInboxDecisions( deps: deps.deliver ?? {}, }); log.info({ session: input.session, numbers: sent.numbers, channel: sent.channel }, "inbox answers sent"); - // `detail` is the one line the hub and /qa show: where it went, else why it did not. + return inboxAnswerResult(input.session, sent); +} + +/** A send as the inbox reports it; `detail` is the one line the hub and /qa show: where it went, else why it did not. */ +export function inboxAnswerResult(session: string, sent: SendResult): InboxAnswerResult { const detail = sent.target ?? sent.error; return { - session: input.session, + session, text: sent.text, channel: sent.channel ?? "queued", delivered: sent.delivered === true, diff --git a/src/question/lib/inbox/build.ts b/src/question/lib/inbox/build.ts index 50c6748b1b..f928ea172a 100644 --- a/src/question/lib/inbox/build.ts +++ b/src/question/lib/inbox/build.ts @@ -435,7 +435,8 @@ function merged(row: DecisionRecord, block: HarvestedDecision): InboxDecision { }; } -function fromForm(form: AskForm): InboxForm { +/** A pending ask form as an inbox item. */ +export function inboxForm(form: AskForm): InboxForm { return { kind: "form", id: form.id, @@ -560,7 +561,7 @@ export function buildInbox({ sessions, scans, rows, forms, now = Date.now() }: B const sessionId = form.sessionHint ?? null; const target = group(sessionId ?? `form:${form.projectPath}`, sessionId); - target.items.push(fromForm(form)); + target.items.push(inboxForm(form)); target.info.cwd ??= form.cwd || form.projectPath; target.info.project ??= form.projectPath.split("/").filter(Boolean).pop() ?? null; } diff --git a/src/question/lib/inbox/drafts.test.ts b/src/question/lib/inbox/drafts.test.ts index 96a3317244..3d4d12c56c 100644 --- a/src/question/lib/inbox/drafts.test.ts +++ b/src/question/lib/inbox/drafts.test.ts @@ -6,7 +6,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import { answerGitWithoutSpawning } from "@genesiscz/utils/test/no-git"; import { deliverDecisions, parseDecisionBlocks } from "../decisions/read"; import { type DecisionRecord, postDecisions, readDecisions } from "../decisions/store"; -import { dismissDecision, draftDecision, sendDrafts } from "./drafts"; +import { dismissDecision, draftDecision, sendDrafts, stagedSendText } from "./drafts"; // A decision post reads its checkout with eight `git` starts; see no-git.ts for the CI hang they caused. answerGitWithoutSpawning(); @@ -101,6 +101,45 @@ describe("dismissDecision", () => { }); describe("sendDrafts", () => { + test("in session: the drafts become sent through the prompt, nothing is typed, and the message comes back", async () => { + const { file, events } = scratch(); + await postDecisions(file, events, { + sessionId: "s", + decisions: [ + { prompt: "One?", options: ["a1", "b1"] }, + { prompt: "Two?", options: ["a2", "b2"] }, + ], + }); + const deps = { file, events, block: async () => null }; + await draftDecision({ session: "s", number: 1, option: "a" }, deps); + await draftDecision({ session: "s", number: 2, text: "my own words" }, deps); + // The mods Decisions sidebar shows this above its [send]: it must be what the send submits. + const preview = stagedSendText(readDecisions(file), "s"); + + const result = await sendDrafts( + { session: "s", provider: "claude", inSession: true }, + { + ...deps, + deliver: { + runTool: async () => { + throw new Error("an in-session send must type nothing"); + }, + }, + } + ); + + expect(result.promoted).toEqual([1, 2]); + expect(result.channel).toBe("prompt"); + expect(result.text).toBe("DECISION 1: a) a1\nDECISION 2: my own words"); + expect(preview).toBe(result.text); + expect(stagedSendText(readDecisions(file), "s")).toBeUndefined(); + const rows = readDecisions(file); + expect(rows.map((row) => [row.state, row.delivery?.route])).toEqual([ + ["sent", "prompt"], + ["sent", "prompt"], + ]); + }); + test("promotes every drafted answer and delivers them as one message", async () => { const { file, events } = scratch(); await postDecisions(file, events, { diff --git a/src/question/lib/inbox/drafts.ts b/src/question/lib/inbox/drafts.ts index 6902599f13..d82c186a81 100644 --- a/src/question/lib/inbox/drafts.ts +++ b/src/question/lib/inbox/drafts.ts @@ -1,5 +1,5 @@ import { type DeliverDeps, resolveDeliveryTarget } from "../decisions/deliver"; -import { optionLetters } from "../decisions/read"; +import { decisionLines, dueDecisions, optionLetters } from "../decisions/read"; import { type SendResult, sendAnsweredDecisions } from "../decisions/send"; import { type DecisionRecord, @@ -161,11 +161,36 @@ export interface SendDraftsInput { recordResumeTarget?: string; /** How long to wait for a live pane before typing (a just-resumed session boots for a few seconds). */ waitLiveMs?: number; + /** The session submits the message itself; see `sendAnsweredDecisions` `inSession`. */ + inSession?: boolean; } /** Polled once a second while waiting for a resumed session's pane; the deadline is a count, not a clock. */ const WAIT_STEP_MS = 1_000; +/** What a send turns a draft into: the pick becomes the option, the note the answer. */ +function promotedDraft(row: DecisionRecord): Pick<DecisionRecord, "option" | "answer"> { + return { + ...(row.draftOption ? { option: optionLetters(row.draftOption).join("") } : {}), + ...(row.draft?.trim() ? { answer: row.draft.trim() } : {}), + }; +} + +/** + * The message the session's next `sendDrafts` submits, from the rows as they stand: its drafts promoted as the send + * promotes them, plus answers already waiting to go out, in the send's own format (`deliverDecisions`). The mods + * Decisions sidebar shows it above its `[send]`, so what it shows is what goes out. `undefined` when nothing would. + */ +export function stagedSendText(rows: DecisionRecord[], session: string): string | undefined { + const due = dueDecisions( + rows + .filter((row) => row.sessionId === session && kindOf(row) === "decision") + .map((row) => (isDrafted(row) ? { ...row, state: "answered" as const, ...promotedDraft(row) } : row)) + ); + + return due.length > 0 ? decisionLines(due) : undefined; +} + /** * Sends every drafted answer of one session as ONE message. The drafts are promoted to `answered` * (the pick becomes the option, the note the answer) in one transition, then delivered through @@ -194,12 +219,7 @@ export async function sendDrafts( if (drafted.length > 0) { await updateDecisions(deps.file, deps.events, { - updates: drafted.map((row) => ({ - id: row.id, - state: "answered" as const, - ...(row.draftOption ? { option: optionLetters(row.draftOption).join("") } : {}), - ...(row.draft?.trim() ? { answer: row.draft.trim() } : {}), - })), + updates: drafted.map((row) => ({ id: row.id, state: "answered" as const, ...promotedDraft(row) })), }); } @@ -256,6 +276,7 @@ export async function sendDrafts( const result = await sendAnsweredDecisions({ session: input.session, ...(input.provider ? { provider: input.provider } : {}), + ...(input.inSession ? { inSession: true } : {}), files: deps, ...(deps.deliver ? { deps: deps.deliver } : {}), }); diff --git a/src/question/lib/inbox/inbox.test.ts b/src/question/lib/inbox/inbox.test.ts index 97195eed1f..428e46db8a 100644 --- a/src/question/lib/inbox/inbox.test.ts +++ b/src/question/lib/inbox/inbox.test.ts @@ -17,7 +17,15 @@ import type { AskForm } from "../pending/types"; import { answerInboxDecision, answerInboxDecisions, answerInboxForm } from "./answer"; import { buildInbox, type InboxSessionInfo, inboxDelivery, scanTurns, sessionDecisions } from "./build"; import { locateRef } from "./excerpts"; -import { type InboxDeps, loadInbox, loadSessionDecisions, lookupListing, lookupSessionCwd, waitingBlock } from "./load"; +import { + type InboxDeps, + loadInbox, + loadSessionDecisions, + loadSessionInbox, + lookupListing, + lookupSessionCwd, + waitingBlock, +} from "./load"; // A decision post reads its checkout with eight `git` starts; see no-git.ts for the CI hang they caused. answerGitWithoutSpawning(); @@ -410,6 +418,54 @@ describe("sessionDecisions", () => { }); }); +describe("loadSessionInbox", () => { + test("one session's decisions, todos by number, its entries and its pending forms, from one read of the log", async () => { + const row = (overrides: Partial<DecisionRecord>): DecisionRecord => ({ + id: "d_1_s-alpha", + sessionId: "s-alpha", + number: 1, + prompt: "Keep it?", + options: [], + state: "open", + updatedTs: "2026-03-01T08:00:00.000Z", + ...overrides, + }); + const rows = [ + row({}), + row({ id: "t_2_s-alpha", type: "todo", number: 2, prompt: "Write the README\nwith the new keys" }), + row({ id: "t_1_s-alpha", type: "todo", number: 1, title: "Rename the flag", state: "implemented" }), + row({ id: "t_1_s-beta", sessionId: "s-beta", type: "todo", number: 1, prompt: "Other session" }), + ]; + let reads = 0; + const seen: { session: string; stored: number }[] = []; + const entriesOf: string[] = []; + const inbox = await loadSessionInbox("s-alpha", { + rows: () => { + reads++; + return rows; + }, + forms: (session) => (session === "s-alpha" ? [form({ id: "ask_mine", sessionHint: "s-alpha" })] : []), + entries: (session) => { + entriesOf.push(session); + return []; + }, + decisions: async (session, stored) => { + seen.push({ session, stored: stored.length }); + return []; + }, + }); + + expect(reads).toBe(1); + expect(seen).toEqual([{ session: "s-alpha", stored: 4 }]); + expect(inbox.todos.map((todo) => [todo.number, todo.title, todo.state])).toEqual([ + [1, "Rename the flag", "implemented"], + [2, "Write the README", "open"], + ]); + expect(inbox.forms.map((item) => item.id)).toEqual(["ask_mine"]); + expect(entriesOf).toEqual(["s-alpha"]); + }); +}); + describe("loadInbox", () => { test("an unchanged transcript is read from the scan cache, a changed one is read again", async () => { let reads = 0; diff --git a/src/question/lib/inbox/load.ts b/src/question/lib/inbox/load.ts index 5cdb03ef18..5bb237d885 100644 --- a/src/question/lib/inbox/load.ts +++ b/src/question/lib/inbox/load.ts @@ -12,18 +12,30 @@ import type { TranscriptTurn } from "@genesiscz/utils/ai/transcripts/types"; import { concurrentMap } from "@genesiscz/utils/async"; import { logger } from "@genesiscz/utils/logger"; import { Storage } from "@genesiscz/utils/storage"; +import { toolDataDir } from "@genesiscz/utils/storage/root"; import { decisionFiles } from "../decisions/read"; -import { type DecisionRecord, type HarvestedDecision, readDecisions } from "../decisions/store"; +import { + type DecisionRecord, + type DecisionState, + type HarvestedDecision, + kindOf, + readDecisions, +} from "../decisions/store"; import { listPendingForms } from "../pending/ask"; +import { listFormsSnapshot } from "../pending/store"; import type { AskForm } from "../pending/types"; +import { type QaRow, queryEntriesSnapshot } from "../read-model"; import { buildInbox, type InboxDecision, + type InboxForm, type InboxSession, + inboxForm, scanTurns, sessionDecisions, type TranscriptScan, } from "./build"; +import { stagedSendText } from "./drafts"; import { fillExcerpts } from "./excerpts"; const { log } = logger.scoped("question-inbox"); @@ -193,6 +205,77 @@ export async function loadSessionDecisions( return withExcerpts(decisions, cwd); } +/** How many of a session's newest Q→A entries `loadSessionInbox` returns. */ +const SESSION_ENTRIES = 40; + +/** A todo of one session, as the mods Inbox sidebar lists it. */ +export interface SessionTodo { + id: string; + number: number; + title: string; + state: DecisionState; + at: string; +} + +/** Everything the answer log holds for one session: `inbox --session <id> --all-items`, the mods sidebars' read. */ +export interface SessionInbox { + sessionId: string; + decisions: InboxDecision[]; + /** The message `inbox send` would submit now (`stagedSendText`); absent when nothing is staged. */ + staged?: string; + todos: SessionTodo[]; + /** Its newest Q→A entries (messages and recorded answers), oldest first, with `readAt`. */ + entries: QaRow[]; + forms: InboxForm[]; +} + +/** + * One session's decisions in every state, its todos, its newest Q→A entries and its pending forms, from one read of + * the decision log. Reads only: the forms come from a read snapshot filtered in SQL, so no form is expired here. + */ +export async function loadSessionInbox( + session: string, + { + rows = realInboxDeps.rows, + forms = (id: string) => listFormsSnapshot({ opts: { sessionHint: id, status: "pending" } }), + entries = (id: string) => + queryEntriesSnapshot({ + dbPath: toolDataDir("question", "qa.db"), + opts: { sessionId: id, limit: SESSION_ENTRIES }, + }), + decisions = (id: string, stored: DecisionRecord[]) => loadSessionDecisions(id, { rows: () => stored }), + }: { + rows?: () => DecisionRecord[]; + forms?: (session: string) => AskForm[]; + entries?: (session: string) => QaRow[]; + decisions?: (session: string, stored: DecisionRecord[]) => Promise<InboxDecision[]>; + } = {} +): Promise<SessionInbox> { + const stored = rows(); + const todos = stored + .filter((row) => row.sessionId === session && kindOf(row) === "todo") + .map((row) => ({ + id: row.id, + number: row.number, + title: row.title ?? row.prompt.split("\n")[0] ?? "", + state: row.state, + at: row.updatedTs, + })) + .sort((a, b) => a.number - b.number); + const waiting = forms(session).map(inboxForm); + + const staged = stagedSendText(stored, session); + + return { + sessionId: session, + decisions: await decisions(session, stored), + ...(staged ? { staged } : {}), + todos, + entries: entries(session), + forms: waiting, + }; +} + /** Reads each decision's `file:line` references from disk (bounded), off the hub's hot path. */ function withExcerpts(decisions: InboxDecision[], cwd: string | undefined): InboxDecision[] { return decisions.map((item) => ({ ...item, refs: fillExcerpts(item.refs, cwd) })); diff --git a/src/question/lib/read-model.ts b/src/question/lib/read-model.ts index f23f649fa0..9774b42c1c 100644 --- a/src/question/lib/read-model.ts +++ b/src/question/lib/read-model.ts @@ -310,8 +310,41 @@ function rowToQaRow(r: Record<string, unknown>): QaRow { }; } +/** + * The newest `limit` entries of each session, from one read snapshot and one catch-up: the widget asks this for the + * sessions it keeps warm, where a snapshot per session cost about 5 ms each (16 sessions, 90 of 183 ms, 2026-10-24). + */ +export function queryEntriesOfSessionsSnapshot({ + dbPath, + sessionIds, + limit, +}: { + dbPath: string; + sessionIds: string[]; + limit: number; +}): QaRow[] { + if (sessionIds.length === 0) { + return []; + } + + return withDatabaseReadSnapshot({ + path: dbPath, + initialize: initializeReadModel, + read: (db) => { + catchUp(db); + return sessionIds.flatMap((sessionId) => + selectEntries(db, { sessionId, limit, includeNewestUnread: true }) + ); + }, + }); +} + export function queryEntries(db: Database, opts: QueryOpts = {}): QaRow[] { catchUp(db, opts.logBase); + return selectEntries(db, opts); +} + +function selectEntries(db: Database, opts: QueryOpts): QaRow[] { const where: string[] = ["superseded_by IS NULL"]; const params: (string | number)[] = []; if (opts.project) { diff --git a/src/ts/README.md b/src/ts/README.md index b3a47c60e2..534f07305e 100644 --- a/src/ts/README.md +++ b/src/ts/README.md @@ -1,10 +1,11 @@ # tools ts -> **What is in this TypeScript file, what is written twice, and where does the startup time of an entry point go?** +> **What is in this TypeScript file, what is written twice, where does the startup time of an entry point go, and what does type-checking cost?** -Five command groups. `skeleton` prints a file's API. `duplicates` finds code written more than +Six command groups. `skeleton` prints a file's API. `duplicates` finds code written more than once. `refactors` ranks what to change. `imports` answers the startup-cost question. `profile` answers where -the CPU time of a whole run goes. +the CPU time of a whole run goes. `program` answers what a TypeScript program costs to check, and which code makes it +cost that. `skeleton`, `duplicates` and `refactors` take `--format text|md|json|json-compact|toon`, with `--md`, `--json`, `--json-compact` and `--toon` as shorthands. Asking for two different formats is @@ -232,6 +233,68 @@ The `.cpuprofile` files stay in `~/.genesis-tools/ts/profiles/<time>/` and open --- +## `tools ts program cost|files|own|closure|sites|trace` + +> **Why does type-checking this project need gigabytes, and which code makes the checker work hardest?** + +Each command checks a tsconfig's program with the project's OWN `typescript` (the copy beside this tool when the +project has none) in a separate `node` process, because V8's heap is the one `tsc` and a reviewer's +`ts.createProgram` use. `--runtime bun` checks under Bun instead (JavaScriptCore numbers). `--heap <MiB>` limits that +process, so `cost --heap 3072` answers "does it fit a 3 GiB reviewer" by passing or failing as out of memory. The +tsconfig argument is a file or a folder holding `tsconfig.json`, default `./tsconfig.json`. Every command takes `--json`. + +- **`cost`:** the heap after a full collection at each phase (parse, bind, check), the time, the files and bytes + (declaration packages apart), the checker's types, instantiations and symbols, and the diagnostics. +- **`files`:** each file checked in program order with what its check created, ranked `--by instantiations` (or + `types`, `symbols`, `ms`). `--folders` sums per folder. The first file to touch a shared library type pays for it, + so read a ranking, not exact ownership. +- **`own <files...>`:** one file's own work, in a smaller program: the target files plus the tsconfig's own `.d.ts` + files, and what they import. Everything in it except the targets is checked first, then each target. This is the + before and after instrument for an edit: run it, change the file, run it again. Its scope is not the full program: + another root file's `declare global` or module augmentation is missing unless a target imports that file, so the + diagnostics and the numbers can differ from `files` (which checks the whole program). +- **`closure`:** what a tsconfig of only one folder would hold (files, bytes, the other folders and packages it pulls + in), the declaration packages by size with the project files that import them, and `--why <needle>`, the shortest + import chain from a project file to a matching file. It also lists packages whose declarations are in the program + from more than one installed copy (a nested `node_modules`), with the bytes deduplicating would save. No checking, + so it is quick. +- **`sites`:** finds the next costly code shape without reading code. During the first check of each file, every call, + `new`, JSX element and type reference is resolved on its own, and what it created is grouped by what it calls or + references (`name @ declaring file`). A high MEDIAN over many sites is a shape that costs at every use; one large + site with a low median is the first use of a library type. A call is measured after its callee and before its + arguments, so an argument's work counts for the call that receives it (measured the other way, an argument's + contextual type resolves the outer call and the argument takes the blame). `--by median --min-sites 3` finds + repeated shapes, `--project-only` keeps keys declared in the project, `--key <text>` lists the single sites. +- **`trace`:** runs `tsc --generateTrace` (or reads `--dir <folder>`) and lists the slowest files and the slowest single + check events with their source line. Nested events overlap: a statement's time includes its expressions. + +```bash +tools ts program cost # heap per phase of ./tsconfig.json +tools ts program cost --heap 3072 # passes or fails like a reviewer with a 3 GiB heap +tools ts program files --folders # which folders create the most work +tools ts program own src/artifact/runtime/kit/chartjs.tsx +tools ts program closure --why stripe # why a package is in the program +tools ts program sites --by median --min-sites 3 # shapes that cost at every use +tools ts program sites --key callLLMStructured # the single sites of one group +tools ts program trace --min-ms 100 +``` + +What it found in GenesisTools on 2026-10-25 (root program, TypeScript 6.0, 9,995 files, 4.4 GiB retained heap), +each fix type-only, measured with `own` before and after: + +- `new Chart(canvas, config)` inferred Chart's type parameters over a union of every chart type: 1,084 ms and 250k + symbols for one expression. `new Chart<ChartType>(…)`: 12 ms. +- Icon and link props typed `React.ElementType` (a union of every HTML tag) made each JSX use cost about 175k + instantiations, 220 to 250 ms per file. `React.ComponentType<…>`: 4 to 38 ms. +- TypeScript 6 adds no `@types` package to the global scope unless `types` names it (an import still loads its own + package's types), so Bun's globals reached the program only through one file that imports `bun`; a program of + fewer files reported "Cannot find name 'Bun'". +- A program of one folder needs 0.9 to 1.5 GB, against 4.1 GiB for the whole root. +- `sites` on the commit before those fixes ranked them without help: the `ElementType` icons at places 2 and 6 to 11 + (86k to 160k instantiations each), `new Chart` at 12 (1,260 ms), `generateText` at 15, `Parameters<typeof …>` at 34. + +--- + ## Commands | Command | Description | @@ -244,6 +307,7 @@ The `.cpuprofile` files stay in `~/.genesis-tools/ts/profiles/<time>/` and open | `imports barrels <entry>` | `import { a } from "<barrel>"` sites where the barrel re-exports far more than `a` needs, priced in real ms | | `imports cycles <entry>` | Import cycles on the startup path, with the edges that close them | | `profile <entry> [-- args...]` | CPU profile of a whole run: hot functions, repository functions, a repository call tree, optionally every thread | +| `program cost\|files\|own\|closure\|sites\|trace [tsconfig]` | Type-check cost: heap per phase, checker work per file and folder, one file's own cost, one folder's closure and duplicate packages, the costliest calls and types by what they call, trace hot spots | `<entry>` is a `.ts` file, a directory (its `index.ts`, or every top-level source file when there is none), or a `tsconfig.json` (its directory). Test files and `.d.ts` files never count as entries. @@ -343,7 +407,7 @@ src/ts/ ├── index.ts commander entry (`tools ts`) ├── commands/ │ ├── options.ts the --format and scan flags every subcommand shares -│ ├── imports.ts skeleton.ts duplicates.ts refactors.ts +│ ├── imports.ts skeleton.ts duplicates.ts refactors.ts profile.ts program.ts └── lib/ ├── collect.ts walk paths to source files, honouring --tests and --ignore ├── load.ts one read of every file, shared by skeleton/duplicates/refactors @@ -359,7 +423,10 @@ src/ts/ ├── analyze.ts graph + measure + totals + attribution → AnalysisResult ├── attribute.ts why a module is slow (native, await, side effects, barrel, subtree, cycle) ├── barrels.ts lazy.ts cycles.ts - └── render.ts terminal output (box tables from @genesiscz/utils/table) + ├── render.ts terminal output (box tables from @genesiscz/utils/table) + └── program/ `tools ts program`: check-worker.ts (node process, no repo imports), run.ts (spawns it), + closure.ts (folder closures, package sizes, duplicate copies, --why), sites.ts (site + groups), trace.ts (--generateTrace hot spots) ``` ## Limits diff --git a/src/ts/commands/cli.test.ts b/src/ts/commands/cli.test.ts index 9de560b48d..72386169d6 100644 --- a/src/ts/commands/cli.test.ts +++ b/src/ts/commands/cli.test.ts @@ -8,6 +8,7 @@ import { type CapturedOutput, captureOutput } from "@genesiscz/utils/test/captur import { Command } from "commander"; import type { MeasureOptions, MeasureResult, WorkerSample } from "../lib/measure"; import { registerImportsCommands } from "./imports"; +import { registerProgramCommands } from "./program"; import { registerSkeletonCommands } from "./skeleton"; const ENTRY = join(import.meta.dir, "..", "index.ts"); @@ -911,3 +912,14 @@ describe("tools ts imports, run in process", () => { } }); }); + +describe("tools ts program options", () => { + // A timer holds at most 2^31-1 ms: a larger --timeout used to reach boundedCommand and throw a bare Error. + test("refuses a --timeout past what a timer can hold before any check starts", async () => { + const { error } = await runInProcess({ + register: registerProgramCommands, + args: ["program", "cost", "--timeout", "2147484"], + }); + expect(String(error)).toContain("Must be at most 2147483."); + }); +}); diff --git a/src/ts/commands/program.ts b/src/ts/commands/program.ts new file mode 100644 index 0000000000..28d1d6eba8 --- /dev/null +++ b/src/ts/commands/program.ts @@ -0,0 +1,594 @@ +import { readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { suggestCommand, toolCommand } from "@genesiscz/utils/cli"; +import { withInterrupt } from "@genesiscz/utils/cli/interrupt"; +import { formatBytes } from "@genesiscz/utils/format"; +import { out } from "@genesiscz/utils/logger"; +import { createBoxTable, renderCliHeader, renderCliSection } from "@genesiscz/utils/table"; +import { type Command, InvalidArgumentError } from "commander"; +import { costByFolder, duplicatePackages, folderClosures, packageSizes, whyChain } from "../lib/program/closure"; +import { + DEFAULT_TIMEOUT_MS, + generateTrace, + type ProgramRuntime, + resolveTsconfig, + runProgramWorker, +} from "../lib/program/run"; +import { groupSites, SITE_SORT_KEYS, type SiteSortKey, sitesForKey, sortGroups } from "../lib/program/sites"; +import { analyzeTrace } from "../lib/program/trace"; +import type { FileCost } from "../lib/program/types"; +import { numberArg, runUsage } from "./options"; + +interface RuntimeFlags { + runtime: ProgramRuntime; + heap?: number; + timeout: number; + json?: boolean; +} + +const SORT_KEYS = ["instantiations", "types", "symbols", "ms"] as const; +type SortKey = (typeof SORT_KEYS)[number]; + +function runtimeOption(command: Command): Command { + return command + .option( + "--runtime <name>", + "node (V8, as tsc and reviewers run) or bun", + (value: string) => { + if (value !== "node" && value !== "bun") { + throw new InvalidArgumentError("Must be node or bun."); + } + + return value; + }, + "node" + ) + .option( + "--heap <MiB>", + "Heap limit of the checking process (node, default 16384); a larger program fails as out of memory", + numberArg({ min: 64, integer: true }) + ) + .option(...timeoutOption()) + .option("--json", "The result as JSON"); +} + +/** `--timeout <s>`: the check is stopped (its whole process group) after this long. */ +function timeoutOption(): [string, string, (raw: string) => number, number] { + const seconds = DEFAULT_TIMEOUT_MS / 1000; + return [ + "--timeout <s>", + `Stop the check after this many seconds (default ${seconds})`, + // A timer holds at most 2^31-1 ms; past it boundedCommand throws a bare Error instead of a usage message. + numberArg({ min: 1, max: Math.floor(2_147_483_647 / 1000), integer: true }), + seconds, + ]; +} + +/** A usage-error boundary whose check stops on Ctrl-C: the signal reaches the worker's process group. */ +function runCheck(command: Command, action: (signal: AbortSignal) => Promise<void>): Promise<void> { + return runUsage(command, () => withInterrupt(action)); +} + +function siteSortKey(value: string): SiteSortKey { + const key = SITE_SORT_KEYS.find((candidate) => candidate === value); + + if (!key) { + throw new InvalidArgumentError(`Must be one of ${SITE_SORT_KEYS.join(", ")}.`); + } + + return key; +} + +function sortKey(value: string): SortKey { + const key = SORT_KEYS.find((candidate) => candidate === value); + + if (!key) { + throw new InvalidArgumentError(`Must be one of ${SORT_KEYS.join(", ")}.`); + } + + return key; +} + +/** A path for a command typed in the current folder: relative inside it, absolute outside (rows are relative to the tsconfig's folder). */ +function fromCwd(path: string): string { + const rel = relative(process.cwd(), path); + return rel.startsWith("..") ? path : rel; +} + +/** Exact, with separators: a before and after comparison needs every digit. */ +const n = (value: number) => value.toLocaleString("en-US"); + +export function registerProgramCommands(parent: Command): void { + const program = parent + .command("program") + .description( + "What a TypeScript program costs to check, and why: heap per phase, the files and folders that create the most types, what one folder's program would hold, and trace hot spots" + ); + + runtimeOption( + program + .command("cost") + .description("Heap after parse, bind and check (after a full GC), time, files and the checker's counts") + .argument("[tsconfig]", "A tsconfig.json or the folder holding one (default ./tsconfig.json)") + ).action(async (tsconfig: string | undefined, options: RuntimeFlags, command: Command) => { + await runCheck(command, async (signal) => { + const result = await runProgramWorker("phases", { + tsconfig: resolveTsconfig(tsconfig), + runtime: options.runtime, + heapMiB: options.heap, + timeoutMs: options.timeout * 1000, + signal, + }); + + if (options.json) { + out.result(result); + return; + } + + renderCliHeader( + "TypeScript program cost", + `${result.config} · TypeScript ${result.typescript} · ${result.runtime}` + ); + const phases = createBoxTable(["PHASE", "HEAP MIB", "RSS MIB", "AT"]); + + for (const phase of result.phases) { + phases.push([phase.phase, n(phase.heapMiB), n(phase.rssMiB), `${(phase.ms / 1000).toFixed(1)} s`]); + } + + out.println(phases.toString()); + out.println( + `files ${n(result.size.files)} (node_modules ${n(result.size.nodeModulesFiles)}, ${formatBytes(result.size.nodeModulesBytes)}; all ${formatBytes(result.size.sourceBytes)}) · types ${n(result.types)} · instantiations ${n(result.instantiations)} · symbols ${n(result.symbols)} · diagnostics ${result.diagnostics}` + ); + + if (!result.gcForced) { + out.println("heap is the raw heap in use: this runtime offered no full collection"); + } + + for (const line of result.firstDiagnostics) { + out.println(line); + } + }); + }); + + runtimeOption( + program + .command("files") + .description("Each file's check, ranked by what it created (the first user of a library type pays for it)") + .argument("[tsconfig]", "A tsconfig.json or the folder holding one (default ./tsconfig.json)") + .option("--top <n>", "Rows", numberArg({ min: 1, integer: true }), 30) + .option("--by <key>", `Rank by ${SORT_KEYS.join(", ")} (default instantiations)`, sortKey, "instantiations") + .option("--folders", "Sum per folder instead of per file") + ).action( + async ( + tsconfig: string | undefined, + options: RuntimeFlags & { top: number; by: SortKey; folders?: boolean }, + command: Command + ) => { + await runCheck(command, async (signal) => { + const result = await runProgramWorker("files", { + tsconfig: resolveTsconfig(tsconfig), + runtime: options.runtime, + heapMiB: options.heap, + timeoutMs: options.timeout * 1000, + signal, + }); + const rows: Array<Omit<FileCost, "bytes"> & { name: string; count?: number }> = options.folders + ? costByFolder(result.rows).map((row) => ({ + ...row, + file: row.folder, + name: row.folder, + count: row.files, + })) + : result.rows.map((row) => ({ ...row, name: row.file })); + rows.sort((a, b) => b[options.by] - a[options.by]); + const top = rows.slice(0, options.top); + + if (options.json) { + out.result({ ...result, rows: top }); + return; + } + + renderCliHeader( + `Checker work per ${options.folders ? "folder" : "file"}`, + `${result.config} · ranked by ${options.by}` + ); + const table = createBoxTable([ + options.folders ? "FOLDER" : "FILE", + "INSTANTIATIONS", + "TYPES", + "SYMBOLS", + "MS", + ...(options.folders ? ["FILES"] : []), + ]); + + for (const row of top) { + table.push([ + row.name, + n(row.instantiations), + n(row.types), + n(row.symbols), + n(Math.round(row.ms)), + ...(options.folders ? [n(row.count ?? 0)] : []), + ]); + } + + out.println(table.toString()); + out.println( + `program: types ${n(result.types)} · instantiations ${n(result.instantiations)} · symbols ${n(result.symbols)} · ${n(result.rows.length)} files checked` + ); + const next = options.folders + ? ["program", "files", ...(tsconfig ? [tsconfig] : [])] + : [ + "program", + "own", + top[0] ? fromCwd(resolve(dirname(result.config), top[0].name)) : "<file>", + ...(tsconfig ? ["--tsconfig", tsconfig] : []), + ]; + out.println(`Next: ${suggestCommand("tools ts", { replaceCommand: next })}`); + }); + } + ); + + runtimeOption( + program + .command("own") + .description( + "One file's own check cost in a program of the targets and the tsconfig's .d.ts files: their imports are checked first, then each target. Compare a file before and after an edit" + ) + .argument("<files...>", "Files of the project") + .option("--tsconfig <path>", "The project's tsconfig (default ./tsconfig.json)") + ).action(async (files: string[], options: RuntimeFlags & { tsconfig?: string }, command: Command) => { + await runCheck(command, async (signal) => { + const result = await runProgramWorker("own", { + tsconfig: resolveTsconfig(options.tsconfig), + runtime: options.runtime, + heapMiB: options.heap, + timeoutMs: options.timeout * 1000, + signal, + files, + }); + + if (options.json) { + out.result(result); + return; + } + + const table = createBoxTable(["FILE", "MS", "TYPES", "INSTANTIATIONS", "SYMBOLS", "DIAGNOSTICS"]); + + for (const row of result.rows) { + table.push([ + row.file, + n(Math.round(row.ms)), + n(row.types), + n(row.instantiations), + n(row.symbols), + String(row.diagnostics.length), + ]); + } + + out.println(table.toString()); + + for (const row of result.rows) { + for (const line of row.diagnostics.slice(0, 5)) { + out.println(line); + } + } + }); + }); + + runtimeOption( + program + .command("closure") + .description( + "What a tsconfig of only one folder would hold, the declaration packages by size, and why a package is in" + ) + .argument("[tsconfig]", "A tsconfig.json or the folder holding one (default ./tsconfig.json)") + .option("--top <n>", "Rows per table (default 30)", numberArg({ min: 1, integer: true }), 30) + .option("--why <needle>", "The import chain from a project file to the first file whose path contains this") + .option( + "--no-ambient", + "Leave out the project's root .d.ts files (a folder tsconfig that does not include them)" + ) + ).action( + async ( + tsconfig: string | undefined, + options: RuntimeFlags & { top: number; why?: string; ambient: boolean }, + command: Command + ) => { + await runCheck(command, async (signal) => { + const graph = await runProgramWorker("graph", { + tsconfig: resolveTsconfig(tsconfig), + runtime: options.runtime, + heapMiB: options.heap, + timeoutMs: options.timeout * 1000, + signal, + }); + + if (options.why) { + const chain = whyChain(graph, options.why); + + if (options.json) { + out.result({ needle: options.why, chain }); + return; + } + + out.println( + chain ? chain.join("\n -> ") : `no project file reaches a file matching ${options.why}` + ); + return; + } + + const folders = folderClosures(graph, { ambient: options.ambient }); + const packages = packageSizes(graph); + const duplicates = duplicatePackages(graph); + const totalBytes = graph.files.reduce((sum, file) => sum + file.bytes, 0); + + if (options.json) { + out.result({ files: graph.files.length, bytes: totalBytes, folders, packages, duplicates }); + return; + } + + renderCliHeader( + "Program closure", + `${graph.config} · ${n(graph.files.length)} files, ${formatBytes(totalBytes)}` + ); + renderCliSection("A tsconfig of one folder would hold"); + const folderTable = createBoxTable([ + "FOLDER", + "BYTES", + "FILES", + "OWN", + "OTHER FOLDERS", + "NODE_MODULES", + ]); + + for (const row of folders.slice(0, options.top)) { + folderTable.push([ + row.folder, + formatBytes(row.bytes), + n(row.files), + n(row.own), + n(row.otherFolderFiles), + n(row.nodeModulesFiles), + ]); + } + + out.println(folderTable.toString()); + renderCliSection("Declaration packages by size"); + const packageTable = createBoxTable(["PACKAGE", "BYTES", "FILES", "DIRECT IMPORTERS"]); + + for (const row of packages.slice(0, options.top)) { + const importers = row.importers.length + ? `${row.importers.length}: ${row.importers.slice(0, 2).join(", ")}${row.importers.length > 2 ? " …" : ""}` + : "0 (through other packages)"; + packageTable.push([row.name, formatBytes(row.bytes), n(row.files), importers]); + } + + out.println(packageTable.toString()); + + if (duplicates.length > 0) { + renderCliSection("Packages in the program from more than one installed copy"); + const duplicateTable = createBoxTable(["PACKAGE", "COPIES", "EXTRA BYTES", "FOLDERS"]); + + for (const row of duplicates.slice(0, options.top)) { + duplicateTable.push([ + row.name, + n(row.copies.length), + formatBytes(row.extraBytes), + row.copies.map((copy) => `${copy.folder} (${n(copy.files)})`).join("\n"), + ]); + } + + out.println(duplicateTable.toString()); + } + + out.println( + `Next: ${suggestCommand("tools ts", { replaceCommand: ["program", "closure", ...(tsconfig ? [tsconfig] : []), "--why", packages[0]?.name ?? "<package>"] })}` + ); + }); + } + ); + + runtimeOption( + program + .command("sites") + .description( + "Rank what costs the checker most across the program, by what is called or referenced: every call, new, JSX element and type reference measured on its own during the first check" + ) + .argument("[tsconfig]", "A tsconfig.json or the folder holding one (default ./tsconfig.json)") + .option("--top <n>", "Rows", numberArg({ min: 1, integer: true }), 30) + .option("--by <key>", `Rank by ${SITE_SORT_KEYS.join(", ")}`, siteSortKey, "instantiations") + .option( + "--min-sites <n>", + "Only groups with at least this many sites (2 or more finds repeated shapes)", + numberArg({ min: 1, integer: true }), + 1 + ) + .option("--key <text>", "List the single sites whose key contains this, costliest first, instead of groups") + .option("--project-only", "Only keys declared in the project, not in packages or default libraries") + ).action( + async ( + tsconfig: string | undefined, + options: RuntimeFlags & { + top: number; + by: SiteSortKey; + minSites: number; + key?: string; + projectOnly?: boolean; + }, + command: Command + ) => { + await runCheck(command, async (signal) => { + const result = await runProgramWorker("sites", { + tsconfig: resolveTsconfig(tsconfig), + runtime: options.runtime, + heapMiB: options.heap, + timeoutMs: options.timeout * 1000, + signal, + }); + const attributed = result.sites.reduce((sum, site) => sum + site.instantiations, 0); + const share = `${((100 * attributed) / Math.max(1, result.instantiations)).toFixed(0)}% of ${n(result.instantiations)} instantiations at ${n(result.sites.length)} sites of ${n(result.minInstantiations)} or more`; + + if (options.key) { + const needle = options.key; + const rows = sitesForKey(result.sites, needle, options.projectOnly ?? false).slice(0, options.top); + + if (options.json) { + out.result({ key: needle, sites: rows }); + return; + } + + const table = createBoxTable(["WHERE", "INSTANTIATIONS", "TYPES", "MS", "KIND", "KEY"]); + + for (const site of rows) { + table.push([ + `${site.file}:${site.line}`, + n(site.instantiations), + n(site.types), + n(Math.round(site.ms)), + site.kind, + site.key, + ]); + } + + out.println(table.toString()); + return; + } + + const groups = sortGroups( + groupSites(result.sites).filter( + (group) => group.sites >= options.minSites && (!options.projectOnly || !group.external) + ), + options.by + ).slice(0, options.top); + + if (options.json) { + out.result({ ...result, sites: undefined, groups, attributed }); + return; + } + + renderCliHeader("Checker work by what is called", `${result.config} · ${share}`); + const table = createBoxTable([ + "KIND", + "KEY", + "INSTANTIATIONS", + "MEDIAN", + "SITES", + "FILES", + "MS", + "COSTLIEST", + ]); + + for (const group of groups) { + table.push([ + group.kind, + group.key, + n(group.instantiations), + n(group.medianInstantiations), + n(group.sites), + n(group.files), + n(group.ms), + `${group.max.file}:${group.max.line} (${n(group.max.instantiations)})`, + ]); + } + + out.println(table.toString()); + out.println( + "A high MEDIAN over many SITES is a code shape that costs at every use; one large site with a low median is the first use of a library type." + ); + const first = groups[0]; + + if (first) { + out.println( + `Next: ${suggestCommand("tools ts", { replaceCommand: ["program", "sites", ...(tsconfig ? [tsconfig] : []), "--key", first.key.split(" @ ")[0] ?? first.key] })}` + ); + } + }); + } + ); + + program + .command("trace") + .description( + "Run tsc --generateTrace (or read a trace folder) and list the slowest files and expressions with their source line" + ) + .argument("[tsconfig]", "A tsconfig.json or the folder holding one (default ./tsconfig.json)") + .option("--dir <path>", "An existing --generateTrace folder to read instead of running tsc") + .option("--top <n>", "Rows per table (default 30)", numberArg({ min: 1, integer: true }), 30) + .option("--min-ms <n>", "Smallest single check event to list (default 50)", numberArg({ min: 1 }), 50) + .option("--heap <MiB>", "Heap limit of tsc (default 16384)", numberArg({ min: 64, integer: true })) + .option(...timeoutOption()) + .option("--json", "The result as JSON") + .action( + async ( + tsconfig: string | undefined, + options: { dir?: string; top: number; minMs: number; heap?: number; timeout: number; json?: boolean }, + command: Command + ) => { + await runCheck(command, async (signal) => { + const config = resolveTsconfig(tsconfig); + if (!options.dir) { + out.log.info("tsc --generateTrace (a large program takes a minute)"); + } + + const dir = + options.dir ?? + (await generateTrace({ + tsconfig: config, + heapMiB: options.heap, + timeoutMs: options.timeout * 1000, + signal, + })); + const report = analyzeTrace({ + traceJson: readFileSync(join(dir, "trace.json"), "utf8"), + root: dirname(config), + minMs: options.minMs, + }); + + if (options.json) { + out.result({ + dir, + ...report, + files: report.files.slice(0, options.top), + hotSpots: report.hotSpots.slice(0, options.top), + }); + return; + } + + renderCliHeader( + "Trace hot spots", + `${dir} · check ${(report.checkMs / 1000).toFixed(1)} s over ${n(report.files.length)} files` + ); + const fileTable = createBoxTable(["FILE", "CHECK MS"]); + + for (const file of report.files.slice(0, options.top)) { + fileTable.push([file.file, n(file.ms)]); + } + + out.println(fileTable.toString()); + renderCliSection(`Slowest check events of at least ${options.minMs} ms (nested events overlap)`); + const spotTable = createBoxTable(["WHERE", "MS", "EVENT", "SOURCE"]); + + for (const spot of report.hotSpots.slice(0, options.top)) { + spotTable.push([`${spot.file}:${spot.line}`, n(spot.ms), spot.event, spot.source]); + } + + out.println(spotTable.toString()); + }); + } + ); + + program.addHelpText( + "after", + [ + "", + "Examples:", + ` ${toolCommand("ts program cost", "--heap", "3072")}`, + ` ${toolCommand("ts program files", "--folders")}`, + ` ${toolCommand("ts program own", "src/artifact/runtime/kit/chartjs.tsx")}`, + ` ${toolCommand("ts program closure", "--why", "stripe")}`, + ` ${toolCommand("ts program trace", "--min-ms", "100")}`, + ` ${toolCommand("ts program sites", "--min-sites", "5", "--by", "median")}`, + ].join("\n") + ); +} diff --git a/src/ts/index.ts b/src/ts/index.ts index 96ac943b16..1223f55ef4 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { registerDuplicatesCommands } from "./commands/duplicates"; import { registerImportsCommands } from "./commands/imports"; import { registerProfileCommands } from "./commands/profile"; +import { registerProgramCommands } from "./commands/program"; import { registerRefactorsCommands } from "./commands/refactors"; import { registerSkeletonCommands } from "./commands/skeleton"; import { registerSourcemapsCommands } from "./commands/sourcemaps"; @@ -15,7 +16,7 @@ const program = new Command(); program .name(`${toolCommand("ts")}`) .description( - "TypeScript analysis: API skeletons, duplicate code, refactor recommendations, import graphs and cost, CPU profiles" + "TypeScript analysis: API skeletons, duplicate code, refactor recommendations, import graphs and cost, CPU profiles, type-check cost" ); registerImportsCommands(program); @@ -24,6 +25,7 @@ registerDuplicatesCommands(program); registerRefactorsCommands(program); registerSourcemapsCommands(program); registerProfileCommands(program); +registerProgramCommands(program); if (import.meta.main) { await runTool(program, { tool: "ts" }); diff --git a/src/ts/lib/program/check-worker.ts b/src/ts/lib/program/check-worker.ts new file mode 100644 index 0000000000..fee5c4f951 --- /dev/null +++ b/src/ts/lib/program/check-worker.ts @@ -0,0 +1,654 @@ +// The checking process of `tools ts program`. It runs under node by default, because V8's heap is the one `tsc` and +// a reviewer's `ts.createProgram` live in, and it imports nothing from the repo: node cannot resolve the repo's path +// aliases, and the program checked is the TARGET project's, with that project's own `typescript`. That is why the +// protocol is environment in and one JSON file out, as in measure-worker.ts. +// +// GT_TSP_CONFIG absolute tsconfig path +// GT_TSP_MODE "phases" | "files" | "own" | "graph" +// GT_TSP_FILES for "own": the files to measure, one absolute path per line +// GT_TSP_OUT the result file + +import { realpathSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import type * as TS from "typescript"; +import type { + CheckerCounts, + FileCost, + GraphFile, + OwnCost, + PhaseSample, + ProgramMode, + ProgramSize, + SiteCost, + WorkerResult, +} from "./types"; + +const MODES: readonly ProgramMode[] = ["phases", "files", "own", "graph", "sites"]; + +function isProgramMode(value: string): value is ProgramMode { + return MODES.some((mode) => mode === value); +} + +const configPath = process.env.GT_TSP_CONFIG ?? ""; +const requestedMode = process.env.GT_TSP_MODE ?? "phases"; +const outPath = process.env.GT_TSP_OUT ?? ""; + +if (!configPath || !outPath) { + process.stderr.write("check-worker: GT_TSP_CONFIG and GT_TSP_OUT are required\n"); + process.exit(2); +} + +if (!isProgramMode(requestedMode)) { + process.stderr.write(`check-worker: unknown mode ${requestedMode}\n`); + process.exit(2); +} + +const mode: ProgramMode = requestedMode; + +const configDir = dirname(configPath); +const ts = loadTypescript(); +const started = performance.now(); +const gc = findGc(); + +/** The project's own TypeScript (a reviewer runs that one), else the copy beside this tool. */ +function loadTypescript(): typeof TS { + for (const base of [configPath, import.meta.url]) { + try { + const require = createRequire(base); + return require(require.resolve("typescript")); + } catch { + // Not resolvable from this base; the next one is tried. + } + } + + process.stderr.write(`check-worker: no typescript resolvable from ${configDir}\n`); + process.exit(2); +} + +/** A full collection: node's `--expose-gc` global, or Bun's. */ +function findGc(): (() => void) | null { + const nodeGc: unknown = Reflect.get(globalThis, "gc"); + + if (typeof nodeGc === "function") { + return () => { + nodeGc(); + nodeGc(); + }; + } + + const bun: unknown = Reflect.get(globalThis, "Bun"); + const bunGc: unknown = bun && typeof bun === "object" ? Reflect.get(bun, "gc") : undefined; + + if (typeof bunGc === "function") { + return () => bunGc.call(bun, true); + } + + return null; +} + +const MiB = 2 ** 20; + +function sample(phase: PhaseSample["phase"]): PhaseSample { + gc?.(); + const memory = process.memoryUsage(); + return { + phase, + heapMiB: Math.round(memory.heapUsed / MiB), + rssMiB: Math.round(memory.rss / MiB), + ms: Math.round(performance.now() - started), + }; +} + +/** The config folder as the module resolver reports paths: macOS resolves /tmp and /var to /private/... */ +const realConfigDir = realpathSync(configDir); + +function display(file: string): string { + for (const base of [configDir, realConfigDir]) { + const rel = relative(base, file); + + if (!rel.startsWith("..") && !isAbsolute(rel)) { + return rel; + } + } + + return file; +} + +function parseConfig(): TS.ParsedCommandLine { + const parsed = ts.getParsedCommandLineOfConfigFile( + configPath, + { noEmit: true }, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (d) => { + process.stderr.write(`check-worker: ${ts.flattenDiagnosticMessageText(d.messageText, " ")}\n`); + process.exit(3); + }, + } + ); + + if (!parsed) { + process.exit(3); + } + + return parsed; +} + +function createProgram(parsed: TS.ParsedCommandLine, rootNames = parsed.fileNames): TS.Program { + return ts.createProgram({ + rootNames, + options: { ...parsed.options, noEmit: true, incremental: false, composite: false }, + projectReferences: parsed.projectReferences, + // The config's own errors (an unknown option, an include that matches nothing); without them a broken + // tsconfig reported 0 diagnostics. + configFileParsingDiagnostics: ts.getConfigFileParsingDiagnostics(parsed), + }); +} + +/** The counters live on the program; they read its checker's state. */ +function counts(program: TS.Program): CheckerCounts { + return { + types: program.getTypeCount(), + instantiations: program.getInstantiationCount(), + symbols: program.getSymbolCount(), + }; +} + +function size(program: TS.Program): ProgramSize { + const result: ProgramSize = { + files: 0, + declarationFiles: 0, + nodeModulesFiles: 0, + nodeModulesBytes: 0, + sourceBytes: 0, + }; + + for (const file of program.getSourceFiles()) { + result.files++; + result.sourceBytes += file.text.length; + + if (file.isDeclarationFile) { + result.declarationFiles++; + } + + if (file.fileName.includes("/node_modules/")) { + result.nodeModulesFiles++; + result.nodeModulesBytes += file.text.length; + } + } + + return result; +} + +function formatDiagnostic(d: TS.Diagnostic): string { + const message = ts.flattenDiagnosticMessageText(d.messageText, " "); + + if (!d.file || d.start === undefined) { + return `TS${d.code}: ${message}`; + } + + const { line, character } = d.file.getLineAndCharacterOfPosition(d.start); + return `${display(d.file.fileName)}(${line + 1},${character + 1}): TS${d.code}: ${message}`; +} + +/** What `ts.createProgram` plus every diagnostic holds after each phase: what a reviewer's check needs. */ +function phases(): WorkerResult { + const samples = [sample("start")]; + const parsed = parseConfig(); + const program = createProgram(parsed); + samples.push(sample("parse")); + // Creating the checker binds every file. + program.getTypeChecker(); + samples.push(sample("bind")); + const diagnostics = [ + ...program.getConfigFileParsingDiagnostics(), + ...program.getOptionsDiagnostics(), + ...program.getGlobalDiagnostics(), + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ]; + samples.push(sample("check")); + return { + mode: "phases", + typescript: ts.version, + runtime: runtimeName(), + config: configPath, + gcForced: gc !== null, + size: size(program), + phases: samples, + diagnostics: diagnostics.length, + firstDiagnostics: diagnostics.slice(0, 20).map(formatDiagnostic), + ...counts(program), + }; +} + +function checkOne(program: TS.Program, file: TS.SourceFile): FileCost { + const before = counts(program); + const start = performance.now(); + program.getSemanticDiagnostics(file); + const after = counts(program); + return { + file: display(file.fileName), + types: after.types - before.types, + instantiations: after.instantiations - before.instantiations, + symbols: after.symbols - before.symbols, + ms: Math.round((performance.now() - start) * 10) / 10, + bytes: file.text.length, + }; +} + +/** Each file checked in program order, with what its check created. */ +function files(): WorkerResult { + const parsed = parseConfig(); + const program = createProgram(parsed); + const rows = program + .getSourceFiles() + .filter((file) => !file.isDeclarationFile) + .map((file) => checkOne(program, file)); + return { + mode: "files", + typescript: ts.version, + runtime: runtimeName(), + config: configPath, + gcForced: gc !== null, + size: size(program), + rows, + ...counts(program), + }; +} + +/** + * A program of the targets plus the config's own declaration files (ambient ones that nothing imports): every other + * file is checked first, then each target, so a target's numbers are its own work, not its dependencies'. + */ +function own(): WorkerResult { + const parsed = parseConfig(); + const targets = (process.env.GT_TSP_FILES ?? "") + .split("\n") + .filter(Boolean) + .map((file) => resolve(file)); + const ambient = parsed.fileNames.filter((file) => file.endsWith(".d.ts")); + const program = createProgram(parsed, [...targets, ...ambient]); + const wanted = new Set(targets); + + for (const file of program.getSourceFiles()) { + if (!wanted.has(resolve(file.fileName))) { + program.getSemanticDiagnostics(file); + } + } + + const rows: OwnCost[] = []; + + for (const target of targets) { + const file = program.getSourceFile(target); + + if (!file) { + rows.push({ + file: display(target), + types: 0, + instantiations: 0, + symbols: 0, + ms: 0, + bytes: 0, + diagnostics: ["not in the program"], + }); + continue; + } + + const cost = checkOne(program, file); + // A parse error is no semantic diagnostic, so a file that does not parse would otherwise read as clean. + const diagnostics = [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)]; + rows.push({ ...cost, diagnostics: diagnostics.map(formatDiagnostic) }); + } + + return { + mode: "own", + typescript: ts.version, + runtime: runtimeName(), + config: configPath, + gcForced: gc !== null, + rows, + }; +} + +/** + * Every module specifier a file names: import and export declarations, `import x = require()`, `import()` and + * `require()` calls, and `import("…")` types. Each is resolved with its own resolution mode, because under + * node16/nodenext an import and a require of one package can select different declaration files. + */ +/** + * The module the compiler imports for JSX without an import in the source (`react/jsx-runtime` under + * `jsx: react-jsx`). TypeScript adds it as a synthetic import that no AST walk sees, and its API does not + * expose it. A file with no JSX may get an edge it does not need; that keeps a closure complete, never short. + */ +function jsxRuntimeSpecifier(file: TS.SourceFile, options: TS.CompilerOptions): string | undefined { + if (!/\.[jt]sx$/.test(file.fileName)) { + return undefined; + } + + const pragma = /@jsxImportSource\s+(\S+)/.exec(file.text)?.[1]; + const automatic = + options.jsx === ts.JsxEmit.ReactJSX || options.jsx === ts.JsxEmit.ReactJSXDev || options.jsxImportSource; + + if (!automatic && !pragma) { + return undefined; + } + + const runtime = options.jsx === ts.JsxEmit.ReactJSXDev ? "jsx-dev-runtime" : "jsx-runtime"; + return `${pragma ?? options.jsxImportSource ?? "react"}/${runtime}`; +} + +function moduleSpecifiers(file: TS.SourceFile): TS.StringLiteralLike[] { + const found: TS.StringLiteralLike[] = []; + // TypeScript resolves `require("x")` as an import only in JavaScript files. In a .ts file the call is no import, + // and before binding its string has no parent node, which `getModeForUsageLocation` needs. + const requireImports = /\.[cm]?jsx?$/.test(file.fileName); + const visit = (node: TS.Node): void => { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) { + if (ts.isStringLiteralLike(node.moduleSpecifier)) { + found.push(node.moduleSpecifier); + } + } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { + if (ts.isStringLiteralLike(node.moduleReference.expression)) { + found.push(node.moduleReference.expression); + } + } else if (ts.isCallExpression(node)) { + const callee = node.expression; + const isLoader = + callee.kind === ts.SyntaxKind.ImportKeyword || + (requireImports && ts.isIdentifier(callee) && callee.text === "require"); + const [first] = node.arguments; + + if (isLoader && first && ts.isStringLiteralLike(first)) { + found.push(first); + } + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + if (ts.isStringLiteralLike(node.argument.literal)) { + found.push(node.argument.literal); + } + } + + ts.forEachChild(node, visit); + }; + + visit(file); + return found; +} + +/** + * The program's files and what each one imports or references, without checking. TypeScript keeps a file's resolved + * imports internal, so the edges are resolved again with `resolveModuleName`, the program's options, a shared cache, + * and each specifier's own mode (`getModeForUsageLocation`). + */ +function graph(): WorkerResult { + const parsed = parseConfig(); + const program = createProgram(parsed); + const options = program.getCompilerOptions(); + const cache = ts.createModuleResolutionCache(configDir, (name) => name, options); + const sourceFiles = program.getSourceFiles(); + const index = new Map(sourceFiles.map((file, i) => [resolve(file.fileName), i])); + const roots = new Set(program.getRootFileNames().map((file) => resolve(file))); + const base = new Set<number>(); + + for (const name of options.types ?? []) { + const resolved = ts.resolveTypeReferenceDirective( + name, + configPath, + options, + ts.sys + ).resolvedTypeReferenceDirective; + const at = resolved?.resolvedFileName ? index.get(resolve(resolved.resolvedFileName)) : undefined; + + if (at !== undefined) { + base.add(at); + } + } + + const out: GraphFile[] = sourceFiles.map((file, i) => { + const imports = new Set<number>(); + const add = (target: string | undefined) => { + const at = target ? index.get(resolve(target)) : undefined; + + if (at !== undefined && at !== i) { + imports.add(at); + } + }; + + for (const specifier of moduleSpecifiers(file)) { + const resolutionMode = ts.getModeForUsageLocation(file, specifier, options); + add( + ts.resolveModuleName(specifier.text, file.fileName, options, ts.sys, cache, undefined, resolutionMode) + .resolvedModule?.resolvedFileName + ); + } + + const jsxRuntime = jsxRuntimeSpecifier(file, options); + + if (jsxRuntime) { + add( + ts.resolveModuleName( + jsxRuntime, + file.fileName, + options, + ts.sys, + cache, + undefined, + file.impliedNodeFormat + ).resolvedModule?.resolvedFileName + ); + } + + for (const ref of file.referencedFiles) { + const target = resolve(dirname(file.fileName), ref.fileName); + add(index.has(target) ? target : `${target}.d.ts`); + } + + for (const ref of file.typeReferenceDirectives) { + // A directive's own resolution-mode (or the file's module format) picks the import or require branch. + const resolutionMode = ts.getModeForFileReference(ref, file.impliedNodeFormat); + add( + ts.resolveTypeReferenceDirective( + ref.fileName, + file.fileName, + options, + ts.sys, + undefined, + undefined, + resolutionMode + ).resolvedTypeReferenceDirective?.resolvedFileName + ); + } + + let bytes = file.text.length; + + try { + bytes = statSync(file.fileName).size; + } catch { + // A lib file served from memory keeps its text length. + } + + return { + path: display(file.fileName), + bytes, + root: roots.has(resolve(file.fileName)), + base: base.has(i) || program.isSourceFileDefaultLibrary(file), + declaration: file.isDeclarationFile, + imports: [...imports], + }; + }); + + return { + mode: "graph", + typescript: ts.version, + runtime: runtimeName(), + config: configPath, + gcForced: gc !== null, + files: out, + }; +} + +/** Sites below this are not listed; across the GenesisTools root they held under a third of all instantiations. */ +const SITE_MIN_INSTANTIATIONS = 50; + +type SiteNode = TS.CallExpression | TS.NewExpression | TS.JsxOpeningLikeElement | TS.TypeReferenceNode; + +interface SiteKey { + key: string; + external: boolean; +} + +/** `name @ where`: a package file is named from inside its node_modules folder, a project file by its path. */ +function declarationKey(checker: TS.TypeChecker, symbol: TS.Symbol | undefined): SiteKey | null { + if (!symbol) { + return null; + } + + const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + const declaration = target.declarations?.[0]; + + if (!declaration) { + return { key: target.name, external: true }; + } + + const sourceFile = declaration.getSourceFile(); + const file = sourceFile.fileName; + const at = file.lastIndexOf("/node_modules/"); + const external = at >= 0 || sourceFile.hasNoDefaultLib; + return { key: `${target.name} @ ${at >= 0 ? file.slice(at + "/node_modules/".length) : display(file)}`, external }; +} + +function siteKey(checker: TS.TypeChecker, node: SiteNode): Pick<SiteCost, "kind" | "key" | "external"> { + if (ts.isTypeReferenceNode(node)) { + const found = declarationKey(checker, checker.getSymbolAtLocation(node.typeName)); + return { kind: "type", ...(found ?? { key: node.typeName.getText(), external: false }) }; + } + + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const type = checker.getTypeAtLocation(node.tagName); + const alias = type.aliasSymbol ? declarationKey(checker, type.aliasSymbol) : null; + return { + kind: "jsx", + key: `<${node.tagName.getText()}> : ${alias?.key ?? checker.typeToString(type).slice(0, 80)}`, + external: alias?.external ?? false, + }; + } + + const kind = ts.isNewExpression(node) ? "new" : "call"; + const declaration = checker.getResolvedSignature(node)?.declaration; + const named = declaration && "name" in declaration && declaration.name ? declaration.name : undefined; + const callee = ts.isPropertyAccessExpression(node.expression) ? node.expression.name : node.expression; + const found = + declarationKey(checker, named ? checker.getSymbolAtLocation(named) : undefined) ?? + declarationKey(checker, checker.getSymbolAtLocation(callee)); + return { kind, ...(found ?? { key: node.expression.getText().slice(0, 60), external: false }) }; +} + +/** + * During the FIRST check of each project file, every call, `new`, JSX element and type reference is resolved on its + * own and what it created is recorded; then the file's remaining checks run. Grouped by `key`, this ranks the APIs + * and types that cost the most across the whole program. The first site to touch a library type pays for it, so a + * cost repeated at many sites (a high median) is a code shape, and one large site is first use. + * + * ORDER: a call is resolved after its callee and BEFORE its arguments (a JSX element before its attributes), so an + * argument's work (an object literal, a callback's returned value, a nested call) counts for the call that receives + * it, the expression an edit changes. Arguments first would blame them for the outer call: an argument's contextual + * type resolves the outer call, and `z.literal("native")` inside a `z.discriminatedUnion` took 81k instantiations + * that way (2026-10-25). A method chain still costs per link, because each callee is resolved before its call. + */ +function sites(): WorkerResult { + const parsed = parseConfig(); + const program = createProgram(parsed); + const checker = program.getTypeChecker(); + const rows: SiteCost[] = []; + + for (const file of program.getSourceFiles()) { + if (file.isDeclarationFile || file.fileName.includes("/node_modules/")) { + continue; + } + + const measure = (node: SiteNode): void => { + const before = counts(program); + const start = performance.now(); + + try { + if (ts.isTypeReferenceNode(node)) { + checker.getTypeFromTypeNode(node); + } else { + checker.getResolvedSignature(node); + } + } catch { + // A node the checker cannot resolve on its own is checked with its file below. + return; + } + + const after = counts(program); + const instantiations = after.instantiations - before.instantiations; + + if (instantiations < SITE_MIN_INSTANTIATIONS) { + return; + } + + const ms = Math.round((performance.now() - start) * 10) / 10; + const { line } = file.getLineAndCharacterOfPosition(node.getStart(file)); + rows.push({ + file: display(file.fileName), + line: line + 1, + ...siteKey(checker, node), + instantiations, + types: after.types - before.types, + symbols: after.symbols - before.symbols, + ms, + }); + }; + const visitAll = (nodes: readonly TS.Node[] | undefined): void => nodes?.forEach(visit); + const visit = (node: TS.Node): void => { + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + visit(node.expression); + visitAll(node.typeArguments); + measure(node); + visitAll(node.arguments); + return; + } + + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + visit(node.tagName); + visitAll(node.typeArguments); + measure(node); + visit(node.attributes); + return; + } + + ts.forEachChild(node, visit); + + if (ts.isTypeReferenceNode(node)) { + measure(node); + } + }; + + visit(file); + program.getSemanticDiagnostics(file); + } + + return { + mode: "sites", + typescript: ts.version, + runtime: runtimeName(), + config: configPath, + gcForced: gc !== null, + minInstantiations: SITE_MIN_INSTANTIATIONS, + sites: rows, + ...counts(program), + }; +} + +function runtimeName(): string { + const bun: unknown = Reflect.get(globalThis, "Bun"); + return bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; +} + +const run: Record<ProgramMode, () => WorkerResult> = { phases, files, own, graph, sites }; +const result = run[mode](); + +// biome-ignore lint/style/noRestrictedGlobals: a standalone node process cannot import SafeJSON from the repo +writeFileSync(outPath, JSON.stringify(result)); diff --git a/src/ts/lib/program/closure.ts b/src/ts/lib/program/closure.ts new file mode 100644 index 0000000000..b8754b363c --- /dev/null +++ b/src/ts/lib/program/closure.ts @@ -0,0 +1,283 @@ +import type { FileCost, GraphFile, GraphResult } from "./types"; + +/** The package a declaration file belongs to (the innermost node_modules segment), or null for a project file. */ +export function packageOf(path: string): string | null { + const marker = "node_modules/"; + const at = path.lastIndexOf(marker); + + if (at < 0 || (at > 0 && path[at - 1] !== "/")) { + return null; + } + + const [first, second] = path.slice(at + marker.length).split("/"); + + if (!first || second === undefined) { + return null; + } + + return first.startsWith("@") ? `${first}/${second}` : first; +} + +/** A project file's folder: `src/<tool>`, `packages/<name>`, or its first path segment. */ +export function folderOf(path: string): string | null { + if (packageOf(path) || path.startsWith("/")) { + return null; + } + + const parts = path.split("/"); + + if ((parts[0] === "src" || parts[0] === "packages") && parts.length > 2) { + return `${parts[0]}/${parts[1]}`; + } + + return parts.length > 1 ? (parts[0] ?? null) : "(top-level files)"; +} + +function closureOf(files: GraphFile[], starts: Iterable<number>): Set<number> { + const seen = new Set<number>(); + const stack = [...starts]; + + while (stack.length) { + const at = stack.pop(); + + if (at === undefined || seen.has(at)) { + continue; + } + + seen.add(at); + + for (const next of files[at]?.imports ?? []) { + stack.push(next); + } + } + + return seen; +} + +export interface FolderClosure { + folder: string; + /** The folder's own root files. */ + own: number; + /** Every file a tsconfig that includes only this folder would hold. */ + files: number; + bytes: number; + nodeModulesFiles: number; + /** Files of the project's other folders that it pulls in. */ + otherFolderFiles: number; +} + +export interface ClosureOptions { + /** + * Count the project's own root declaration files as part of every folder's program, as a per-folder tsconfig that + * includes `../**\/*.d.ts` would (ambient declarations nothing imports). Default true. + */ + ambient?: boolean; +} + +/** For each folder, what a program of only that folder's files would hold (default libraries and `types` included). */ +export function folderClosures(graph: GraphResult, options: ClosureOptions = {}): FolderClosure[] { + const files = graph.files; + const base = files.flatMap((file, i) => + file.base || (options.ambient !== false && file.root && file.declaration && !packageOf(file.path)) ? [i] : [] + ); + const folders = new Map<string, number[]>(); + + for (const [i, file] of files.entries()) { + const folder = file.root && !file.base ? folderOf(file.path) : null; + + if (folder) { + folders.set(folder, [...(folders.get(folder) ?? []), i]); + } + } + + const rows = [...folders].map(([folder, own]) => { + const reach = closureOf(files, [...own, ...base]); + let bytes = 0; + let nodeModulesFiles = 0; + let otherFolderFiles = 0; + + for (const at of reach) { + const file = files[at]; + + if (!file) { + continue; + } + + bytes += file.bytes; + + if (packageOf(file.path)) { + nodeModulesFiles++; + } else if (folderOf(file.path) !== folder && !file.base) { + otherFolderFiles++; + } + } + + return { folder, own: own.length, files: reach.size, bytes, nodeModulesFiles, otherFolderFiles }; + }); + + return rows.sort((a, b) => b.bytes - a.bytes); +} + +export interface PackageSize { + name: string; + files: number; + bytes: number; + /** Project files that import the package directly. */ + importers: string[]; +} + +/** Declaration packages by size, each with the project files that import it directly. */ +export function packageSizes(graph: GraphResult): PackageSize[] { + const sizes = new Map<string, PackageSize>(); + + for (const file of graph.files) { + const name = packageOf(file.path); + + if (!name) { + continue; + } + + const size = sizes.get(name) ?? { name, files: 0, bytes: 0, importers: [] }; + size.files++; + size.bytes += file.bytes; + sizes.set(name, size); + } + + for (const file of graph.files) { + if (packageOf(file.path)) { + continue; + } + + for (const at of file.imports) { + const name = packageOf(graph.files[at]?.path ?? ""); + const size = name ? sizes.get(name) : undefined; + + if (size && !size.importers.includes(file.path)) { + size.importers.push(file.path); + } + } + } + + return [...sizes.values()].sort((a, b) => b.bytes - a.bytes); +} + +/** + * The shortest import chain from a project file to the first file whose path contains `needle` (a package name or + * part of a path), or null when no project file reaches it. + */ +export function whyChain(graph: GraphResult, needle: string): string[] | null { + const files = graph.files; + const target = files.findIndex((file) => file.path.includes(needle)); + + if (target < 0) { + return null; + } + + const importers = new Map<number, number[]>(); + + for (const [i, file] of files.entries()) { + for (const at of file.imports) { + importers.set(at, [...(importers.get(at) ?? []), i]); + } + } + + const previous = new Map<number, number>([[target, -1]]); + const queue = [target]; + + for (let head = 0; head < queue.length; head++) { + const at = queue[head]; + + if (at === undefined) { + continue; + } + + if (!packageOf(files[at]?.path ?? "") && files[at]?.base !== true) { + const chain: string[] = []; + + for (let step = at; step >= 0; step = previous.get(step) ?? -1) { + chain.push(files[step]?.path ?? "?"); + } + + return chain; + } + + for (const from of importers.get(at) ?? []) { + if (!previous.has(from)) { + previous.set(from, at); + queue.push(from); + } + } + } + + return null; +} + +export interface FolderCost { + folder: string; + files: number; + types: number; + instantiations: number; + symbols: number; + ms: number; +} + +/** Per-file check costs summed per folder. */ +export function costByFolder(rows: FileCost[]): FolderCost[] { + const folders = new Map<string, FolderCost>(); + + for (const row of rows) { + const folder = folderOf(row.file) ?? "(outside)"; + const cost = folders.get(folder) ?? { folder, files: 0, types: 0, instantiations: 0, symbols: 0, ms: 0 }; + cost.files++; + cost.types += row.types; + cost.instantiations += row.instantiations; + cost.symbols += row.symbols; + cost.ms += row.ms; + folders.set(folder, cost); + } + + return [...folders.values()]; +} + +export interface DuplicatePackage { + name: string; + /** Each installed copy: its folder, files and bytes in the program. */ + copies: Array<{ folder: string; files: number; bytes: number }>; + /** Bytes of every copy after the largest: what deduplicating would take out of the program. */ + extraBytes: number; +} + +/** Packages whose declarations are in the program from more than one installed copy (nested node_modules). */ +export function duplicatePackages(graph: GraphResult): DuplicatePackage[] { + const copies = new Map<string, Map<string, { files: number; bytes: number }>>(); + + for (const file of graph.files) { + const name = packageOf(file.path); + + if (!name) { + continue; + } + + const at = file.path.lastIndexOf(`node_modules/${name}/`); + const folder = file.path.slice(0, at + `node_modules/${name}`.length); + const byFolder = copies.get(name) ?? new Map(); + const copy = byFolder.get(folder) ?? { files: 0, bytes: 0 }; + copy.files++; + copy.bytes += file.bytes; + byFolder.set(folder, copy); + copies.set(name, byFolder); + } + + const rows: DuplicatePackage[] = []; + + for (const [name, byFolder] of copies) { + if (byFolder.size < 2) { + continue; + } + + const list = [...byFolder].map(([folder, copy]) => ({ folder, ...copy })).sort((a, b) => b.bytes - a.bytes); + rows.push({ name, copies: list, extraBytes: list.slice(1).reduce((sum, copy) => sum + copy.bytes, 0) }); + } + + return rows.sort((a, b) => b.extraBytes - a.extraBytes); +} diff --git a/src/ts/lib/program/program.test.ts b/src/ts/lib/program/program.test.ts new file mode 100644 index 0000000000..acef2b822e --- /dev/null +++ b/src/ts/lib/program/program.test.ts @@ -0,0 +1,513 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { removeRecursive, waitForPath } from "@genesiscz/utils/fs"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { isProcessAlive } from "@genesiscz/utils/process-alive"; +import { toolDataDir } from "@genesiscz/utils/storage/root"; +import { + costByFolder, + duplicatePackages, + folderClosures, + folderOf, + packageOf, + packageSizes, + whyChain, +} from "./closure"; +import { generateTrace, runProgramWorker } from "./run"; +import { groupSites, sitesForKey, sortGroups } from "./sites"; +import { analyzeTrace } from "./trace"; +import type { GraphFile, GraphResult, SiteCost } from "./types"; + +function graphOf(files: Array<Partial<GraphFile> & { path: string }>): GraphResult { + return { + mode: "graph", + typescript: "6.0.0", + runtime: "node", + config: "/p/tsconfig.json", + gcForced: true, + files: files.map((file) => ({ bytes: 10, root: false, base: false, declaration: false, imports: [], ...file })), + }; +} + +describe("program closure", () => { + const graph = graphOf([ + { path: "node_modules/typescript/lib/lib.es5.d.ts", base: true, declaration: true, bytes: 100 }, + { path: "src/hub/a.ts", root: true, imports: [2, 4] }, + { path: "src/utils/u.ts", root: true, imports: [3] }, + { path: "node_modules/@scope/pkg/index.d.ts", declaration: true, bytes: 1000, imports: [5] }, + { path: "node_modules/big/index.d.ts", declaration: true, bytes: 5000 }, + { path: "node_modules/@scope/pkg/node_modules/inner/x.d.ts", declaration: true, bytes: 7 }, + { path: "src/ambient.d.ts", root: true, declaration: true, bytes: 3 }, + { path: "src/youtube/y.ts", root: true }, + ]); + + it("names folders and packages, the innermost node_modules winning", () => { + expect(folderOf("src/hub/lib/x.ts")).toBe("src/hub"); + expect(folderOf("scripts/ci/x.ts")).toBe("scripts"); + expect(folderOf("test-a.ts")).toBe("(top-level files)"); + expect(folderOf("node_modules/a/b.d.ts")).toBeNull(); + expect(packageOf("node_modules/@scope/pkg/node_modules/inner/x.d.ts")).toBe("inner"); + expect(packageOf("node_modules/@scope/pkg/index.d.ts")).toBe("@scope/pkg"); + expect(packageOf("src/a.ts")).toBeNull(); + }); + + it("holds what one folder reaches, plus the base and the ambient root declarations", () => { + const hub = folderClosures(graph).find((row) => row.folder === "src/hub"); + // a.ts, u.ts, pkg, big, inner, lib.es5, ambient + expect(hub).toEqual({ + folder: "src/hub", + own: 1, + files: 7, + bytes: 6130, + nodeModulesFiles: 4, + otherFolderFiles: 2, + }); + const withoutAmbient = folderClosures(graph, { ambient: false }).find((row) => row.folder === "src/hub"); + expect(withoutAmbient?.files).toBe(6); + }); + + it("ranks packages by bytes and lists their direct project importers", () => { + const sizes = packageSizes(graph); + expect(sizes.map((size) => size.name)).toEqual(["big", "@scope/pkg", "typescript", "inner"]); + expect(sizes.find((size) => size.name === "@scope/pkg")?.importers).toEqual(["src/utils/u.ts"]); + expect(sizes.find((size) => size.name === "inner")?.importers).toEqual([]); + }); + + it("finds the shortest chain from a project file", () => { + expect(whyChain(graph, "inner/x")).toEqual([ + "src/utils/u.ts", + "node_modules/@scope/pkg/index.d.ts", + "node_modules/@scope/pkg/node_modules/inner/x.d.ts", + ]); + expect(whyChain(graph, "no-such-file")).toBeNull(); + }); + + it("sums file costs per folder", () => { + const rows = costByFolder([ + { file: "src/hub/a.ts", types: 1, instantiations: 2, symbols: 3, ms: 4, bytes: 0 }, + { file: "src/hub/b.ts", types: 10, instantiations: 20, symbols: 30, ms: 40, bytes: 0 }, + ]); + expect(rows).toEqual([{ folder: "src/hub", files: 2, types: 11, instantiations: 22, symbols: 33, ms: 44 }]); + }); +}); + +describe("program sites and duplicates", () => { + it("lists a package once per installed copy, the extra bytes being every copy after the largest", () => { + const graph = graphOf([ + { path: "node_modules/@scope/pkg/index.d.ts", declaration: true, bytes: 1000 }, + { path: "node_modules/@scope/pkg/types.d.ts", declaration: true, bytes: 200 }, + { path: "node_modules/host/node_modules/@scope/pkg/index.d.ts", declaration: true, bytes: 300 }, + { path: "node_modules/single/index.d.ts", declaration: true, bytes: 50 }, + ]); + expect(duplicatePackages(graph)).toEqual([ + { + name: "@scope/pkg", + copies: [ + { folder: "node_modules/@scope/pkg", files: 2, bytes: 1200 }, + { folder: "node_modules/host/node_modules/@scope/pkg", files: 1, bytes: 300 }, + ], + extraBytes: 300, + }, + ]); + }); + + it("groups sites by what they call, with the median and the costliest site", () => { + const site = (key: string, line: number, instantiations: number): SiteCost => ({ + file: "src/a.ts", + line, + kind: "call", + key, + external: key.includes("node_modules"), + instantiations, + types: 1, + symbols: 0, + ms: 1, + }); + const groups = groupSites([ + site("where @ kysely", 1, 100), + site("where @ kysely", 2, 300), + site("where @ kysely", 3, 200), + site("render @ src/r.ts", 4, 5000), + ]); + expect(sortGroups(groups, "instantiations").map((group) => [group.key, group.instantiations])).toEqual([ + ["render @ src/r.ts", 5000], + ["where @ kysely", 600], + ]); + const where = groups.find((group) => group.key === "where @ kysely"); + expect(where).toMatchObject({ sites: 3, files: 1, medianInstantiations: 200, max: { line: 2 } }); + expect(sortGroups(groups, "sites")[0]?.key).toBe("where @ kysely"); + // An even group averages its two middle sites. + const pair = groupSites([site("cheap @ src/c.ts", 1, 50), site("cheap @ src/c.ts", 2, 5000)]); + expect(pair[0]?.medianInstantiations).toBe(2525); + }); + + it("lists the sites of one key, keeping only project keys under --project-only", () => { + const site = (key: string, instantiations: number): SiteCost => ({ + file: "src/a.ts", + line: 1, + kind: "call", + key, + external: key.includes("node_modules"), + instantiations, + types: 1, + symbols: 0, + ms: 1, + }); + const sites = [ + site("where @ node_modules/kysely", 300), + site("where @ src/db.ts", 100), + site("render @ src/r.ts", 900), + ]; + expect(sitesForKey(sites, "where", false).map((row) => row.key)).toEqual([ + "where @ node_modules/kysely", + "where @ src/db.ts", + ]); + expect(sitesForKey(sites, "where", true).map((row) => row.key)).toEqual(["where @ src/db.ts"]); + }); +}); + +describe("program trace", () => { + it("adds check time per file and lists hot events once per line, matching lowercased paths", () => { + const source = "const a = 1;\nconst slow = make();\n"; + const traceJson = SafeJSON.stringify([ + { ph: "X", cat: "check", name: "checkSourceFile", ts: 0, dur: 300_000, args: { path: "/repo/src/a.ts" } }, + { + ph: "X", + cat: "check", + name: "checkVariableDeclaration", + ts: 0, + dur: 120_000, + args: { path: "/repo/src/a.ts", pos: 14 }, + }, + { ph: "B", cat: "check", name: "checkExpression", ts: 0, args: { path: "/repo/src/a.ts", pos: 20 } }, + { ph: "E", cat: "check", name: "checkExpression", ts: 110_000 }, + { + ph: "X", + cat: "check", + name: "checkExpression", + ts: 0, + dur: 5_000, + args: { path: "/repo/src/a.ts", pos: 0 }, + }, + ]); + const report = analyzeTrace({ traceJson, root: "/Repo", minMs: 50, readSource: () => source }); + expect(report.checkMs).toBe(300); + expect(report.files).toEqual([{ file: "src/a.ts", ms: 300 }]); + expect(report.hotSpots).toEqual([ + { file: "src/a.ts", line: 2, event: "checkVariableDeclaration", ms: 120, source: "const slow = make();" }, + ]); + }); +}); + +/** + * A project whose `typescript` blocks forever after writing what the child is (its pid and result path), so a + * deadline or an abort is the only way the run can end. Atomics.wait blocks without using the CPU. + */ +function blockingProject(root: string): { tsconfig: string; started: (file: "index" | "tsc") => string } { + const pkg = join(root, "node_modules", "typescript"); + mkdirSync(join(pkg, "lib"), { recursive: true }); + writeFileSync(join(root, "tsconfig.json"), SafeJSON.stringify({ compilerOptions: { noEmit: true }, files: [] })); + writeFileSync( + join(pkg, "package.json"), + SafeJSON.stringify({ name: "typescript", version: "0.0.0", main: "index.js" }) + ); + const blocker = [ + 'const { renameSync, writeFileSync } = require("node:fs");', + 'const { join } = require("node:path");', + "// Renamed into place, so a watcher that sees started.json reads it whole.", + 'writeFileSync(join(__dirname, "started.json.tmp"), JSON.stringify({ pid: process.pid, out: process.env.GT_TSP_OUT ?? null }));', + 'renameSync(join(__dirname, "started.json.tmp"), join(__dirname, "started.json"));', + "Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);", + ].join("\n"); + writeFileSync(join(pkg, "index.js"), blocker); + writeFileSync(join(pkg, "lib", "tsc.js"), blocker); + return { + tsconfig: join(root, "tsconfig.json"), + started: (file) => join(pkg, ...(file === "tsc" ? ["lib"] : []), "started.json"), + }; +} + +interface Started { + pid: number; + out: string | null; +} + +/** Wakes on the file event: the child writes the file within a second of starting on any machine. */ +async function waitForStart(path: string): Promise<Started> { + if (!(await waitForPath(path, { timeoutMs: 10_000 }))) { + throw new Error(`the child never wrote ${path}`); + } + + const started: Started = SafeJSON.parse(readFileSync(path, "utf8"), { strict: true }); + return started; +} + +/** A killed child can stay a zombie until launchd reaps it, and it is not our child, so no exit event: poll with a deadline. */ +async function waitForExit(pid: number): Promise<boolean> { + const until = Date.now() + 5_000; + + while (Date.now() < until) { + if (!isProcessAlive(pid)) { + return true; + } + + await Bun.sleep(100); + } + + return false; +} + +describe("program worker deadlines", () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "gt-ts-program-deadline-")); + }); + + afterAll(() => { + removeRecursive(root); + }); + + it("stops a check at its deadline, kills the child and removes the result folder", async () => { + const project = blockingProject(join(root, "timeout")); + const run = runProgramWorker("phases", { tsconfig: project.tsconfig, timeoutMs: 2_000 }); + // Handled before the wait, so a deadline that fires first is never an unhandled rejection. Bun's + // `expect(run).rejects` blocks until the promise settles, so it cannot be the early handler itself. + run.catch(() => undefined); + const started = await waitForStart(project.started("index")); + await expect(run).rejects.toThrow("the check did not finish in 2 s and was stopped; pass a larger --timeout"); + expect(await waitForExit(started.pid)).toBe(true); + expect(started.out).not.toBeNull(); + expect(existsSync(dirname(started.out ?? ""))).toBe(false); + }, 30_000); + + it("stops a check on abort, kills the child and removes the result folder", async () => { + const project = blockingProject(join(root, "abort")); + const controller = new AbortController(); + const run = runProgramWorker("phases", { tsconfig: project.tsconfig, signal: controller.signal }); + run.catch(() => undefined); + const started = await waitForStart(project.started("index")); + controller.abort(); + await expect(run).rejects.toThrow("the check was interrupted"); + expect(await waitForExit(started.pid)).toBe(true); + expect(existsSync(dirname(started.out ?? ""))).toBe(false); + }, 30_000); + + it("starts no child for a signal that is already aborted", async () => { + const project = blockingProject(join(root, "aborted")); + await expect( + runProgramWorker("phases", { tsconfig: project.tsconfig, signal: AbortSignal.abort() }) + ).rejects.toThrow("the check was interrupted"); + expect(existsSync(project.started("index"))).toBe(false); + }, 30_000); + + it("forwards the deadline to tsc --generateTrace and removes the partial trace", async () => { + const project = blockingProject(join(root, "trace")); + const traces = toolDataDir("ts", "traces"); + const before = existsSync(traces) ? readdirSync(traces) : []; + const run = generateTrace({ tsconfig: project.tsconfig, timeoutMs: 1_500 }); + run.catch(() => undefined); + const started = await waitForStart(project.started("tsc")); + await expect(run).rejects.toThrow("tsc --generateTrace did not finish in 2 s and was stopped"); + expect(await waitForExit(started.pid)).toBe(true); + expect(existsSync(traces) ? readdirSync(traces) : []).toEqual(before); + }, 30_000); +}); + +describe("program worker (node)", () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "gt-ts-program-test-")); + mkdirSync(join(dir, "src")); + writeFileSync( + join(dir, "tsconfig.json"), + SafeJSON.stringify({ + compilerOptions: { strict: true, noEmit: true, lib: ["ES2022"] }, + include: ["src/**/*"], + }) + ); + writeFileSync(join(dir, "src", "b.ts"), "export const b = 1;\n"); + writeFileSync(join(dir, "src", "a.ts"), 'import { b } from "./b";\nexport const a: number = b + 1;\n'); + writeFileSync(join(dir, "src", "bad.ts"), 'export const bad: number = "text";\n'); + writeFileSync(join(dir, "src", "globals.d.ts"), "declare const BUILD_ID: string;\n"); + }); + + afterAll(() => { + removeRecursive(dir); + }); + + it("measures phases, one file's own cost, and the import graph of a real program", async () => { + const tsconfig = join(dir, "tsconfig.json"); + const phases = await runProgramWorker("phases", { tsconfig }); + expect(phases.phases.map((phase) => phase.phase)).toEqual(["start", "parse", "bind", "check"]); + expect(phases.gcForced).toBe(true); + expect(phases.diagnostics).toBe(1); + expect(phases.firstDiagnostics[0]).toContain("src/bad.ts(1,14): TS2322"); + + const own = await runProgramWorker("own", { + tsconfig, + files: [join(dir, "src", "a.ts"), join(dir, "src", "bad.ts")], + }); + expect(own.rows.map((row) => [row.file, row.diagnostics.length])).toEqual([ + ["src/a.ts", 0], + ["src/bad.ts", 1], + ]); + + const graph = await runProgramWorker("graph", { tsconfig }); + const a = graph.files.find((file) => file.path === "src/a.ts"); + expect(a?.imports.map((at) => graph.files[at]?.path)).toEqual(["src/b.ts"]); + expect(graph.files.find((file) => file.path === "src/globals.d.ts")).toMatchObject({ + root: true, + declaration: true, + }); + expect(graph.files.some((file) => file.base && file.declaration)).toBe(true); + }, 60_000); + + it("charges a call with its arguments, so a contextually typed inner call is not blamed for the outer one", async () => { + const project = join(dir, "sites"); + mkdirSync(join(project, "src"), { recursive: true }); + writeFileSync( + join(project, "tsconfig.json"), + SafeJSON.stringify({ + compilerOptions: { strict: true, noEmit: true, lib: ["ES2022"] }, + include: ["src/**/*"], + }) + ); + // Overload resolution is the outer call's own work; the arguments' contextual type needs it resolved. + const overloads = Array.from( + { length: 60 }, + (_, at) => `declare function outer<T extends { tag: { tag: "a${at}" } }>(value: T): T;` + ); + writeFileSync( + join(project, "src", "s.ts"), + [ + ...overloads, + "declare function outer<T extends { tag: { tag: string } }>(value: T): T;", + "declare function inner<const V extends string>(value: V): { tag: V };", + 'export const built = outer({ tag: inner("x"), other: inner("y") });', + "", + ].join("\n") + ); + const result = await runProgramWorker("sites", { tsconfig: join(project, "tsconfig.json") }); + const outerSite = result.sites.find((site) => site.key.startsWith("outer @ src/s.ts")); + expect(outerSite).toMatchObject({ file: "src/s.ts", line: 63, kind: "call", external: false }); + expect(outerSite?.instantiations ?? 0).toBeGreaterThanOrEqual(result.minInstantiations); + + for (const site of result.sites.filter((row) => row.key.startsWith("inner @"))) { + expect(site.instantiations).toBeLessThan(outerSite?.instantiations ?? 0); + } + }, 60_000); + + it("reports the tsconfig's own errors instead of 0 diagnostics", async () => { + const broken = join(dir, "broken"); + mkdirSync(join(broken, "src"), { recursive: true }); + writeFileSync( + join(broken, "tsconfig.json"), + SafeJSON.stringify({ compilerOptions: { noEmit: true, notARealOption: true }, include: ["src/**/*"] }) + ); + writeFileSync(join(broken, "src", "x.ts"), "export const x = 1;\n"); + const phases = await runProgramWorker("phases", { tsconfig: join(broken, "tsconfig.json") }); + expect(phases.diagnostics).toBe(1); + expect(phases.firstDiagnostics[0]).toContain("TS5023"); + }, 60_000); + + it("resolves each import with its own mode, so ESM and CommonJS reach different declaration files", async () => { + const dual = join(dir, "dual"); + const pkg = join(dual, "node_modules", "dual"); + mkdirSync(join(dual, "src"), { recursive: true }); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(dual, "tsconfig.json"), + SafeJSON.stringify({ + compilerOptions: { + module: "nodenext", + moduleResolution: "nodenext", + strict: true, + noEmit: true, + types: [], + }, + include: ["src/**/*"], + }) + ); + writeFileSync( + join(pkg, "package.json"), + SafeJSON.stringify({ + name: "dual", + version: "1.0.0", + exports: { ".": { import: { types: "./esm.d.ts" }, require: { types: "./cjs.d.ts" } } }, + }) + ); + writeFileSync(join(pkg, "esm.d.ts"), "export declare const esm: number;\n"); + writeFileSync(join(pkg, "cjs.d.ts"), "export declare const cjs: number;\n"); + writeFileSync(join(dual, "src", "a.mts"), 'import { esm } from "dual";\nexport const a = esm;\n'); + writeFileSync(join(dual, "src", "b.cts"), 'import dual = require("dual");\nexport const b = dual.cjs;\n'); + // A type reference resolves with its own resolution-mode, not the file's (a .cts asking for the import branch). + writeFileSync( + join(dual, "src", "c.cts"), + '/// <reference types="dual" resolution-mode="import" />\nexport {};\n' + ); + writeFileSync( + join(dual, "src", "d.mts"), + 'export const load = () => import("dual");\nexport type Dual = typeof import("dual");\n' + ); + // A require() call in a TypeScript file is no import (TypeScript resolves it only in JavaScript files), and + // before binding its string literal has no parent node. + writeFileSync( + join(dual, "src", "e.ts"), + 'declare const require: (id: string) => unknown;\nexport const e = require("dual");\n' + ); + const graph = await runProgramWorker("graph", { tsconfig: join(dual, "tsconfig.json") }); + const edges = (path: string) => + graph.files.find((file) => file.path === path)?.imports.map((at) => graph.files[at]?.path); + expect(edges("src/a.mts")).toEqual(["node_modules/dual/esm.d.ts"]); + expect(edges("src/b.cts")).toEqual(["node_modules/dual/cjs.d.ts"]); + expect(edges("src/c.cts")).toEqual(["node_modules/dual/esm.d.ts"]); + expect(edges("src/d.mts")).toEqual(["node_modules/dual/esm.d.ts"]); + expect(edges("src/e.ts")).toEqual([]); + }, 60_000); + + it("reports a parse error as a diagnostic and links a TSX file to the JSX runtime it never imports", async () => { + const project = join(dir, "jsx"); + const react = join(project, "node_modules", "react"); + mkdirSync(join(project, "src"), { recursive: true }); + mkdirSync(react, { recursive: true }); + writeFileSync( + join(project, "tsconfig.json"), + SafeJSON.stringify({ + compilerOptions: { + jsx: "react-jsx", + module: "esnext", + moduleResolution: "bundler", + noEmit: true, + types: [], + }, + include: ["src/**/*"], + }) + ); + writeFileSync(join(react, "package.json"), SafeJSON.stringify({ name: "react", version: "19.0.0" })); + writeFileSync( + join(react, "jsx-runtime.d.ts"), + "export declare function jsx(type: unknown, props: unknown): unknown;\n" + ); + writeFileSync(join(project, "src", "view.tsx"), "export const view = <div />;\n"); + writeFileSync(join(project, "src", "broken.ts"), "export const broken = ;\n"); + const tsconfig = join(project, "tsconfig.json"); + + const own = await runProgramWorker("own", { tsconfig, files: [join(project, "src", "broken.ts")] }); + expect(own.rows[0]?.diagnostics.some((line) => line.includes("TS1109"))).toBe(true); + + const graph = await runProgramWorker("graph", { tsconfig }); + const view = graph.files.find((file) => file.path === "src/view.tsx"); + expect(view?.imports.map((at) => graph.files[at]?.path)).toEqual(["node_modules/react/jsx-runtime.d.ts"]); + }, 60_000); + + it("reports a heap that is too small as a usage error, not a stack dump", async () => { + const tsconfig = join(dir, "tsconfig.json"); + await expect(runProgramWorker("phases", { tsconfig, heapMiB: 8 })).rejects.toThrow( + /does not fit in 8 MiB of heap/ + ); + }, 60_000); +}); diff --git a/src/ts/lib/program/run.ts b/src/ts/lib/program/run.ts new file mode 100644 index 0000000000..36d81dd386 --- /dev/null +++ b/src/ts/lib/program/run.ts @@ -0,0 +1,227 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { env } from "@genesiscz/utils/env"; +import { removeRecursive } from "@genesiscz/utils/fs"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { boundedCommand } from "@genesiscz/utils/process/bounded-command"; +import { toolDataDir } from "@genesiscz/utils/storage/root"; +import { UsageError } from "../format"; +import type { + FilesResult, + GraphResult, + OwnResult, + PhasesResult, + ProgramMode, + SitesResult, + WorkerResult, +} from "./types"; + +const WORKER = join(import.meta.dir, "check-worker.ts"); +export const DEFAULT_HEAP_MIB = 16_384; +/** A whole check of the GenesisTools root took 35-41 s (2026-10-25); a run past this is stuck, not slow. */ +export const DEFAULT_TIMEOUT_MS = 15 * 60_000; +/** Room for a program with thousands of diagnostics on stdout (tsc) or a long stack on stderr. */ +const OUTPUT_BUDGET_BYTES = 32 * 1024 * 1024; + +export type ProgramRuntime = "node" | "bun"; + +export interface ProgramRunOptions { + tsconfig: string; + /** node (default): V8, the heap `tsc` and a reviewer's `ts.createProgram` use. bun: JavaScriptCore. */ + runtime?: ProgramRuntime; + /** + * The worker's heap limit in MiB (node only). A run that needs more fails as out of memory, as a reviewer's would. + * Unset means DEFAULT_HEAP_MIB, not node's own default (about 4 GiB on this Mac), which the GenesisTools root + * program exceeded before 2026-10-25: a measurement must finish, a limit is asked for on purpose. + */ + heapMiB?: number; + /** The check is killed (its whole process group) after this long. Default DEFAULT_TIMEOUT_MS. */ + timeoutMs?: number; + /** Aborts the check, as Ctrl-C does through `withInterrupt`. */ + signal?: AbortSignal; + files?: string[]; +} + +/** The tsconfig to check: a path to the file, or a folder holding `tsconfig.json`. */ +export function resolveTsconfig(input: string | undefined): string { + const path = resolve(input ?? "tsconfig.json"); + const file = existsSync(join(path, "tsconfig.json")) ? join(path, "tsconfig.json") : path; + + if (!existsSync(file)) { + throw new UsageError(`no tsconfig at ${file}`); + } + + return file; +} + +/** Checked before the spawn, so a worker's own "not found" error is never mistaken for a missing node. */ +function requireNode(): void { + if (!Bun.which("node")) { + throw new UsageError("node is not on PATH; install it or pass --runtime bun (JavaScriptCore heap numbers)"); + } +} + +interface BoundedRun { + command: string[]; + timeoutMs: number; + signal?: AbortSignal; + environment?: Record<string, string | undefined>; + what: string; +} + +/** A child with a deadline: on expiry or abort its process group gets SIGTERM, then SIGKILL. */ +function seconds(ms: number): string { + return ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`; +} + +async function runBounded(run: BoundedRun): Promise<{ status: number | null; stderr: string }> { + // boundedCommand throws a bare AbortError for a signal that is already aborted; it starts no child either way. + if (run.signal?.aborted) { + throw new UsageError(`${run.what} was interrupted`); + } + + const result = await boundedCommand({ + command: run.command, + timeoutMs: run.timeoutMs, + signal: run.signal, + maxBufferBytes: OUTPUT_BUDGET_BYTES, + environment: run.environment, + }); + + if (result.error?.code === "ETIMEDOUT") { + throw new UsageError( + `${run.what} did not finish in ${seconds(run.timeoutMs)} and was stopped; pass a larger --timeout` + ); + } + + if (result.error?.code === "ABORT_ERR") { + throw new UsageError(`${run.what} was interrupted`); + } + + if (result.error) { + throw new Error(`${run.what} failed: ${result.error.message}`, { cause: result.error }); + } + + return { status: result.status, stderr: result.stderr }; +} + +export async function runProgramWorker(mode: "phases", options: ProgramRunOptions): Promise<PhasesResult>; +export async function runProgramWorker(mode: "files", options: ProgramRunOptions): Promise<FilesResult>; +export async function runProgramWorker(mode: "own", options: ProgramRunOptions): Promise<OwnResult>; +export async function runProgramWorker(mode: "graph", options: ProgramRunOptions): Promise<GraphResult>; +export async function runProgramWorker(mode: "sites", options: ProgramRunOptions): Promise<SitesResult>; +export async function runProgramWorker(mode: ProgramMode, options: ProgramRunOptions): Promise<WorkerResult> { + const runtime = options.runtime ?? "node"; + + if (runtime === "node") { + requireNode(); + } + + const dir = mkdtempSync(join(tmpdir(), "gt-ts-program-")); + const outPath = join(dir, "result.json"); + const cmd = + runtime === "node" + ? ["node", "--expose-gc", `--max-old-space-size=${options.heapMiB ?? DEFAULT_HEAP_MIB}`, WORKER] + : [process.execPath, "--smol", WORKER]; + + logger.debug({ mode, runtime, tsconfig: options.tsconfig, cmd }, "ts program: spawning check worker"); + + try { + const { status, stderr } = await runBounded({ + command: cmd, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + signal: options.signal, + what: "the check", + environment: { + ...env.getProcessEnv(), + GT_TSP_CONFIG: options.tsconfig, + GT_TSP_MODE: mode, + GT_TSP_FILES: (options.files ?? []).map((file) => resolve(file)).join("\n"), + GT_TSP_OUT: outPath, + }, + }); + + if (status !== 0 || !existsSync(outPath)) { + logger.warn({ mode, runtime, status, stderr: stderr.slice(-2000) }, "ts program: check worker failed"); + + // An out-of-memory run is a result (the program does not fit that heap), not a crash to dump. + if (/heap out of memory|Reached heap limit/i.test(stderr)) { + throw new UsageError( + `the program does not fit in ${options.heapMiB ?? DEFAULT_HEAP_MIB} MiB of heap (out of memory); pass a larger --heap to measure it` + ); + } + + throw new Error( + `the check worker failed (exit ${status}): ${stderr.trim().split("\n").slice(-3).join(" ")}` + ); + } + + const result: WorkerResult = SafeJSON.parse(readFileSync(outPath, "utf8"), { strict: true }); + return result; + } finally { + removeRecursive(dir); + } +} + +export interface TraceRunOptions { + tsconfig: string; + heapMiB?: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +/** `tsc --generateTrace` of the project's own TypeScript, under node, into a dated folder; returns the folder. */ +export async function generateTrace(options: TraceRunOptions): Promise<string> { + requireNode(); + const require = createRequire(options.tsconfig); + let tsc: string; + + try { + tsc = require.resolve("typescript/lib/tsc.js"); + } catch (error) { + throw new UsageError(`no typescript/lib/tsc.js resolvable from ${dirname(options.tsconfig)}`, { cause: error }); + } + + const dir = toolDataDir("ts", "traces", new Date().toISOString().replace(/[:.]/g, "-")); + mkdirSync(dir, { recursive: true }); + + try { + return await traceInto(dir, tsc, options); + } catch (error) { + // A stopped or failed tsc leaves a partial trace that no later read can use. + removeRecursive(dir); + throw error; + } +} + +async function traceInto(dir: string, tsc: string, options: TraceRunOptions): Promise<string> { + const { status, stderr } = await runBounded({ + command: [ + "node", + `--max-old-space-size=${options.heapMiB ?? DEFAULT_HEAP_MIB}`, + tsc, + "-p", + options.tsconfig, + "--noEmit", + "--pretty", + "false", + "--generateTrace", + dir, + ], + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + signal: options.signal, + what: "tsc --generateTrace", + }); + + // tsc exits 1 or 2 when the program has errors; the trace is still complete. + if (status === null || status > 2 || !existsSync(join(dir, "trace.json"))) { + throw new Error( + `tsc --generateTrace failed (exit ${status}): ${stderr.trim().split("\n").slice(-3).join(" ")}` + ); + } + + return dir; +} diff --git a/src/ts/lib/program/sites.ts b/src/ts/lib/program/sites.ts new file mode 100644 index 0000000000..f5aa6d3abf --- /dev/null +++ b/src/ts/lib/program/sites.ts @@ -0,0 +1,75 @@ +import type { SiteCost } from "./types"; + +export interface SiteGroup { + kind: SiteCost["kind"]; + key: string; + external: boolean; + sites: number; + files: number; + instantiations: number; + types: number; + ms: number; + /** The median site: high with many sites means a code shape that costs at every use. */ + medianInstantiations: number; + /** The costliest site; one large site and a low median means the first use of a library type. */ + max: Pick<SiteCost, "file" | "line" | "instantiations">; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2; + } + + return sorted[mid] ?? 0; +} + +/** Site costs grouped by what each site calls or references. */ +export function groupSites(sites: SiteCost[]): SiteGroup[] { + const groups = new Map<string, SiteCost[]>(); + + for (const site of sites) { + const id = `${site.kind} ${site.key}`; + const members = groups.get(id); + + if (members) { + members.push(site); + } else { + groups.set(id, [site]); + } + } + + return [...groups.values()].map((members) => { + const first = members[0] as SiteCost; + const max = members.reduce((best, site) => (site.instantiations > best.instantiations ? site : best), first); + return { + kind: first.kind, + key: first.key, + external: first.external, + sites: members.length, + files: new Set(members.map((site) => site.file)).size, + instantiations: members.reduce((sum, site) => sum + site.instantiations, 0), + types: members.reduce((sum, site) => sum + site.types, 0), + ms: Math.round(members.reduce((sum, site) => sum + site.ms, 0)), + medianInstantiations: median(members.map((site) => site.instantiations)), + max: { file: max.file, line: max.line, instantiations: max.instantiations }, + }; + }); +} + +/** The single sites whose key holds `needle`, costliest first; `projectOnly` drops package and lib keys. */ +export function sitesForKey(sites: SiteCost[], needle: string, projectOnly: boolean): SiteCost[] { + return sites + .filter((site) => site.key.includes(needle) && (!projectOnly || !site.external)) + .sort((a, b) => b.instantiations - a.instantiations); +} + +export const SITE_SORT_KEYS = ["instantiations", "median", "types", "ms", "sites"] as const; +export type SiteSortKey = (typeof SITE_SORT_KEYS)[number]; + +export function sortGroups(groups: SiteGroup[], by: SiteSortKey): SiteGroup[] { + const value = (group: SiteGroup): number => (by === "median" ? group.medianInstantiations : group[by]); + return [...groups].sort((a, b) => value(b) - value(a)); +} diff --git a/src/ts/lib/program/trace.ts b/src/ts/lib/program/trace.ts new file mode 100644 index 0000000000..f05f0e8abe --- /dev/null +++ b/src/ts/lib/program/trace.ts @@ -0,0 +1,157 @@ +import { readFileSync } from "node:fs"; +import { SafeJSON } from "@genesiscz/utils/json"; + +/** One event of a `tsc --generateTrace` trace.json (Chrome trace format). */ +interface TraceEvent { + ph: string; + cat?: string; + name: string; + ts: number; + dur?: number; + args?: { path?: string; pos?: number }; +} + +export interface TraceFile { + file: string; + ms: number; +} + +export interface TraceHotSpot { + file: string; + line: number; + event: string; + ms: number; + /** The source line, trimmed. */ + source: string; +} + +export interface TraceReport { + checkMs: number; + files: TraceFile[]; + hotSpots: TraceHotSpot[]; +} + +/** A trace path relative to the project. tsc lowercases trace paths on a case-insensitive disk, so compare lowercased. */ +function relativeTo(root: string, path: string): string { + const prefix = `${root.replace(/\/$/, "")}/`; + return path.toLowerCase().startsWith(prefix.toLowerCase()) ? path.slice(prefix.length) : path; +} + +/** + * `checkSourceFile` time per file, and the slowest single check events with their source line. Nested events + * overlap: a statement's time includes the expressions inside it, so one hot expression shows on several lines. + */ +export function analyzeTrace(options: { + traceJson: string; + root: string; + minMs?: number; + readSource?: (path: string) => string; +}): TraceReport { + const events: TraceEvent[] = SafeJSON.parse(options.traceJson, { strict: true }); + const minMs = options.minMs ?? 50; + const read = options.readSource ?? ((path: string) => readFileSync(path, "utf8")); + const files: TraceFile[] = []; + const spots: Array<{ path: string; pos: number; event: string; ms: number }> = []; + const open: TraceEvent[] = []; + + const take = (event: TraceEvent, ms: number) => { + const path = event.args?.path; + + if (!path) { + return; + } + + if (event.name === "checkSourceFile") { + files.push({ file: path, ms }); + } else if (event.cat === "check" && ms >= minMs) { + spots.push({ path, pos: event.args?.pos ?? 0, event: event.name, ms }); + } + }; + + for (const event of events) { + if (event.ph === "X") { + take(event, (event.dur ?? 0) / 1000); + } else if (event.ph === "B") { + open.push(event); + } else if (event.ph === "E") { + const begin = open.pop(); + + if (begin) { + take(begin, (event.ts - begin.ts) / 1000); + } + } + } + + // One line index per source: a slow nested event costs a binary search, not a split of the file. + const sources = new Map<string, { lines: string[]; starts: number[] }>(); + const hotSpots: TraceHotSpot[] = []; + const seen = new Set<string>(); + + for (const spot of spots.sort((a, b) => b.ms - a.ms)) { + let source = sources.get(spot.path); + + if (source === undefined) { + let text = ""; + + try { + text = read(spot.path); + } catch { + // An unreadable source still lists the spot, at line 1 with no text. + } + + const lines = text.split("\n"); + const starts: number[] = []; + let at = 0; + + for (const line of lines) { + starts.push(at); + at += line.length + 1; + } + + source = { lines, starts }; + sources.set(spot.path, source); + } + + const line = lineOf(source.starts, spot.pos); + const key = `${spot.path}:${line}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + hotSpots.push({ + file: relativeTo(options.root, spot.path), + line, + event: spot.event, + ms: Math.round(spot.ms), + source: (source.lines[line - 1] ?? "").trim().slice(0, 140), + }); + } + + return { + checkMs: Math.round(files.reduce((sum, file) => sum + file.ms, 0)), + files: files + .map((file) => ({ file: relativeTo(options.root, file.file), ms: Math.round(file.ms) })) + .sort((a, b) => b.ms - a.ms), + hotSpots, + }; +} + +/** The 1-based line that holds `pos`, given each line's start offset in ascending order. */ +function lineOf(starts: number[], pos: number): number { + let low = 0; + let high = starts.length - 1; + + while (low < high) { + const mid = (low + high + 1) >> 1; + + if ((starts[mid] ?? 0) <= pos) { + low = mid; + } else { + high = mid - 1; + } + } + + return low + 1; +} diff --git a/src/ts/lib/program/types.ts b/src/ts/lib/program/types.ts new file mode 100644 index 0000000000..74710f15c3 --- /dev/null +++ b/src/ts/lib/program/types.ts @@ -0,0 +1,107 @@ +/** What `tools ts program` measures. The check worker writes these as JSON; it imports this file for types only. */ + +export type ProgramMode = "phases" | "files" | "own" | "graph" | "sites"; + +export interface PhaseSample { + phase: "start" | "parse" | "bind" | "check"; + /** Heap after a full collection when the runtime allows one (`gcForced`), else the raw heap in use. */ + heapMiB: number; + rssMiB: number; + ms: number; +} + +export interface CheckerCounts { + types: number; + instantiations: number; + symbols: number; +} + +export interface ProgramSize { + files: number; + declarationFiles: number; + nodeModulesFiles: number; + nodeModulesBytes: number; + sourceBytes: number; +} + +interface WorkerBase { + typescript: string; + runtime: string; + config: string; + gcForced: boolean; +} + +export interface PhasesResult extends WorkerBase, CheckerCounts { + mode: "phases"; + size: ProgramSize; + phases: PhaseSample[]; + diagnostics: number; + firstDiagnostics: string[]; +} + +/** What checking one file created; the first file to touch a shared library type pays for it. */ +export interface FileCost { + file: string; + types: number; + instantiations: number; + symbols: number; + ms: number; + bytes: number; +} + +export interface FilesResult extends WorkerBase, CheckerCounts { + mode: "files"; + size: ProgramSize; + rows: FileCost[]; +} + +export interface OwnCost extends FileCost { + diagnostics: string[]; +} + +export interface OwnResult extends WorkerBase { + mode: "own"; + rows: OwnCost[]; +} + +export interface GraphFile { + /** Relative to the tsconfig's folder, or absolute when outside it. */ + path: string; + bytes: number; + /** Named by the tsconfig's include or files. */ + root: boolean; + /** Loaded by every program of these options: a default library, or a package named in `types`. */ + base: boolean; + declaration: boolean; + /** Indexes into `files` of what this file imports or references. */ + imports: number[]; +} + +export interface GraphResult extends WorkerBase { + mode: "graph"; + files: GraphFile[]; +} + +/** What one call, `new`, JSX element or type reference created when it was resolved on its own, during the first check. */ +export interface SiteCost { + file: string; + line: number; + kind: "call" | "new" | "jsx" | "type"; + /** What it calls or references: `name @ declaring file` (a package file is named from its package folder). */ + key: string; + /** The key's declaration is in a package or a default library, not in the project. */ + external: boolean; + instantiations: number; + types: number; + symbols: number; + ms: number; +} + +export interface SitesResult extends WorkerBase, CheckerCounts { + mode: "sites"; + /** Sites under `minInstantiations` are left out; their work stays in the program totals. */ + minInstantiations: number; + sites: SiteCost[]; +} + +export type WorkerResult = PhasesResult | FilesResult | OwnResult | GraphResult | SitesResult; diff --git a/src/update/README.md b/src/update/README.md index 22640f599d..d07618e3f2 100644 --- a/src/update/README.md +++ b/src/update/README.md @@ -18,13 +18,25 @@ The tool discovers the GenesisTools install path from `$GENESIS_TOOLS_PATH` or t --- +## Dev channel + +```bash +tools update dev # switch to the newest origin/feat/<date>-enhancements-unified branch +tools update # the next plain update switches back to the branch you came from, then pulls +``` + +The branch you came from is kept in `~/.genesis-tools/update/dev-channel.json`. Without that file a plain update stays on its current branch. The switch refuses uncommitted tracked changes, and it refuses a local copy of the dev branch that has commits origin does not have. + ## What it does 1. `git checkout -- bun.lock` to discard local lockfile drift. 2. `git pull` in the GenesisTools repo. 3. `bun install` — retries once with a clean `node_modules` if the first attempt fails. -4. Prompts to refresh the Claude Code plugin: + Everything after this step runs in a child `tools update` on the code the pull just brought in (`GENESIS_TOOLS_UPDATE_POST_PULL=1`). Before this, the steps ran the code loaded before the pull, so every fix to them reached a user one update late. +4. Refreshes the Claude Code plugin. A terminal gets asked first; without one (an agent, CI) the step runs without asking, and `--no-plugins` skips it: - Inside a Claude Code session: runs `claude plugin marketplace update` and `claude plugin update`. - Outside: runs `marketplace add`, `marketplace update`, then `plugin install genesis-tools@genesis-tools`. -Cancel the Claude step if you only want the source update. +Cancel the Claude step, or pass `--no-plugins`, if you only want the source update. + +An installed GenesisTools.app whose sources changed is rebuilt only with full Xcode. With the Command Line Tools only (`xcode-select -p` is `/Library/Developer/CommandLineTools`) the rebuild is skipped with a hint, because the SwiftUI macro plugins ship only with Xcode. diff --git a/src/update/channel.ts b/src/update/channel.ts new file mode 100644 index 0000000000..b4f4a47b2b --- /dev/null +++ b/src/update/channel.ts @@ -0,0 +1,198 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { toolCommand } from "@genesiscz/utils/cli/tool-command"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { out } from "@genesiscz/utils/logger"; +import { toolDataDir } from "@genesiscz/utils/storage/root"; +import pc from "picocolors"; +import { git } from "./recovery"; + +export type UpdateMode = "default" | "dev"; + +/** + * `tools update dev` moves the checkout onto the newest enhancements-unified branch for a while; + * the next plain `tools update` moves it back. The branch it came from is kept here, so a + * checkout that was on the unified branch to begin with is never moved to master by surprise. + */ +export interface DevChannelState { + branch: string; + returnTo: string; + since: string; +} + +const UNIFIED_REF = /^origin\/(feat\/(\d{4}-\d{2}-\d{2})-enhancements-unified)$/; + +/** + * The child that runs the steps after the pull on the freshly pulled code. A variable, not a flag: + * a child on code from before the hand-over (back from the dev channel onto an older master) would + * refuse an unknown flag, while it simply ignores the variable and runs a whole update. + */ +export function postPullInvocation(opts: { plugins: boolean }): { args: string[]; env: Record<string, string> } { + return { + args: ["update", ...(opts.plugins ? [] : ["--no-plugins"])], + env: { GENESIS_TOOLS_UPDATE_POST_PULL: "1" }, + }; +} + +function isUnifiedBranch(branch: string): boolean { + return UNIFIED_REF.test(`origin/${branch}`); +} + +export function devChannelPath(): string { + return toolDataDir("update", "dev-channel.json"); +} + +/** The newest `origin/feat/<date>-enhancements-unified`, by the date in its name, as a local branch name. */ +export function pickNewestUnifiedBranch(remoteRefs: string[]): string | undefined { + let best: { branch: string; date: string } | undefined; + + for (const ref of remoteRefs) { + const match = ref.trim().match(UNIFIED_REF); + + if (!match?.[1] || !match[2]) { + continue; + } + + if (!best || match[2] > best.date) { + best = { branch: match[1], date: match[2] }; + } + } + + return best?.branch; +} + +export function readDevChannel(path = devChannelPath()): DevChannelState | undefined { + if (!existsSync(path)) { + return undefined; + } + + const parsed: unknown = SafeJSON.parse(readFileSync(path, "utf-8")); + + if ( + typeof parsed === "object" && + parsed !== null && + "branch" in parsed && + "returnTo" in parsed && + typeof parsed.branch === "string" && + typeof parsed.returnTo === "string" + ) { + return { + branch: parsed.branch, + returnTo: parsed.returnTo, + since: "since" in parsed && typeof parsed.since === "string" ? parsed.since : "", + }; + } + + return undefined; +} + +export function writeDevChannel(state: DevChannelState, path = devChannelPath()): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${SafeJSON.stringify(state, null, 2)}\n`); +} + +export function clearDevChannel(path = devChannelPath()): void { + if (existsSync(path)) { + unlinkSync(path); + } +} + +/** + * Moves the checkout onto or off the dev channel before the pull. + * + * "pull": go on with `git pull` on the current branch. "at-tip": the checkout already sits on the + * freshly fetched dev branch, so there is nothing to pull. "failed": the reason is printed. + */ +export async function switchChannel( + cwd: string, + mode: UpdateMode, + statePath = devChannelPath() +): Promise<"pull" | "at-tip" | "failed"> { + const state = readDevChannel(statePath); + + if (mode === "default") { + if (!state) { + return "pull"; + } + + if (!(await workingTreeIsClean(cwd))) { + return "failed"; + } + + out.println(pc.dim(` Leaving the dev channel (${state.branch}): back to ${state.returnTo}...`)); + const back = await git(["switch", state.returnTo], cwd); + + if (back.code !== 0) { + out.error(pc.red(` Could not switch back to ${state.returnTo}.`)); + out.println(pc.dim(` ${back.output}`)); + return "failed"; + } + + clearDevChannel(statePath); + return "pull"; + } + + out.println(pc.dim(" Fetching origin...")); + const fetched = await git(["fetch", "origin", "--prune"], cwd); + + if (fetched.code !== 0) { + out.error(pc.red(" git fetch failed.")); + out.println(pc.dim(` ${fetched.output}`)); + return "failed"; + } + + const refs = await git(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin/feat/"], cwd); + const branch = pickNewestUnifiedBranch(refs.output.split("\n")); + + if (!branch) { + out.error(pc.red(" origin has no feat/<date>-enhancements-unified branch.")); + return "failed"; + } + + if (!(await workingTreeIsClean(cwd))) { + return "failed"; + } + + // `switch -C` resets a local branch of that name onto origin's tip. Commits that exist only + // locally would be lost, so they stop the switch instead. + const local = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd); + + if (local.code === 0) { + const ahead = await git(["rev-list", "--count", `origin/${branch}..${branch}`], cwd); + + if (Number(ahead.output) > 0) { + out.error(pc.red(` ${branch} has ${ahead.output} local commit(s) that are not on origin.`)); + out.println(pc.dim(" Push them or move them to another branch, then run this again.")); + return "failed"; + } + } + + const current = (await git(["branch", "--show-current"], cwd)).output; + // A checkout switched to a unified branch by hand (the first switch from code that predates + // `tools update dev`) has no record yet; returning to that same branch would never leave it. + const returnTo = state?.returnTo ?? (current && !isUnifiedBranch(current) ? current : "master"); + const switched = await git(["switch", "--no-track", "-C", branch, `origin/${branch}`], cwd); + + if (switched.code !== 0) { + out.error(pc.red(` Could not switch to ${branch}.`)); + out.println(pc.dim(` ${switched.output}`)); + return "failed"; + } + + writeDevChannel({ branch, returnTo, since: new Date().toISOString() }, statePath); + out.println(pc.yellow(` On ${branch} (dev channel). The next ${toolCommand("update")} returns to ${returnTo}.`)); + return "at-tip"; +} + +/** Tracked changes would block `git switch` halfway, so the channel switch refuses them up front. */ +async function workingTreeIsClean(cwd: string): Promise<boolean> { + const status = await git(["status", "--porcelain", "--untracked-files=no"], cwd); + + if (status.code === 0 && status.output === "") { + return true; + } + + out.error(pc.red(" The GenesisTools checkout has uncommitted changes; commit or stash them first:")); + out.println(pc.dim(` ${status.output.split("\n").slice(0, 10).join("\n ")}`)); + return false; +} diff --git a/src/update/index.ts b/src/update/index.ts index 34f337232a..deb590558e 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -6,14 +6,17 @@ import { createBuildProgressDisplay } from "@app/macos/lib/permissions/swift-pro import { discoverTools } from "@app/tools/lib/discovery"; import * as p from "@clack/prompts"; import { getAgentRuntimeContext } from "@genesiscz/utils/agent/runtime"; +import { findClaudeExecutable } from "@genesiscz/utils/claude"; import { execTool, execToolInteractive, isInteractive, isVerbose, runTool, suggestCommand } from "@genesiscz/utils/cli"; import { toolCommand } from "@genesiscz/utils/cli/tool-command"; import { env } from "@genesiscz/utils/env"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger, out } from "@genesiscz/utils/logger"; -import { genesisAppBuildHint } from "@genesiscz/utils/macos/xcode"; +import { detectXcodeToolchain, genesisAppBuildHint } from "@genesiscz/utils/macos/xcode"; import { Command } from "commander"; import pc from "picocolors"; +import type { UpdateMode } from "./channel"; +import { postPullInvocation, switchChannel } from "./channel"; import { printUpdateCatalogue } from "./diff"; import { genesisAppRefreshAction } from "./genesis-app-refresh"; import { @@ -23,14 +26,18 @@ import { type MarketplaceLookup, PLUGIN_REF, planMarketplaceAction, + pluginStepDecision, } from "./marketplace"; import { offerMcpRegistration } from "./mcp-registration-offer"; import { recoverFromFailedPull } from "./recovery"; +/** The `claude` binary the plugin steps run, resolved once before they start (`findClaudeExecutable`). */ +let claudeBin = "claude"; + /** Bun.spawn throws SYNCHRONOUSLY when the executable is missing, so a bare await never sees it. */ async function runClaude(args: string[]): Promise<number> { try { - return await Bun.spawn(["claude", ...args], { stdio: ["inherit", "inherit", "inherit"] }).exited; + return await Bun.spawn([claudeBin, ...args], { stdio: ["inherit", "inherit", "inherit"] }).exited; } catch (err) { logger.warn({ err, args }, "update: could not run the claude CLI"); return 127; @@ -43,7 +50,7 @@ async function claudeJson<T>(args: string[]): Promise<T | null> { let exitCode: number; try { - const proc = Bun.spawn(["claude", ...args], { stdio: ["ignore", "pipe", "ignore"] }); + const proc = Bun.spawn([claudeBin, ...args], { stdio: ["ignore", "pipe", "ignore"] }); stdout = await new Response(proc.stdout).text(); exitCode = await proc.exited; } catch (err) { @@ -83,21 +90,54 @@ async function pluginIsInstalled(id: string): Promise<boolean> { return Array.isArray(entries) && entries.some((entry) => entry.id === id); } +interface UpdateOptions { + plugins: boolean; +} + +const PLUGINS_FLAG_HELP = "Skip the Claude Code marketplace and plugin step"; + const program = new Command() .name("update") .description("Update GenesisTools to the latest version") - .action(async () => { - const genesisPath = env.tools.getPath() || resolve(import.meta.dir, "../.."); - const srcDir = join(genesisPath, "src"); + .option("--no-plugins", PLUGINS_FLAG_HELP) + .action(async (opts: UpdateOptions) => { + await runUpdate("default", opts); + }); + +program + .command("dev") + .description( + `Switch to the newest feat/<date>-enhancements-unified branch; the next plain ${toolCommand("update")} switches back` + ) + .option("--no-plugins", PLUGINS_FLAG_HELP) + .action(async (opts: UpdateOptions) => { + await runUpdate("dev", opts); + }); + +async function runUpdate(mode: UpdateMode, opts: UpdateOptions): Promise<void> { + const genesisPath = env.tools.getPath() || resolve(import.meta.dir, "../.."); - out.println(pc.cyan("\n Updating GenesisTools...\n")); + if (env.tools.isUpdatePostPull()) { + await updateAfterPull(genesisPath, opts); + return; + } + + out.println(pc.cyan(`\n Updating GenesisTools${mode === "dev" ? " (dev channel)" : ""}...\n`)); + + // 1. Git pull (discard bun.lock changes — it will be regenerated by bun install) + await Bun.spawn(["git", "checkout", "--", "bun.lock"], { + cwd: genesisPath, + stdio: ["inherit", "inherit", "inherit"], + }).exited; + + const channel = await switchChannel(genesisPath, mode); + + if (channel === "failed") { + process.exit(1); + } - // 1. Git pull (discard bun.lock changes — it will be regenerated by bun install) + if (channel === "pull") { out.println(pc.dim(" Pulling latest changes...")); - await Bun.spawn(["git", "checkout", "--", "bun.lock"], { - cwd: genesisPath, - stdio: ["inherit", "inherit", "inherit"], - }).exited; const pull = Bun.spawn(["git", "pull"], { cwd: genesisPath, stdio: ["inherit", "inherit", "inherit"], @@ -115,177 +155,217 @@ const program = new Command() process.exit(1); } } + } - // 2. Install dependencies (clean retry if first attempt fails) - out.println(pc.dim("\n Installing dependencies...")); - const install = Bun.spawn(["bun", "install"], { + // 2. Install dependencies (clean retry if first attempt fails) + out.println(pc.dim("\n Installing dependencies...")); + const install = Bun.spawn(["bun", "install"], { + cwd: genesisPath, + stdio: ["inherit", "inherit", "inherit"], + }); + + if ((await install.exited) !== 0) { + out.println(pc.yellow(" Install failed, retrying with clean node_modules...")); + rmSync(join(genesisPath, "node_modules"), { recursive: true, force: true }); + const retry = Bun.spawn(["bun", "install"], { cwd: genesisPath, stdio: ["inherit", "inherit", "inherit"], }); - if ((await install.exited) !== 0) { - out.println(pc.yellow(" Install failed, retrying with clean node_modules...")); - rmSync(join(genesisPath, "node_modules"), { recursive: true, force: true }); - const retry = Bun.spawn(["bun", "install"], { - cwd: genesisPath, - stdio: ["inherit", "inherit", "inherit"], - }); + if ((await retry.exited) !== 0) { + out.error(pc.red(" Failed to install dependencies")); + process.exit(1); + } + } - if ((await retry.exited) !== 0) { - out.error(pc.red(" Failed to install dependencies")); - process.exit(1); - } + // The steps below live in the code that was just pulled, but this process still runs the code it + // loaded before the pull. Run them in a child instead, or every fix to them reached a user one + // update late (arwen, 2026-10-25: an update hung on a prompt that the pulled code no longer had). + // A child on code from before this hand-over ignores the variable and runs a whole update; its + // pull and install are no-ops by then. + const handover = postPullInvocation(opts); + const after = await execToolInteractive(handover.args, { cwd: genesisPath, env: handover.env }); + process.exit(after.exitCode); +} + +async function updateAfterPull(genesisPath: string, opts: UpdateOptions): Promise<void> { + const srcDir = join(genesisPath, "src"); + + // 3. Install or rebuild GenesisTools.app. + // + // The bundle lives outside the repo (~/Applications), so a pull never touches it and the + // existing user would never get the app at all, and after a source change the old launcher + // would keep running while the repo moved on. Grants survive either way, because re-signing + // keeps the identity. Rebuilding while this very process runs under the old bundle is safe: + // the running binary stays mapped until it exits. + await refreshGenesisApp(); + + // 3b. Long-running servers (daemons, gateways, dashboards) keep the code they started with. + await offerServiceRestarts(); + + // 4. Claude Code plugin management. Without a claude binary there is nothing to run: asking first and then + // failing every step with a stack trace is what a Mac without Claude Code on PATH used to get. + const inClaudeCode = getAgentRuntimeContext().isInAgent; + const resolvedClaude = await findClaudeExecutable(); + logger.debug({ claude: resolvedClaude }, "update: claude CLI for the plugin steps"); + + if (resolvedClaude) { + claudeBin = resolvedClaude; + } else { + out.println( + pc.yellow( + "\n Claude Code CLI not found (no `claude` on PATH or in its install folders); skipping the plugin steps." + ) + ); + out.println(pc.dim(` Install Claude Code, then run: ${toolCommand("update")}`)); + } + + const pluginStep = pluginStepDecision({ + hasClaude: resolvedClaude !== null, + plugins: opts.plugins, + interactive: isInteractive(), + }); + logger.debug({ pluginStep }, "update: Claude plugin step"); + + if (pluginStep === "run") { + out.println(pc.dim(`\n No terminal to ask, so the Claude plugin step runs (skip it with --no-plugins).`)); + } + + const runClaudeUpdates = + pluginStep === "run" || + (pluginStep === "ask" && + (await p.confirm({ + message: inClaudeCode + ? "Run Claude plugin update and marketplace update?" + : "Run Claude marketplace update, add, and plugin install?", + initialValue: true, + }))); + if (p.isCancel(runClaudeUpdates)) { + p.cancel("Cancelled"); + process.exit(0); + } + if (runClaudeUpdates) { + // This checkout IS the marketplace — step 1 just pulled it. Registering the GitHub + // URL instead makes Claude Code clone a SECOND copy into + // ~/.claude/plugins/marketplaces/genesis-tools and read the plugin from there, so + // edits in this repo stop taking effect until they are committed AND pushed. + const checkout = resolve(genesisPath); + const isCheckout = existsSync(join(checkout, ".claude-plugin", "marketplace.json")); + const lookup: MarketplaceLookup = await lookupMarketplace(MARKETPLACE_NAME); + const plan = planMarketplaceAction({ isCheckout, checkout, lookup, resolvePath: resolve }); + + if (plan.reason) { + out.println(pc.yellow(`\n ${plan.reason}`)); } - // 3. Install or rebuild GenesisTools.app. - // - // The bundle lives outside the repo (~/Applications), so a pull never touches it and the - // existing user would never get the app at all, and after a source change the old launcher - // would keep running while the repo moved on. Grants survive either way, because re-signing - // keeps the identity. Rebuilding while this very process runs under the old bundle is safe: - // the running binary stays mapped until it exits. - await refreshGenesisApp(); - - // 3b. Long-running servers (daemons, gateways, dashboards) keep the code they started with. - await offerServiceRestarts(); - - // 4. Claude Code plugin management - const inClaudeCode = getAgentRuntimeContext().isInAgent; - const runClaudeUpdates = await p.confirm({ - message: inClaudeCode - ? "Run Claude plugin update and marketplace update?" - : "Run Claude marketplace update, add, and plugin install?", - initialValue: true, - }); - if (p.isCancel(runClaudeUpdates)) { - p.cancel("Cancelled"); - process.exit(0); + // Every step below is a prerequisite of the next one. A failure that is only printed + // and then stepped over is how `tools update` used to uninstall a working plugin and + // then fail to reinstall it, leaving the user with nothing. + let marketplaceReady = true; + + if (plan.action === "add-checkout") { + // `marketplace add` OVERWRITES an existing registration's source rather than + // refusing, so this one command covers both first registration and the flip. + out.println( + pc.dim(plan.repointed ? `\n Re-pointing marketplace at ${checkout}...` : "\n Adding marketplace...") + ); + marketplaceReady = (await runClaude(["plugin", "marketplace", "add", checkout])) === 0; + } else if (plan.action === "add-fallback") { + out.println(pc.yellow(`\n ${checkout} is not a GenesisTools checkout; using the GitHub marketplace.`)); + marketplaceReady = (await runClaude(["plugin", "marketplace", "add", MARKETPLACE_FALLBACK_SOURCE])) === 0; + } else { + out.println(pc.dim("\n Refreshing marketplace...")); } - if (runClaudeUpdates) { - // This checkout IS the marketplace — step 1 just pulled it. Registering the GitHub - // URL instead makes Claude Code clone a SECOND copy into - // ~/.claude/plugins/marketplaces/genesis-tools and read the plugin from there, so - // edits in this repo stop taking effect until they are committed AND pushed. - const checkout = resolve(genesisPath); - const isCheckout = existsSync(join(checkout, ".claude-plugin", "marketplace.json")); - const lookup: MarketplaceLookup = await lookupMarketplace(MARKETPLACE_NAME); - const plan = planMarketplaceAction({ isCheckout, checkout, lookup, resolvePath: resolve }); - - if (plan.reason) { - out.println(pc.yellow(`\n ${plan.reason}`)); - } - // Every step below is a prerequisite of the next one. A failure that is only printed - // and then stepped over is how `tools update` used to uninstall a working plugin and - // then fail to reinstall it, leaving the user with nothing. - let marketplaceReady = true; - - if (plan.action === "add-checkout") { - // `marketplace add` OVERWRITES an existing registration's source rather than - // refusing, so this one command covers both first registration and the flip. - out.println( - pc.dim( - plan.repointed ? `\n Re-pointing marketplace at ${checkout}...` : "\n Adding marketplace..." - ) - ); - marketplaceReady = (await runClaude(["plugin", "marketplace", "add", checkout])) === 0; - } else if (plan.action === "add-fallback") { - out.println(pc.yellow(`\n ${checkout} is not a GenesisTools checkout; using the GitHub marketplace.`)); - marketplaceReady = - (await runClaude(["plugin", "marketplace", "add", MARKETPLACE_FALLBACK_SOURCE])) === 0; + if (!marketplaceReady) { + out.error(pc.red(" Marketplace registration failed — leaving the installed plugin untouched.")); + } else { + // Claude Code caches each marketplace's PARSED plugin catalog; even a directory + // source is not re-read on its own. Skipping this is what makes the next step fail + // with `Plugin "genesis-tools" not found in marketplace "genesis-tools"`. + if ((await runClaude(["plugin", "marketplace", "update", MARKETPLACE_NAME])) !== 0) { + out.error(pc.red(" Marketplace refresh failed — leaving the installed plugin untouched.")); + } else if (!(await pluginIsInstalled(PLUGIN_REF))) { + out.println(pc.dim("\n Installing plugin...")); + + if ((await runClaude(["plugin", "install", PLUGIN_REF])) !== 0) { + out.println(pc.yellow(" Plugin install had issues")); + } + } else if (plan.repointed) { + // The installed copy was taken from the OLD source, and the plugin cache is keyed + // by version — so `plugin update` is a no-op whenever the version is unchanged and + // the stale copy would survive the flip. Reinstall to re-copy from this checkout. + out.println(pc.dim("\n Reinstalling plugin from this checkout...")); + + if ((await runClaude(["plugin", "uninstall", PLUGIN_REF, "--keep-data", "-y"])) !== 0) { + out.println(pc.yellow(" Plugin uninstall failed — keeping the currently installed copy.")); + } else if ((await runClaude(["plugin", "install", PLUGIN_REF])) !== 0) { + out.error( + pc.red( + ` Plugin reinstall failed after uninstall. Restore it with: ` + + `claude plugin install ${PLUGIN_REF}` + ) + ); + } } else { - out.println(pc.dim("\n Refreshing marketplace...")); - } + out.println(pc.dim("\n Updating Claude Code plugin...")); - if (!marketplaceReady) { - out.error(pc.red(" Marketplace registration failed — leaving the installed plugin untouched.")); - } else { - // Claude Code caches each marketplace's PARSED plugin catalog; even a directory - // source is not re-read on its own. Skipping this is what makes the next step fail - // with `Plugin "genesis-tools" not found in marketplace "genesis-tools"`. - if ((await runClaude(["plugin", "marketplace", "update", MARKETPLACE_NAME])) !== 0) { - out.error(pc.red(" Marketplace refresh failed — leaving the installed plugin untouched.")); - } else if (!(await pluginIsInstalled(PLUGIN_REF))) { - out.println(pc.dim("\n Installing plugin...")); - - if ((await runClaude(["plugin", "install", PLUGIN_REF])) !== 0) { - out.println(pc.yellow(" Plugin install had issues")); - } - } else if (plan.repointed) { - // The installed copy was taken from the OLD source, and the plugin cache is keyed - // by version — so `plugin update` is a no-op whenever the version is unchanged and - // the stale copy would survive the flip. Reinstall to re-copy from this checkout. - out.println(pc.dim("\n Reinstalling plugin from this checkout...")); - - if ((await runClaude(["plugin", "uninstall", PLUGIN_REF, "--keep-data", "-y"])) !== 0) { - out.println(pc.yellow(" Plugin uninstall failed — keeping the currently installed copy.")); - } else if ((await runClaude(["plugin", "install", PLUGIN_REF])) !== 0) { - out.error( - pc.red( - ` Plugin reinstall failed after uninstall. Restore it with: ` + - `claude plugin install ${PLUGIN_REF}` - ) - ); - } - } else { - out.println(pc.dim("\n Updating Claude Code plugin...")); - - if ((await runClaude(["plugin", "update", PLUGIN_REF])) !== 0) { - out.println(pc.yellow(" Plugin update had issues")); - } + if ((await runClaude(["plugin", "update", PLUGIN_REF])) !== 0) { + out.println(pc.yellow(" Plugin update had issues")); } } + } - if (inClaudeCode) { - out.println(pc.dim("\n Restart Claude Code to apply the new plugin version.")); - } + if (inClaudeCode) { + out.println(pc.dim("\n Restart Claude Code to apply the new plugin version.")); } + } - // 4b. The genesis-tools MCP server (D4, round 2): registration stays manual, but a TTY - // gets asked once it notices the server is missing. - await offerGenesisToolsMcpRegistration(); - - // 5. Show latest changelog entry (full text only with --verbose; the version line in - // the short summary below already says when it moved). - const changelogPath = join(genesisPath, "CHANGELOG.md"); - let latestVersionHeading: string | null = null; - if (existsSync(changelogPath)) { - const changelog = readFileSync(changelogPath, "utf-8"); - const latestEntry = extractLatestEntry(changelog); - if (latestEntry) { - latestVersionHeading = - latestEntry - .split("\n")[0] - ?.replace(/^##\s*/, "") - .trim() ?? null; - - if (isVerbose()) { - out.println(pc.cyan("\n Latest changes:")); - out.println(pc.dim(` ${latestEntry.split("\n").join("\n ")}`)); - } + // 4b. The genesis-tools MCP server (D4, round 2): registration stays manual, but a TTY + // gets asked once it notices the server is missing. + await offerGenesisToolsMcpRegistration(); + + // 5. Show latest changelog entry (full text only with --verbose; the version line in + // the short summary below already says when it moved). + const changelogPath = join(genesisPath, "CHANGELOG.md"); + let latestVersionHeading: string | null = null; + if (existsSync(changelogPath)) { + const changelog = readFileSync(changelogPath, "utf-8"); + const latestEntry = extractLatestEntry(changelog); + if (latestEntry) { + latestVersionHeading = + latestEntry + .split("\n")[0] + ?.replace(/^##\s*/, "") + .trim() ?? null; + + if (isVerbose()) { + out.println(pc.cyan("\n Latest changes:")); + out.println(pc.dim(` ${latestEntry.split("\n").join("\n ")}`)); } } + } - // 6. Catalogue summary: only what changed since the last run (D4, round 2 — the full - // dump every run was unreadable noise). `--verbose` prints the full lists instead. - const tools = discoverTools(srcDir); - const skills = discoverSkills(join(genesisPath, "plugins/genesis-tools/skills")); - - out.println(pc.green("\n GenesisTools updated successfully!\n")); - - printUpdateCatalogue({ - current: { - version: latestVersionHeading, - tools: tools.map((t) => ({ name: t.name, description: t.description })), - skills: skills.map((s) => ({ name: s.name, description: s.description })), - }, - verbose: isVerbose(), - println: (line) => out.println(line), - }); + // 6. Catalogue summary: only what changed since the last run (D4, round 2 — the full + // dump every run was unreadable noise). `--verbose` prints the full lists instead. + const tools = discoverTools(srcDir); + const skills = discoverSkills(join(genesisPath, "plugins/genesis-tools/skills")); + + out.println(pc.green("\n GenesisTools updated successfully!\n")); - out.println(""); + printUpdateCatalogue({ + current: { + version: latestVersionHeading, + tools: tools.map((t) => ({ name: t.name, description: t.description })), + skills: skills.map((s) => ({ name: s.name, description: s.description })), + }, + verbose: isVerbose(), + println: (line) => out.println(line), }); + out.println(""); +} + /** * Install GenesisTools.app when it is missing, or re-sign it when its sources moved. * @@ -358,6 +438,16 @@ async function refreshGenesisApp(): Promise<void> { return; } + // buildApp() refuses this too, but only after the "Rebuilding" line: say it once and move on. + const toolchain = detectXcodeToolchain(); + + if (toolchain.kind !== "xcode") { + logger.info({ toolchain }, "update: GenesisTools.app rebuild skipped, no full Xcode"); + out.println(pc.yellow("\n Skipped rebuilding GenesisTools.app: it needs Xcode, not the Command Line Tools.")); + out.println(pc.dim(` ${genesisAppBuildHint(toolchain)}`)); + return; + } + out.println(pc.dim("\n Rebuilding GenesisTools.app (its sources changed in this update)...")); try { diff --git a/src/update/marketplace.test.ts b/src/update/marketplace.test.ts index 33f06c0535..47b25216c3 100644 --- a/src/update/marketplace.test.ts +++ b/src/update/marketplace.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { resolve } from "node:path"; -import { planMarketplaceAction } from "./marketplace"; +import { planMarketplaceAction, pluginStepDecision } from "./marketplace"; const CHECKOUT = "/Users/me/GenesisTools"; @@ -8,6 +8,21 @@ function plan(args: Parameters<typeof planMarketplaceAction>[0] extends infer T return planMarketplaceAction({ ...args, resolvePath: resolve }); } +describe("pluginStepDecision", () => { + it("never prompts without a terminal: it runs the default instead", () => { + expect(pluginStepDecision({ hasClaude: true, plugins: true, interactive: false })).toBe("run"); + }); + + it("asks on a terminal", () => { + expect(pluginStepDecision({ hasClaude: true, plugins: true, interactive: true })).toBe("ask"); + }); + + it("skips with --no-plugins or without a claude binary", () => { + expect(pluginStepDecision({ hasClaude: true, plugins: false, interactive: false })).toBe("skip"); + expect(pluginStepDecision({ hasClaude: false, plugins: true, interactive: true })).toBe("skip"); + }); +}); + describe("planMarketplaceAction", () => { it("registers the checkout when no marketplace exists yet", () => { expect(plan({ isCheckout: true, checkout: CHECKOUT, lookup: { status: "absent" } })).toEqual({ diff --git a/src/update/marketplace.ts b/src/update/marketplace.ts index b5c87d3783..b5f95a7e60 100644 --- a/src/update/marketplace.ts +++ b/src/update/marketplace.ts @@ -3,6 +3,26 @@ export const MARKETPLACE_NAME = "genesis-tools"; export const MARKETPLACE_FALLBACK_SOURCE = "https://github.com/genesiscz/GenesisTools"; export const PLUGIN_REF = "genesis-tools@genesis-tools"; +export type PluginStepDecision = "skip" | "run" | "ask"; + +/** + * Whether step 4 refreshes the Claude Code plugin, and whether it may ask first. + * + * Only a terminal can answer a prompt. Under an agent stdin is /dev/null, and the confirm used to + * wait there forever (arwen, 2026-10-25), so without a TTY the step takes the prompt's default (yes). + */ +export function pluginStepDecision(opts: { + hasClaude: boolean; + plugins: boolean; + interactive: boolean; +}): PluginStepDecision { + if (!opts.hasClaude || !opts.plugins) { + return "skip"; + } + + return opts.interactive ? "ask" : "run"; +} + export interface MarketplaceEntry { name?: string; source?: "directory" | "git" | "github"; diff --git a/src/update/recovery.test.ts b/src/update/recovery.test.ts index 8be908421f..bc26adcaf9 100644 --- a/src/update/recovery.test.ts +++ b/src/update/recovery.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { copyRepoTree } from "@genesiscz/utils/git/test-repo"; +import { pickNewestUnifiedBranch, postPullInvocation, readDevChannel, switchChannel } from "./channel"; import { git, recoverFromFailedPull, upstreamRef } from "./recovery"; const dirs: string[] = []; @@ -203,3 +204,96 @@ describe("recoverFromFailedPull", () => { expect(shown.output).toContain("my uncommitted edit"); }); }); + +describe("the steps after the pull run the pulled code", () => { + it("hands over through a variable that older code ignores, keeping --no-plugins", () => { + expect(postPullInvocation({ plugins: true })).toEqual({ + args: ["update"], + env: { GENESIS_TOOLS_UPDATE_POST_PULL: "1" }, + }); + expect(postPullInvocation({ plugins: false }).args).toEqual(["update", "--no-plugins"]); + }); +}); + +describe("tools update dev", () => { + it("picks the newest unified branch by the date in its name", () => { + expect( + pickNewestUnifiedBranch([ + "origin/master", + "origin/feat/2026-10-13-enhancements-unified", + "origin/feat/2026-09-01-enhancements-unified", + "origin/feat/2026-11-01-something-else", + ]) + ).toBe("feat/2026-10-13-enhancements-unified"); + expect(pickNewestUnifiedBranch(["origin/master"])).toBeUndefined(); + }); + + async function withUnifiedBranches(): Promise<{ local: string; statePath: string }> { + const { local } = await repoPair(); + const seed = join(local, "..", "seed"); + + for (const branch of ["feat/2026-09-01-enhancements-unified", "feat/2026-10-13-enhancements-unified"]) { + await git(["switch", "-c", branch, "master"], seed); + await writeFile(join(seed, "b.txt"), `${branch}\n`); + await git(["add", "."], seed); + await git(["commit", "-m", branch], seed); + await git(["push", "origin", branch], seed); + } + + return { local, statePath: join(await scratch(), "dev-channel.json") }; + } + + it("switches to the newest unified branch, and the next plain update returns to the old branch", async () => { + const { local, statePath } = await withUnifiedBranches(); + + expect(await switchChannel(local, "dev", statePath)).toBe("at-tip"); + expect((await git(["branch", "--show-current"], local)).output).toBe("feat/2026-10-13-enhancements-unified"); + expect(readDevChannel(statePath)?.returnTo).toBe("master"); + + expect(await switchChannel(local, "default", statePath)).toBe("pull"); + expect((await git(["branch", "--show-current"], local)).output).toBe("master"); + expect(readDevChannel(statePath)).toBeUndefined(); + + // Without the dev channel a plain update stays on its branch. + expect(await switchChannel(local, "default", statePath)).toBe("pull"); + expect((await git(["branch", "--show-current"], local)).output).toBe("master"); + }); + + it("a checkout already switched to a unified branch by hand returns to master", async () => { + const { local, statePath } = await withUnifiedBranches(); + await git( + [ + "switch", + "-c", + "feat/2026-10-13-enhancements-unified", + "--track", + "origin/feat/2026-10-13-enhancements-unified", + ], + local + ); + + expect(await switchChannel(local, "dev", statePath)).toBe("at-tip"); + expect(readDevChannel(statePath)?.returnTo).toBe("master"); + }); + + it("refuses to reset a local dev branch that has commits origin does not have", async () => { + const { local, statePath } = await withUnifiedBranches(); + expect(await switchChannel(local, "dev", statePath)).toBe("at-tip"); + await writeFile(join(local, "c.txt"), "local only\n"); + await git(["add", "."], local); + await git(["commit", "-m", "local only"], local); + const head = (await git(["rev-parse", "HEAD"], local)).output; + + expect(await switchChannel(local, "dev", statePath)).toBe("failed"); + expect((await git(["rev-parse", "HEAD"], local)).output).toBe(head); + }); + + it("refuses to switch over uncommitted tracked changes", async () => { + const { local, statePath } = await withUnifiedBranches(); + await writeFile(join(local, "a.txt"), "dirty\n"); + + expect(await switchChannel(local, "dev", statePath)).toBe("failed"); + expect((await git(["branch", "--show-current"], local)).output).toBe("master"); + expect(readDevChannel(statePath)).toBeUndefined(); + }); +}); diff --git a/src/utils/agent-sessions/jsonl-fold.ts b/src/utils/agent-sessions/jsonl-fold.ts index 71b1eeddac..41221be5a9 100644 --- a/src/utils/agent-sessions/jsonl-fold.ts +++ b/src/utils/agent-sessions/jsonl-fold.ts @@ -1,6 +1,18 @@ -import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs"; +import { + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + type Stats, + statSync, +} from "node:fs"; import { dirname } from "node:path"; import { readPrefixMark } from "@genesiscz/utils/ai/transcripts/file-scan"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readLinesSync } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { atomicWriteFileSync } from "@genesiscz/utils/storage/storage"; @@ -134,6 +146,10 @@ export interface FoldJsonlOptions<S> { * Returns null when the file cannot be read (the issue is reported: "Source missing" / "Source read failed"). */ export function foldJsonlResumable<S>(options: FoldJsonlOptions<S>): S | null { + if (isZstdPath(options.path)) { + return foldCompressed(options); + } + let fd: number | null = null; try { fd = openSync(options.path, "r"); @@ -169,24 +185,8 @@ export function foldJsonlResumable<S>(options: FoldJsonlOptions<S>): S | null { options.onIssue(message); } - const fold1 = (text: string, line: number, partial: boolean, into: S, issues: string[] | null): void => { - const original = text.replace(/\r$/, ""); - if (!original.trim()) { - return; - } - - let value: JsonValue; - try { - value = SafeJSON.parse(original, { strict: true }) as JsonValue; - } catch { - const message = `${partial ? "Partial final record" : "Malformed record"} at line ${line}`; - issues?.push(message); - options.onIssue(message); - return; - } - - options.apply(into, asRecord(value)); - }; + const fold1 = (text: string, line: number, partial: boolean, into: S, issues: string[] | null): void => + foldLine(options, { text, line, partial, into, issues }); // Complete lines, in chunks, cut at newline bytes. let position = fold.consumed; @@ -251,3 +251,120 @@ export function foldJsonlResumable<S>(options: FoldJsonlOptions<S>): S | null { closeSync(fd); } } + +/** Folds one JSONL line into `into`; a line that does not parse is an issue ("Malformed record at line N"). */ +function foldLine<S>( + options: FoldJsonlOptions<S>, + input: { text: string; line: number; partial: boolean; into: S; issues: string[] | null } +): void { + const original = input.text.replace(/\r$/, ""); + if (!original.trim()) { + return; + } + + let value: JsonValue; + try { + value = SafeJSON.parse(original, { strict: true }) as JsonValue; + } catch { + const message = `${input.partial ? "Partial final record" : "Malformed record"} at line ${input.line}`; + input.issues?.push(message); + options.onIssue(message); + return; + } + + options.apply(input.into, asRecord(value)); +} + +/** + * A `.jsonl.zst` (Codex compresses its old rollouts) never grows, so it is folded whole once and kept by inode, size + * and mtime. It is persisted from 2 MB compressed, about the 8 MB of text a plain file needs (Codex rollouts shrink + * about 3.7 times). + */ +function foldCompressed<S>(options: FoldJsonlOptions<S>): S | null { + let status: Stats; + try { + status = statSync(options.path); + } catch (error) { + const missing = error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT"; + options.onIssue(missing ? "Source missing" : "Source read failed"); + return null; + } + + const mark = `zst:${status.size}:${status.mtimeMs}`; + const resumable = status.size >= (options.resumeMinBytes ?? RESUME_MIN_BYTES) / 4; + const store: FoldStore = resumable ? readStore(options.storePath) : { version: 1, files: {} }; + const kept = [memoryFolds.get(options.path), store.files[options.path]].find( + (candidate): candidate is StoredFold<S> => + validFold(candidate, options.validState) && candidate.ino === status.ino && candidate.mark === mark + ); + + if (kept) { + for (const message of kept.issues) { + options.onIssue(message); + } + + return options.copy(kept.state); + } + + const fold: StoredFold<S> = { + ino: status.ino, + consumed: status.size, + line: 0, + mark, + issues: [], + state: options.initial(), + }; + + try { + // One line behind, so the last one is known: an unfinished last line is a partial record, as in a plain file. + let previous: string | undefined; + for (const text of readLinesSync(options.path)) { + options.signal?.throwIfAborted(); + if (previous !== undefined) { + fold.line++; + foldLine(options, { + text: previous, + line: fold.line, + partial: false, + into: fold.state, + issues: fold.issues, + }); + } + + previous = text; + } + + if (previous) { + fold.line++; + foldLine(options, { + text: previous, + line: fold.line, + partial: true, + into: fold.state, + issues: fold.issues, + }); + } + } catch (error) { + options.signal?.throwIfAborted(); + logger.debug({ error, path: options.path }, "[agent-sessions] compressed fold failed"); + options.onIssue("Source read failed"); + return null; + } + + memoryFolds.delete(options.path); + memoryFolds.set(options.path, { ...fold, issues: [...fold.issues], state: options.copy(fold.state) }); + if (memoryFolds.size > MEMORY_FILES) { + const oldest = memoryFolds.keys().next().value; + if (oldest !== undefined) { + memoryFolds.delete(oldest); + } + } + + if (resumable) { + delete store.files[options.path]; + store.files[options.path] = { ...fold, state: options.copy(fold.state) } as StoredFold<unknown>; + writeStore(options.storePath, store); + } + + return options.copy(fold.state); +} diff --git a/src/utils/agent-sessions/provider-extensibility.test.ts b/src/utils/agent-sessions/provider-extensibility.test.ts index f6aef7a926..1e6b1d1894 100644 --- a/src/utils/agent-sessions/provider-extensibility.test.ts +++ b/src/utils/agent-sessions/provider-extensibility.test.ts @@ -13,6 +13,7 @@ import { createNativeHistoryAdapter } from "./native-adapter"; import { openHistoryService } from "./open-service"; import { createClaudeHistoryOperations } from "./readers/claude"; import { discoverClaudeHistorySources } from "./readers/claude-discovery"; +import { noBackwardRead } from "./testing/no-backward-read"; import type { NativeSessionSource } from "./types"; afterEach(() => { @@ -46,6 +47,7 @@ test("a registered fourth provider supports listing, content, detail and unavail codingAgent: { kind: "fixture", parserVersion: "1", + readBackward: noBackwardRead, roots: () => [root], discover: async (roots, options) => { const discovered = await discoverClaudeHistorySources(roots, options); diff --git a/src/utils/agent-sessions/provider.test.ts b/src/utils/agent-sessions/provider.test.ts index 96e349d0c9..a8d273c983 100644 --- a/src/utils/agent-sessions/provider.test.ts +++ b/src/utils/agent-sessions/provider.test.ts @@ -17,6 +17,7 @@ import { sessionMessageTextHash, } from "./message-queue"; import { resolveHistoryProvider } from "./provider"; +import { noBackwardRead } from "./testing/no-backward-read"; afterEach(() => { _resetPluginsForTest(); @@ -35,6 +36,7 @@ test("a registered fourth history provider can be resolved without an account al codingAgent: { kind: "fixture", parserVersion: "1", + readBackward: noBackwardRead, roots: () => [], async discover() { return { sources: [], issues: [], completeRoots: [] }; diff --git a/src/utils/agent-sessions/read-backward.ts b/src/utils/agent-sessions/read-backward.ts new file mode 100644 index 0000000000..8b709c6c3e --- /dev/null +++ b/src/utils/agent-sessions/read-backward.ts @@ -0,0 +1,69 @@ +import { readLinesBackwardSync } from "@genesiscz/utils/fs/read-lines"; +import { SafeJSON } from "@genesiscz/utils/json"; +import type { JsonValue } from "./source-scan"; +import type { BackwardRead, NativeHistoryEntry, NativeSourceIssue, ReadBackwardOptions } from "./types"; + +/** How far back a `readBackward` reads by default: the newest 16 MB of a session. */ +export const READ_BACKWARD_MAX_BYTES = 16 * 1024 * 1024; + +/** + * The shared half of every provider's `readBackward`: one JSONL file read from its end, each row turned into entries + * by the provider's own `rowEntries`, until an entry `until` accepts. The provider owns the format; this owns the walk. + * A row that does not parse (a line still being written) is skipped and reported. + */ +export function readJsonlBackward( + options: ReadBackwardOptions & { + path: string; + rowEntries: (row: JsonValue, line: number) => NativeHistoryEntry[]; + } +): BackwardRead { + const limit = options.maxBytes ?? READ_BACKWARD_MAX_BYTES; + const rows: NativeHistoryEntry[][] = []; + const issues: NativeSourceIssue[] = []; + let bytes = 0; + let line = 0; + + for (const text of readLinesBackwardSync(options.path)) { + line--; + bytes += Buffer.byteLength(text) + 1; + + if (options.signal?.aborted || bytes > limit) { + break; + } + + if (!text.trim()) { + continue; + } + + let row: JsonValue; + + try { + row = SafeJSON.parse(text, { strict: true }) as JsonValue; + } catch { + issues.push({ path: options.path, message: `Unreadable row at line ${line} from the end` }); + continue; + } + + const entries = options.rowEntries(row, line); + // A loop, not `findLastIndex`: the dashboards compile src/utils with a `lib` older than ES2023. + let stop = -1; + + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]; + + if (entry && options.until(entry)) { + stop = index; + break; + } + } + + if (stop >= 0) { + rows.push(entries.slice(stop)); + return { entries: rows.reverse().flat(), found: true, issues }; + } + + rows.push(entries); + } + + return { entries: rows.reverse().flat(), found: false, issues }; +} diff --git a/src/utils/agent-sessions/reader-compat.ts b/src/utils/agent-sessions/reader-compat.ts index 1ab59977ef..34af5d027c 100644 --- a/src/utils/agent-sessions/reader-compat.ts +++ b/src/utils/agent-sessions/reader-compat.ts @@ -1,6 +1,7 @@ import { stat } from "node:fs/promises"; import { basename } from "node:path"; import { PROVIDER_ALIASES } from "@genesiscz/utils/ai/providers/aliases"; +import { transcriptStem } from "@genesiscz/utils/providers/session-paths"; import { sourceFingerprint } from "./fingerprint"; import { historySourceKey } from "./identity"; import type { @@ -56,7 +57,7 @@ function metadataSession<Kind extends AgentKind>(options: { }): AgentSession<Kind> { const { kind, source, metadata, entries, records, fileMtime } = options; const sourceMetadata = source.metadata ?? {}; - const fallbackId = basename(source.filePath, ".jsonl"); + const fallbackId = transcriptStem(basename(source.filePath)); const isSubagent = metadata?.isSubagent ?? sourceMetadata.isSubagent ?? false; const sessionId = kind === "claude" && isSubagent diff --git a/src/utils/agent-sessions/readers/claude.test.ts b/src/utils/agent-sessions/readers/claude.test.ts index 2ac869489f..ab81ca9922 100644 --- a/src/utils/agent-sessions/readers/claude.test.ts +++ b/src/utils/agent-sessions/readers/claude.test.ts @@ -305,6 +305,44 @@ test("large metadata reads preserve head and tail fields with a bounded search c expect(result.metadata?.summary).toBe("Latest tail summary"); }); +test("Claude Code's own session name (ai-title) is the title until a /rename, which still wins", async () => { + const { path, source } = fixture(); + const prompt = { + type: "user", + sessionId: MAIN_ID, + timestamp: "2026-09-01T11:00:00.000Z", + message: { content: "pls research whether we are affected" }, + }; + writeFileSync( + path, + jsonl( + prompt, + { type: "ai-title", aiTitle: "Early guess", sessionId: MAIN_ID }, + { type: "ai-title", aiTitle: "Hotfix research", sessionId: MAIN_ID } + ) + ); + + const named = await readClaudeMetadata(source); + + expect(named.metadata?.summary).toBe("Hotfix research"); + expect(named.metadata?.customTitle).toBeNull(); + expect(named.metadata?.firstPrompt).toBe("pls research whether we are affected"); + + writeFileSync( + path, + jsonl( + prompt, + { type: "ai-title", aiTitle: "Hotfix research", sessionId: MAIN_ID }, + { type: "custom-title", customTitle: "My own name", sessionId: MAIN_ID } + ) + ); + + const renamed = await readClaudeMetadata(source); + + expect(renamed.metadata?.customTitle).toBe("My own name"); + expect(renamed.metadata?.summary).toBe("Hotfix research"); +}); + test("compact operations factory binds metadata, scan, and selected-record reads", async () => { const { path, source } = fixture(); writeFileSync( @@ -498,3 +536,42 @@ test("an unparsable timestamp never becomes the session date", async () => { expect(result.metadata?.firstTimestamp).toBe("2026-09-01T10:00:00.000Z"); expect(result.metadata?.lastTimestamp).toBe("2026-09-01T10:00:00.000Z"); }); + +test("readBackward walks from the end back to the entry `until` accepts, and returns it in order", async () => { + const { path, source } = fixture(); + writeFileSync( + path, + jsonl( + { type: "user", sessionId: MAIN_ID, timestamp: "2026-09-01T11:00:00.000Z", message: { content: "start" } }, + { + type: "assistant", + sessionId: MAIN_ID, + timestamp: "2026-09-01T11:01:00.000Z", + message: { content: [{ type: "text", text: "❓ DECISION 1: Keep it?\n- a) keep\n- b) drop" }] }, + }, + { type: "user", sessionId: MAIN_ID, timestamp: "2026-09-01T11:02:00.000Z", message: { content: "D1 b)" } }, + { + type: "assistant", + sessionId: MAIN_ID, + timestamp: "2026-09-01T11:03:00.000Z", + message: { content: [{ type: "text", text: "Dropping it." }] }, + } + ) + ); + + const read = await createClaudeHistoryOperations().readBackward(source, { + until: (entry) => entry.text.includes("DECISION 1"), + }); + + expect(read.found).toBe(true); + expect(read.entries.map((entry) => [entry.role, entry.text.split("\n")[0], entry.line])).toEqual([ + ["assistant", "❓ DECISION 1: Keep it?", -3], + ["user", "D1 b)", -2], + ["assistant", "Dropping it.", -1], + ]); + + const none = await createClaudeHistoryOperations().readBackward(source, { until: () => false }); + + expect(none.found).toBe(false); + expect(none.entries.map((entry) => entry.text)[0]).toBe("start"); +}); diff --git a/src/utils/agent-sessions/readers/claude.ts b/src/utils/agent-sessions/readers/claude.ts index c42af7c93a..fed4926b9e 100644 --- a/src/utils/agent-sessions/readers/claude.ts +++ b/src/utils/agent-sessions/readers/claude.ts @@ -3,6 +3,7 @@ import { stat } from "node:fs/promises"; import { basename, sep } from "node:path"; import { createInterface } from "node:readline"; import { flattenToolInput } from "@genesiscz/utils/agent-sessions/native-content"; +import { readJsonlBackward } from "@genesiscz/utils/agent-sessions/read-backward"; import type { JsonRecord, JsonValue } from "@genesiscz/utils/agent-sessions/source-scan"; import { asRecord, blocksMetadata, scanJsonlRecords } from "@genesiscz/utils/agent-sessions/source-scan"; import { isWrapperUserText } from "@genesiscz/utils/agent-sessions/user-text"; @@ -10,6 +11,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import { profiler } from "@genesiscz/utils/profile"; import { boundHistoryText, HISTORY_METADATA_LIMITS } from "../metadata"; import type { + BackwardRead, BoundedMetadataField, HistoryMetadataRead, HistoryReadOptions, @@ -19,6 +21,7 @@ import type { NativeSessionReader, NativeSessionSource, NativeSourceIssue, + ReadBackwardOptions, } from "../types"; import { claudeNativeId, claudeProjectDirectory, claudeProjectName, isClaudeSubagentPath } from "./claude-paths"; @@ -218,6 +221,16 @@ function contentEntries(options: { return entries; } +/** + * The name Claude Code gave the session itself: an `ai-title` line (the name its /resume list and the terminal tab + * show), or the `summary` line older versions wrote. Both are stored as the summary, the newest line winning; a + * `/rename` (`custom-title`) still comes first. Without `ai-title` the widget listed "Kalendora hotfix research" under + * its first prompt, and Martin could not find it (2026-10-24). + */ +function autoTitleOf(row: JsonRecord): string { + return row.type === "ai-title" ? asText(row.aiTitle) : row.type === "summary" ? asText(row.summary) : ""; +} + function claudeRecordContext(row: JsonRecord): Pick<HistorySourceRecord, "timestamp" | "role" | "metadataChanges"> { const timestamp = asText(row.timestamp) || undefined; const role = @@ -228,7 +241,7 @@ function claudeRecordContext(row: JsonRecord): Pick<HistorySourceRecord, "timest : undefined; const sessionId = asText(row.sessionId); const customTitle = row.type === "custom-title" ? asText(row.customTitle) : ""; - const summary = row.type === "summary" ? asText(row.summary) : ""; + const summary = autoTitleOf(row); const gitBranch = asText(row.gitBranch); const cwd = asText(row.cwd); const metadataChanges = { @@ -454,8 +467,8 @@ async function readClaudeMetadataUncounted( function apply(row: JsonRecord): void { validRecords++; - if (row.type === "summary" && asText(row.summary)) { - summary = asText(row.summary); + if (autoTitleOf(row)) { + summary = autoTitleOf(row); } if (row.type === "custom-title" && asText(row.customTitle)) { customTitle = asText(row.customTitle); @@ -598,13 +611,31 @@ async function readClaudeMetadataUncounted( } export type ClaudeHistoryOperations = Pick<NativeSessionReader<"claude">, "parserVersion"> & - Required<Pick<NativeSessionReader<"claude">, "readMetadata" | "scan" | "readRecords">>; + Required<Pick<NativeSessionReader<"claude">, "readMetadata" | "scan" | "readRecords" | "readBackward">>; + +/** + * The session JSONL from its end. A tool result's name comes from its call, an earlier row, so walking back from a + * result leaves the name out until the call is reached; message text is whole. + */ +async function readClaudeBackward( + source: NativeSessionSource<"claude">, + options: ReadBackwardOptions +): Promise<BackwardRead> { + const tools: ToolContext = { names: new Map(), paths: new Map() }; + + return readJsonlBackward({ + ...options, + path: source.filePath, + rowEntries: (row, line) => recordEntries(asRecord(row), line, tools), + }); +} export function createClaudeHistoryOperations(): ClaudeHistoryOperations { return { - parserVersion: "5", + parserVersion: "6", readMetadata: readClaudeMetadata, scan: scanClaudeRecords, readRecords: readClaudeRecords, + readBackward: readClaudeBackward, }; } diff --git a/src/utils/agent-sessions/readers/codex-discovery.ts b/src/utils/agent-sessions/readers/codex-discovery.ts index 4c33ee9a2c..dae623c1f1 100644 --- a/src/utils/agent-sessions/readers/codex-discovery.ts +++ b/src/utils/agent-sessions/readers/codex-discovery.ts @@ -1,12 +1,14 @@ import { Database } from "bun:sqlite"; import { readdir, readFile, realpath } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; import { FileIdentityCache, fileIdentity } from "@genesiscz/utils/fs/identity-cache"; import { type EdgeLines, readFirstLines } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { isObject } from "@genesiscz/utils/object"; import { profiler } from "@genesiscz/utils/profile"; +import { isNativeTranscript } from "@genesiscz/utils/providers/session-paths"; import { toolDataDir } from "@genesiscz/utils/storage/root"; import { type HistoryDiscoveryOptions, walkSourceRoots } from "../source-discovery"; import { asRecord, type JsonRecord, type JsonValue, scanJsonlRecords, text } from "../source-scan"; @@ -378,7 +380,7 @@ export async function discoverCodexHistorySources( filtered, filePaths: options.filePaths, signal: options.signal, - includeFile: ({ path }) => basename(path).startsWith("rollout-") && path.endsWith(".jsonl"), + includeFile: ({ path }) => basename(path).startsWith("rollout-") && isNativeTranscript("codex", basename(path)), }); const issues = [...walked.issues]; const incompleteRoots = new Set<string>(); @@ -449,7 +451,8 @@ export async function discoverCodexHistorySources( filePath: file.path, dataPaths: [file.path], metadataPaths, - ...(header.historyMode === "paginated" ? {} : { searchPaths: [file.path] }), + // rg reads raw bytes, so a compressed rollout gets no rg prefilter and is searched by the reader. + ...(header.historyMode === "paginated" || isZstdPath(file.path) ? {} : { searchPaths: [file.path] }), metadata, }; let projection: string | undefined; diff --git a/src/utils/agent-sessions/readers/codex.test.ts b/src/utils/agent-sessions/readers/codex.test.ts index ad8dc816c7..88d3e3f286 100644 --- a/src/utils/agent-sessions/readers/codex.test.ts +++ b/src/utils/agent-sessions/readers/codex.test.ts @@ -1315,3 +1315,29 @@ test.each([true, false, "partial"] as const)( } } ); + +test("readBackward reads the rollout from its end back to the entry `until` accepts", async () => { + const message = (role: "user" | "assistant", text: string) => ({ + type: "response_item", + payload: { + type: "message", + role, + content: [{ type: role === "user" ? "input_text" : "output_text", text }], + }, + }); + const source = legacyFixture([ + { type: "session_meta", payload: { id: CHILD_ID, cwd: "/projects/child", history_mode: "legacy" } }, + message("assistant", "❓ DECISION 2: Ship it?"), + message("user", "D2 a)"), + ]); + + const read = await createCodexHistoryOperations().readBackward(source, { + until: (entry) => entry.text.includes("DECISION 2"), + }); + + expect(read.found).toBe(true); + expect(read.entries.map((entry) => [entry.role, entry.text])).toEqual([ + ["assistant", "❓ DECISION 2: Ship it?"], + ["user", "D2 a)"], + ]); +}); diff --git a/src/utils/agent-sessions/readers/codex.ts b/src/utils/agent-sessions/readers/codex.ts index bf0529a720..67d606c869 100644 --- a/src/utils/agent-sessions/readers/codex.ts +++ b/src/utils/agent-sessions/readers/codex.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { stat } from "node:fs/promises"; import { sep } from "node:path"; import { flattenToolInput } from "@genesiscz/utils/agent-sessions/native-content"; +import { readJsonlBackward } from "@genesiscz/utils/agent-sessions/read-backward"; import type { JsonRecord, JsonValue } from "@genesiscz/utils/agent-sessions/source-scan"; import { asRecord, blocksMetadata, scanJsonlRecords } from "@genesiscz/utils/agent-sessions/source-scan"; import { isWrapperUserText } from "@genesiscz/utils/agent-sessions/user-text"; @@ -13,6 +14,7 @@ import { genesisToolsDir } from "@genesiscz/utils/storage/root"; import { foldJsonlResumable } from "../jsonl-fold"; import { boundHistoryText, HISTORY_METADATA_LIMITS } from "../metadata"; import type { + BackwardRead, BoundedMetadataField, HistoryMetadataRead, HistoryReadOptions, @@ -22,6 +24,7 @@ import type { NativeSessionReader, NativeSessionSource, NativeSourceIssue, + ReadBackwardOptions, } from "../types"; import { type CodexProjectionIndex, @@ -1347,7 +1350,25 @@ async function readCodexMetadataUncounted( } export type CodexHistoryOperations = Pick<NativeSessionReader<"codex">, "parserVersion"> & - Required<Pick<NativeSessionReader<"codex">, "readMetadata" | "scan" | "readRecords">>; + Required<Pick<NativeSessionReader<"codex">, "readMetadata" | "scan" | "readRecords" | "readBackward">>; + +/** + * The rollout JSONL from its end. It holds every response item in both history modes (a paginated thread's projection + * database is a second copy), so the backward walk needs no projection; a tool result's name comes from its call, an + * earlier row, so walking back from a result leaves the name out until the call is reached. + */ +async function readCodexBackward( + source: NativeSessionSource<"codex">, + options: ReadBackwardOptions +): Promise<BackwardRead> { + const tools: ToolContext = { names: new Map(), paths: new Map() }; + + return readJsonlBackward({ + ...options, + path: source.filePath, + rowEntries: (row, line) => legacyEntries(asRecord(row), line, tools), + }); +} export function createCodexHistoryOperations(): CodexHistoryOperations { return { @@ -1355,5 +1376,6 @@ export function createCodexHistoryOperations(): CodexHistoryOperations { readMetadata: readCodexMetadata, scan: scanCodexRecords, readRecords: readCodexRecords, + readBackward: readCodexBackward, }; } diff --git a/src/utils/agent-sessions/readers/grok.test.ts b/src/utils/agent-sessions/readers/grok.test.ts index 2cbde1ceba..5dcf711bc7 100644 --- a/src/utils/agent-sessions/readers/grok.test.ts +++ b/src/utils/agent-sessions/readers/grok.test.ts @@ -732,3 +732,35 @@ test("user-query extraction pulls the tagged query and skips harness info blobs" }) ).toEqual([]); }); + +test("readBackward reads the chat history from its end, bounded by content since Grok rows have no time", async () => { + const home = mkdtempSync(join(tmpdir(), "gt-grok-backward-")); + const root = join(home, "sessions"); + const directory = join(root, encodeURIComponent(CWD), SESSION_ID); + mkdirSync(directory, { recursive: true }); + const chatPath = join(directory, "chat_history.jsonl"); + writeFileSync( + chatPath, + [ + line({ type: "user", content: [{ type: "text", text: "<user_query>start</user_query>" }] }), + line({ type: "assistant", content: [{ type: "text", text: "❓ DECISION 3: Which one?" }] }), + line({ type: "user", content: [{ type: "text", text: "<user_query>3 b)</user_query>" }] }), + ].join("") + ); + const source: NativeSessionSource<"grok"> = { + kind: "grok", + root, + sourceHome: home, + filePath: chatPath, + dataPaths: [chatPath], + metadataPaths: [], + }; + + const read = await createGrokHistoryOperations().readBackward(source, { + until: (entry) => entry.text.includes("DECISION 3"), + }); + + expect(read.found).toBe(true); + expect(read.entries.map((entry) => entry.role)).toEqual(["assistant", "user"]); + expect(read.entries[1]?.text).toContain("3 b)"); +}); diff --git a/src/utils/agent-sessions/readers/grok.ts b/src/utils/agent-sessions/readers/grok.ts index 01112b0cb8..e36eb6162f 100644 --- a/src/utils/agent-sessions/readers/grok.ts +++ b/src/utils/agent-sessions/readers/grok.ts @@ -2,6 +2,7 @@ import { stat } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { boundHistoryText, HISTORY_METADATA_LIMITS } from "@genesiscz/utils/agent-sessions/metadata"; import { flattenToolInput } from "@genesiscz/utils/agent-sessions/native-content"; +import { readJsonlBackward } from "@genesiscz/utils/agent-sessions/read-backward"; import type { JsonRecord, JsonValue } from "@genesiscz/utils/agent-sessions/source-scan"; import { asRecord, blocksMetadata, scanJsonlRecords } from "@genesiscz/utils/agent-sessions/source-scan"; import { isWrapperUserText } from "@genesiscz/utils/agent-sessions/user-text"; @@ -9,6 +10,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { profiler } from "@genesiscz/utils/profile"; import type { + BackwardRead, BoundedMetadataField, HistoryMetadataRead, HistoryReadOptions, @@ -18,6 +20,7 @@ import type { NativeSessionReader, NativeSessionSource, NativeSourceIssue, + ReadBackwardOptions, } from "../types"; const USER_QUERY_RE = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/i; @@ -706,7 +709,30 @@ async function readGrokMetadataUncounted( } export type GrokHistoryOperations = Pick<NativeSessionReader<"grok">, "parserVersion"> & - Required<Pick<NativeSessionReader<"grok">, "readMetadata" | "scan" | "readRecords">>; + Required<Pick<NativeSessionReader<"grok">, "readMetadata" | "scan" | "readRecords" | "readBackward">>; + +/** + * The chat history JSONL from its end. Its rows carry no timestamps, so a caller bounds the walk by content (a + * decision's ask), never by time; a session without a chat file reads as nothing, with an issue. + */ +async function readGrokBackward( + source: NativeSessionSource<"grok">, + options: ReadBackwardOptions +): Promise<BackwardRead> { + const chat = preferredChatPath(source); + + if (!chat) { + return { entries: [], found: false, issues: [{ path: source.filePath, message: "Chat source missing" }] }; + } + + const tools: ToolContext = { names: new Map(), paths: new Map() }; + + return readJsonlBackward({ + ...options, + path: chat, + rowEntries: (row, line) => recordEntries(asRecord(row), line, tools), + }); +} export function createGrokHistoryOperations(): GrokHistoryOperations { return { @@ -717,5 +743,6 @@ export function createGrokHistoryOperations(): GrokHistoryOperations { readMetadata: readGrokMetadata, scan: scanGrokRecords, readRecords: readGrokRecords, + readBackward: readGrokBackward, }; } diff --git a/src/utils/agent-sessions/search-source.test.ts b/src/utils/agent-sessions/search-source.test.ts index 3173bda53b..89c9e5c55a 100644 --- a/src/utils/agent-sessions/search-source.test.ts +++ b/src/utils/agent-sessions/search-source.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import type { CachedHistoryMetadata } from "./repository"; import { searchHistorySource } from "./search-source"; +import { noBackwardRead } from "./testing/no-backward-read"; import type { HistorySourceRecord, NativeSessionReader, NativeSessionSource } from "./types"; const START = "2026-09-01T10:00:00.000Z"; @@ -88,6 +89,7 @@ function reader(records: HistorySourceRecord[]): NativeSessionReader<string> { return { kind: "fixture", parserVersion: "fixture-v1", + readBackward: noBackwardRead, roots: () => [SOURCE.root], discover: async () => ({ sources: [SOURCE], issues: [], completeRoots: [SOURCE.root] }), read: async () => { diff --git a/src/utils/agent-sessions/service-regressions.test.ts b/src/utils/agent-sessions/service-regressions.test.ts index 83180c1ff6..4305190b2c 100644 --- a/src/utils/agent-sessions/service-regressions.test.ts +++ b/src/utils/agent-sessions/service-regressions.test.ts @@ -8,6 +8,7 @@ import { codexHistoryReader, grokHistoryReader } from "./compact-readers"; import { initializeCompactHistorySchema } from "./migrations"; import { HistoryService } from "./service"; import { HistorySyncRepository } from "./sync-repository"; +import { noBackwardRead } from "./testing/no-backward-read"; import type { HistoryMetadataRecord, HistorySourceRecord, NativeSessionReader, NativeSessionSource } from "./types"; interface FixtureSource { @@ -124,6 +125,7 @@ function fixtureReader(options: { return { kind: "fixture", parserVersion: "fixture-v1", + readBackward: noBackwardRead, roots: () => [...new Set(options.fixtures.map((fixture) => fixture.source.root))], discover: async () => ({ sources: options.fixtures.map((fixture) => fixture.source), diff --git a/src/utils/agent-sessions/session-backward.ts b/src/utils/agent-sessions/session-backward.ts new file mode 100644 index 0000000000..6fc94b6954 --- /dev/null +++ b/src/utils/agent-sessions/session-backward.ts @@ -0,0 +1,33 @@ +import { resolveHistoryProvider } from "./provider"; +import type { BackwardRead, ReadBackwardOptions } from "./types"; + +/** + * A session's newest entries, from the transcript a hook names (`transcript_path`), through its provider's reader + * (`NativeSessionReader.readBackward`): the door a hook or a tool uses to read a conversation from its end. `kind` is + * the harness (`claude`, `codex`, `grok`) or a provider id; the provider finds the session source for the file. + */ +export async function readSessionBackward({ + kind, + transcriptPath, + ...options +}: ReadBackwardOptions & { kind: string; transcriptPath: string }): Promise<BackwardRead> { + const { reader } = resolveHistoryProvider(kind); + const found = await reader.discover(reader.roots(), { filePaths: [transcriptPath], signal: options.signal }); + const source = + found.sources.find( + (candidate) => candidate.filePath === transcriptPath || candidate.dataPaths.includes(transcriptPath) + ) ?? found.sources[0]; + + if (!source) { + return { + entries: [], + found: false, + issues: [ + ...found.issues, + { path: transcriptPath, message: `No ${kind} session source for this transcript` }, + ], + }; + } + + return reader.readBackward(source, options); +} diff --git a/src/utils/agent-sessions/source-scan.test.ts b/src/utils/agent-sessions/source-scan.test.ts index 869e32c31c..96c373c631 100644 --- a/src/utils/agent-sessions/source-scan.test.ts +++ b/src/utils/agent-sessions/source-scan.test.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import { foldJsonlResumable } from "./jsonl-fold"; import { scanJsonlRecords } from "./source-scan"; @@ -295,3 +296,21 @@ test("malformed fold stores and entries are cache misses, never source failures" expect(resumedFold(uncachedPath(), storePath)).toEqual(expected); } }); + +test("a .jsonl.zst rollout scans and folds like its plain form, and a second fold is served from the kept fold", async () => { + const root = mkdtempSync(join(tmpdir(), "gt-jsonl-zst-")); + const content = '{"n":1}\n\n{broken\n{"n":2,"text":"žluťoučký"}\r\n{"n":3,"t":"unfinished'; + const plain = join(root, "rollout.jsonl"); + const compressed = join(root, "rollout.jsonl.zst"); + writeFileSync(plain, content); + writeFileSync(compressed, compressBytes(content)); + + const expected = await fullScan(plain); + expect(expected.rows).toHaveLength(2); + expect(await fullScan(compressed)).toEqual(expected); + + const storePath = join(root, "store.json"); + expect(resumedFold(compressed, storePath)).toEqual(expected); + // The second one comes from the kept fold: a compressed rollout never grows. + expect(resumedFold(compressed, storePath)).toEqual(expected); +}); diff --git a/src/utils/agent-sessions/source-scan.ts b/src/utils/agent-sessions/source-scan.ts index c1168b42ab..f0380416bf 100644 --- a/src/utils/agent-sessions/source-scan.ts +++ b/src/utils/agent-sessions/source-scan.ts @@ -1,3 +1,4 @@ +import { readTextChunks } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; import type { NativeSourceIssue } from "./types"; @@ -60,8 +61,8 @@ function isMissing(error: unknown): boolean { ); } +/** The records of a JSONL file, plain or `.jsonl.zst` (Codex compresses its old rollouts), one parsed line at a time. */ export async function* scanJsonlRecords(options: ScanJsonlOptions): AsyncGenerator<ScannedJsonlRecord> { - const reader = Bun.file(options.path).stream().getReader(); const decoder = new TextDecoder(); let pending = ""; let line = 0; @@ -84,13 +85,9 @@ export async function* scanJsonlRecords(options: ScanJsonlOptions): AsyncGenerat } try { - while (true) { + for await (const chunk of readTextChunks(options.path)) { options.signal?.throwIfAborted(); - const chunk = await reader.read(); - if (chunk.done) { - break; - } - pending += decoder.decode(chunk.value, { stream: true }); + pending += decoder.decode(chunk, { stream: true }); let newline = pending.indexOf("\n"); while (newline >= 0) { const original = pending.slice(0, newline).replace(/\r$/, ""); @@ -116,7 +113,5 @@ export async function* scanJsonlRecords(options: ScanJsonlOptions): AsyncGenerat } catch (error) { options.signal?.throwIfAborted(); report(options, isMissing(error) ? "Source missing" : "Source read failed"); - } finally { - await reader.cancel(); } } diff --git a/src/utils/agent-sessions/sync-optimistic.test.ts b/src/utils/agent-sessions/sync-optimistic.test.ts index f3fc98f18d..237ff5abc6 100644 --- a/src/utils/agent-sessions/sync-optimistic.test.ts +++ b/src/utils/agent-sessions/sync-optimistic.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { initializeCompactHistorySchema } from "./migrations"; import { synchronizeHistory } from "./sync"; import { HistorySyncRepository } from "./sync-repository"; +import { noBackwardRead } from "./testing/no-backward-read"; import type { HistoryMetadataRead, NativeSessionReader, NativeSessionSource, NativeSourceIssue } from "./types"; const PROVIDER = "fixture-provider"; @@ -35,6 +36,7 @@ function fixture() { const reader: NativeSessionReader<string> = { kind: "fixture", parserVersion: "fixture-v1", + readBackward: noBackwardRead, roots: () => [root], discover: async () => { state.discoveryCount++; diff --git a/src/utils/agent-sessions/sync.test.ts b/src/utils/agent-sessions/sync.test.ts index ceef2670da..69e42b9b44 100644 --- a/src/utils/agent-sessions/sync.test.ts +++ b/src/utils/agent-sessions/sync.test.ts @@ -7,6 +7,7 @@ import { historySourceKey } from "./identity"; import { initializeCompactHistorySchema } from "./migrations"; import { synchronizeHistory } from "./sync"; import { HistorySyncRepository } from "./sync-repository"; +import { noBackwardRead } from "./testing/no-backward-read"; import type { HistoryMetadataRead, NativeSessionReader, NativeSessionSource } from "./types"; const PROVIDER = "fixture-provider"; @@ -57,6 +58,7 @@ function createFixture(): { const reader: NativeSessionReader<string> = { kind: "fixture", parserVersion: "fixture-v1", + readBackward: noBackwardRead, roots: () => [root], discover: async () => ({ sources: state.sources, @@ -406,6 +408,7 @@ test("selective refresh reuses full discovery, retains unselected sources, and l const reader: NativeSessionReader<string> = { kind: "fixture", parserVersion: "fixture-v1", + readBackward: noBackwardRead, roots: () => [root], discover: async () => discovery, readMetadata: async (source): Promise<HistoryMetadataRead> => { diff --git a/src/utils/agent-sessions/testing/no-backward-read.ts b/src/utils/agent-sessions/testing/no-backward-read.ts new file mode 100644 index 0000000000..d1ddfec60c --- /dev/null +++ b/src/utils/agent-sessions/testing/no-backward-read.ts @@ -0,0 +1,6 @@ +import type { BackwardRead } from "../types"; + +/** `readBackward` for a test reader whose test never walks a session backwards. */ +export async function noBackwardRead(): Promise<BackwardRead> { + return { entries: [], found: false, issues: [] }; +} diff --git a/src/utils/agent-sessions/types.ts b/src/utils/agent-sessions/types.ts index ec5c7fe10e..2e6b1466a8 100644 --- a/src/utils/agent-sessions/types.ts +++ b/src/utils/agent-sessions/types.ts @@ -243,7 +243,34 @@ export interface HistoryDiscoveryOptions { signal?: AbortSignal; } +/** What `readBackward` reads: a session from its end, back to the first entry `until` accepts. */ +export interface ReadBackwardOptions { + /** Reading backwards, the walk stops at the first entry this accepts; that entry is returned too. */ + until: (entry: NativeHistoryEntry) => boolean; + /** At most this many bytes from the end (default `READ_BACKWARD_MAX_BYTES`). */ + maxBytes?: number; + signal?: AbortSignal; +} + +export interface BackwardRead { + /** In conversation order, oldest first. `line` counts back from the end of the file: -1 is the last line. */ + entries: NativeHistoryEntry[]; + /** Whether `until` accepted an entry; false when the walk reached the start of the file or `maxBytes`. */ + found: boolean; + issues: NativeSourceIssue[]; +} + +/** + * One coding agent's native sessions (Claude, Codex, Grok). Every fact a feature reads from a transcript is a member + * here, implemented by each provider from its own format, so a feature never parses a transcript itself + * (`.claude/rules/agent-transcripts.md`). A required member makes a new provider name what it cannot answer. + */ export interface NativeSessionReader<Kind extends string = AgentKind> { + /** + * The newest entries of a session, read from the end of its source back to the first entry `options.until` + * accepts: the conversation after a known point (a decision's ask) without reading the whole file. + */ + readBackward(source: NativeSessionSource<Kind>, options: ReadBackwardOptions): Promise<BackwardRead>; importSession?( session: AgentSession<Kind>, context: NativeSessionImportContext diff --git a/src/utils/ai/catalog/efforts.ts b/src/utils/ai/catalog/efforts.ts new file mode 100644 index 0000000000..d22c20d9af --- /dev/null +++ b/src/utils/ai/catalog/efforts.ts @@ -0,0 +1,21 @@ +/** + * Every reasoning-effort value a provider takes, weakest first: the union of Anthropic's (`low` to `max`), OpenAI's + * and OpenRouter's (`none`, `minimal` to `xhigh`) and the proxy's own `ultra`. A provider takes a subset, so a list + * for one provider is `effortRange(from, to)` or a filter of this list, never a list of its own + * (lint-rules `no-effort-vocabulary-list`). + */ +export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] as const; + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; + +/** The efforts from `from` to `to`, both included, weakest first: `effortRange("low", "max")`. */ +export function effortRange<From extends ReasoningEffort, To extends ReasoningEffort>( + from: From, + to: To +): ReasoningEffort[] { + return REASONING_EFFORTS.slice(REASONING_EFFORTS.indexOf(from), REASONING_EFFORTS.indexOf(to) + 1); +} + +export function isReasoningEffort(value: unknown): value is ReasoningEffort { + return typeof value === "string" && (REASONING_EFFORTS as readonly string[]).includes(value); +} diff --git a/src/utils/ai/compact/stream.ts b/src/utils/ai/compact/stream.ts index 42f79064eb..096f173b30 100644 --- a/src/utils/ai/compact/stream.ts +++ b/src/utils/ai/compact/stream.ts @@ -1,4 +1,5 @@ import { watchFileFeed } from "@genesiscz/utils/fs/file-feed-watcher"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; import { type CompactSessionOptions, compactSession } from "./index"; import type { CompactResult } from "./schema"; @@ -35,7 +36,7 @@ export async function followCompact(options: FollowCompactOptions): Promise<numb debounceMs: FOLLOW_DEBOUNCE_MS, signal: options.signal, onChange: async () => { - const text = await Bun.file(options.filePath).text(); + const text = readWholeTextSync(options.filePath); const sourceBytes = Buffer.byteLength(text, "utf8"); if (sourceBytes === lastBytes) { return; diff --git a/src/utils/ai/core/call.ts b/src/utils/ai/core/call.ts index bf3b37aab0..6f8008e2a4 100644 --- a/src/utils/ai/core/call.ts +++ b/src/utils/ai/core/call.ts @@ -84,14 +84,18 @@ export interface CallLLMResult { usage?: LanguageModelUsage; } -export interface CallLLMStructuredOptions<T> { +/** + * `S` is the schema itself and the result its `z.output`. Inferring an output `T` from a `z.ZodType<T>` field instead + * walks the whole schema type against ZodType at every call (about 3,400 instantiations per call, 2026-10-25). + */ +export interface CallLLMStructuredOptions<S extends z.ZodType = z.ZodType> { systemPrompt: string; userPrompt: string; providerChoice?: ProviderChoice; model?: ModelRef | ResolvedBinding; task?: ResolveOptions["task"]; app?: ResolveOptions["app"]; - schema: z.ZodType<T>; + schema: S; maxTokens?: number; temperature?: number; /** The SDK's own retries; 0 lets a caller with another account to try fail at once. */ @@ -525,7 +529,9 @@ export async function streamLLM( } } -export async function callLLMStructured<T>(options: CallLLMStructuredOptions<T>): Promise<CallLLMStructuredResult<T>> { +export async function callLLMStructured<S extends z.ZodType>( + options: CallLLMStructuredOptions<S> +): Promise<CallLLMStructuredResult<z.output<S>>> { const target = await resolveCallTarget(options); try { @@ -535,10 +541,10 @@ export async function callLLMStructured<T>(options: CallLLMStructuredOptions<T>) } } -async function runStructured<T>( +async function runStructured<S extends z.ZodType>( target: CallTarget, - options: CallLLMStructuredOptions<T> -): Promise<CallLLMStructuredResult<T>> { + options: CallLLMStructuredOptions<S> +): Promise<CallLLMStructuredResult<z.output<S>>> { const { schema, maxTokens, onPartial } = options; const temperature = acceptedTemperature(target, options.temperature); @@ -553,7 +559,7 @@ async function runStructured<T>( }; if (onPartial) { - const streamed = await tryStreamObject<T>({ model: target.model, callArgs, onPartial }); + const streamed = await tryStreamObject({ model: target.model, callArgs, onPartial }); if (streamed) { return streamed; @@ -561,23 +567,23 @@ async function runStructured<T>( } const result = await generateObject({ - model: target.model as unknown as Parameters<typeof generateObject>[0]["model"], + model: target.model, ...callArgs, }); return { - object: result.object as T, + object: result.object as z.output<S>, content: SafeJSON.stringify(result.object, null, 2), usage: result.usage, }; } -interface TryStreamObjectOpts<T> { +interface TryStreamObjectOpts<S extends z.ZodType> { model: LanguageModel; callArgs: { system: string | undefined; prompt: string; - schema: z.ZodType<T>; + schema: S; providerOptions: ReturnType<typeof buildProviderOptions>; maxOutputTokens?: number; temperature?: number; @@ -590,12 +596,14 @@ interface TryStreamObjectOpts<T> { * `generateObject` fallback) when the stream fails before the first chunk; * errors after the first chunk propagate like a failed `generateObject` call. */ -async function tryStreamObject<T>(opts: TryStreamObjectOpts<T>): Promise<CallLLMStructuredResult<T> | null> { +async function tryStreamObject<S extends z.ZodType>( + opts: TryStreamObjectOpts<S> +): Promise<CallLLMStructuredResult<z.output<S>> | null> { let sawChunk = false; try { const result = streamObject({ - model: opts.model as unknown as Parameters<typeof streamObject>[0]["model"], + model: opts.model, ...opts.callArgs, }); @@ -604,7 +612,7 @@ async function tryStreamObject<T>(opts: TryStreamObjectOpts<T>): Promise<CallLLM opts.onPartial(partial); } - const object = (await result.object) as T; + const object = (await result.object) as z.output<S>; return { object, diff --git a/src/utils/ai/grok/headers.ts b/src/utils/ai/grok/headers.ts index b2a203cc34..28cc58ddca 100644 --- a/src/utils/ai/grok/headers.ts +++ b/src/utils/ai/grok/headers.ts @@ -25,6 +25,14 @@ export function readGrokClientVersion(homePath?: string): string { return DEFAULT_GROK_CLIENT_VERSION; } +/** + * xAI routes its prompt cache by conversation: the `x-grok-conv-id` header, with the same value as the body's + * `prompt_cache_key` (docs.x.ai, "maximizing cache hits"; hermes-agent sends both). No key, no header. + */ +export function grokConversationHeaders(cacheKey: string | undefined): Record<string, string> { + return cacheKey ? { "x-grok-conv-id": cacheKey } : {}; +} + export function buildCliProxyHeaders({ token, modelOverride, diff --git a/src/utils/ai/openai/codex-models.test.ts b/src/utils/ai/openai/codex-models.test.ts new file mode 100644 index 0000000000..3f18941e67 --- /dev/null +++ b/src/utils/ai/openai/codex-models.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { + codexModelsCachePath, + describeCodexEfforts, + mergeCodexModelRecords, + readCodexModelsCache, + resolveCodexEffort, +} from "./codex-models"; +import { OPENAI_SUB_STATIC_CATALOG, resolveOpenAiSubModel, type WhamModelRecord } from "./sub-models"; + +const SOL_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"]; + +function record(slug: string, fields: Partial<WhamModelRecord> = {}): WhamModelRecord { + return { slug, displayName: slug, contextWindow: 272_000, visibility: "list", ...fields }; +} + +describe("mergeCodexModelRecords", () => { + it("takes each field from the first source that has it: live, then the CLI cache, then static", () => { + const merged = mergeCodexModelRecords({ + live: [record("m", { contextWindow: 500_000, supportedEfforts: SOL_EFFORTS })], + cache: [record("m", { contextWindow: 400_000, effectiveContextWindowPercent: 95, defaultEffort: "low" })], + fallback: [record("m", { compHash: "3000", defaultEffort: "medium", useResponsesLite: true })], + }).get("m"); + + expect(merged).toMatchObject({ + window: 500_000, + efforts: SOL_EFFORTS, + usablePercent: 95, + defaultEffort: "low", + compHash: "3000", + useResponsesLite: true, + }); + expect(merged?.sources).toMatchObject({ + window: "live", + efforts: "live", + usablePercent: "cache", + defaultEffort: "cache", + compHash: "static", + compactPercent: "default", + }); + }); + + it("derives the usable and compact windows by percent, and an override wins over every source", () => { + const plain = mergeCodexModelRecords({ live: [record("m", { contextWindow: 500_000 })], fallback: [] }).get( + "m" + ); + + // codex-rs: 95% usable when the record names no percent, compaction at 90%. + expect(plain).toMatchObject({ + usablePercent: 95, + compactPercent: 90, + usableTokens: 475_000, + compactTokens: 450_000, + }); + + const limited = mergeCodexModelRecords({ + live: [record("m", { contextWindow: 500_000, autoCompactTokenLimit: 300_000 })], + fallback: [], + }).get("m"); + + // A catalog compaction limit may only lower the 90% point. + expect(limited?.compactTokens).toBe(300_000); + + const overridden = mergeCodexModelRecords({ + live: [record("m", { contextWindow: 500_000, defaultEffort: "low" })], + fallback: [], + overrides: { m: { window: 400_000, usablePercent: 92, compactPercent: 80, defaultEffort: "high" } }, + }).get("m"); + + expect(overridden).toMatchObject({ + window: 400_000, + usableTokens: 368_000, + compactTokens: 320_000, + defaultEffort: "high", + }); + expect(overridden?.sources).toMatchObject({ + window: "override", + usablePercent: "override", + compactPercent: "override", + defaultEffort: "override", + }); + }); + + it("keeps the static GPT-6 records complete enough to work with no live list and no cache", () => { + const merged = mergeCodexModelRecords({}); + + expect(merged.get("gpt-6.1-sol")).toMatchObject({ + window: 500_000, + usableTokens: 475_000, + efforts: SOL_EFFORTS, + defaultEffort: "low", + multiAgentReasoningEffort: "xhigh", + useResponsesLite: true, + compHash: "3000", + }); + expect(merged.get("gpt-6-luna")?.efforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(OPENAI_SUB_STATIC_CATALOG.some((entry) => entry.slug === "gpt-6-sol")).toBe(true); + expect(resolveOpenAiSubModel("sol6")).toBe("gpt-6-sol"); + expect(resolveOpenAiSubModel("sol")).toBe("gpt-6.1-sol"); + }); +}); + +describe("resolveCodexEffort", () => { + const catalog = mergeCodexModelRecords({}); + const model = (slug: string) => { + const info = catalog.get(slug); + + if (!info) { + throw new Error(`no static record for ${slug}`); + } + + return info; + }; + + it("passes a listed effort unchanged, max included", () => { + expect(resolveCodexEffort(model("gpt-6.1-sol"), "max")).toEqual({ ok: true, effort: "max" }); + expect(resolveCodexEffort(model("gpt-6.1-sol"), "XHigh")).toEqual({ ok: true, effort: "xhigh" }); + }); + + it("maps ultra the way codex-rs does: the multi-agent effort, else max, else the strongest other level", () => { + expect(resolveCodexEffort(model("gpt-6.1-sol"), "ultra")).toEqual({ + ok: true, + effort: "xhigh", + mappedFrom: "ultra", + }); + expect(resolveCodexEffort(model("gpt-6-sol"), "ultra")).toEqual({ + ok: true, + effort: "max", + mappedFrom: "ultra", + }); + + const noMax = { slug: "m", efforts: ["low", "high", "ultra"], multiAgentReasoningEffort: "max" }; + expect(resolveCodexEffort(noMax, "ultra")).toEqual({ ok: true, effort: "high", mappedFrom: "ultra" }); + }); + + it("refuses an effort the model does not list, naming the allowed ones", () => { + expect(resolveCodexEffort(model("gpt-6-luna"), "ultra")).toEqual({ + ok: false, + allowed: ["low", "medium", "high", "xhigh", "max"], + message: 'gpt-6-luna has no reasoning effort "ultra". Allowed: low, medium, high, xhigh, max.', + }); + expect(describeCodexEfforts(model("gpt-6.1-sol"))).toBe("low, medium, high, xhigh, max, ultra (runs as xhigh)"); + }); +}); + +describe("readCodexModelsCache", () => { + it("reads the Codex CLI cache schema and answers an empty list for a missing file", () => { + const dir = mkdtempSync(join(tmpdir(), "gt-codex-models-")); + const path = join(dir, "models_cache.json"); + writeFileSync( + path, + SafeJSON.stringify({ + fetched_at: "2026-10-25T20:19:22Z", + models: [ + { + slug: "gpt-x", + display_name: "X", + context_window: 500_000, + visibility: "list", + supported_reasoning_levels: [{ effort: "low", description: "Fast" }, { effort: "max" }], + default_reasoning_level: "low", + effective_context_window_percent: 95, + auto_compact_token_limit: null, + multi_agent_reasoning_effort: null, + comp_hash: "3000", + }, + { slug: "broken" }, + ], + }) + ); + + expect(readCodexModelsCache(path)).toEqual([ + expect.objectContaining({ + slug: "gpt-x", + contextWindow: 500_000, + supportedEfforts: ["low", "max"], + defaultEffort: "low", + effectiveContextWindowPercent: 95, + autoCompactTokenLimit: undefined, + multiAgentReasoningEffort: undefined, + compHash: "3000", + }), + ]); + expect(readCodexModelsCache(join(dir, "missing.json"))).toEqual([]); + }); + + it("never reads the real Codex home under test: the preload points the cache at an empty folder", () => { + expect(codexModelsCachePath()).toContain("gt-test-codex-models-"); + }); +}); diff --git a/src/utils/ai/openai/codex-models.ts b/src/utils/ai/openai/codex-models.ts new file mode 100644 index 0000000000..46d252a5b3 --- /dev/null +++ b/src/utils/ai/openai/codex-models.ts @@ -0,0 +1,288 @@ +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { env } from "@genesiscz/utils/env"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; +import { primaryCodexHome } from "@genesiscz/utils/providers/session-paths"; +import { + OPENAI_SUB_STATIC_CATALOG, + type WhamApiModel, + type WhamModelRecord, + whamModelRecordFromApi, +} from "./sub-models"; + +/** + * One place that knows, per Codex model: window, usable and compact percents, efforts, default effort, Responses + * Lite, `comp_hash`, search support. Sources in order: the live WHAM `/models` list, the Codex CLI's own + * `models_cache.json` (the same schema, written by the CLI), then `OPENAI_SUB_STATIC_CATALOG`. A per-model override + * from the ai-proxy config wins over all three for the fields it names. + * + * Rules follow codex-rs (openai/codex 970111f6, `protocol/src/openai_models.rs` and `reasoning_effort.rs`): the + * usable window is `effective_context_window_percent` of the window (default 95), compaction is at 90% unless the + * record's `auto_compact_token_limit` is lower, and `ultra` is never sent: it resolves to the record's + * `multi_agent_reasoning_effort`, else `max`, else the strongest other level. The backend refuses a literal `ultra` + * with a 400 (probed 2026-10-25). + */ + +export type CodexCatalogSource = "live" | "cache" | "static" | "default" | "override"; + +/** Per-model settings Martin may change in the ai-proxy config (`codexModels.<slug>`). */ +export interface CodexModelOverride { + /** Context window in tokens; replaces the catalog value. */ + window?: number; + /** Share of the window a client may fill, in percent. */ + usablePercent?: number; + /** Share of the window at which the client compacts, in percent. */ + compactPercent?: number; + /** Effort used when a request names none. */ + defaultEffort?: string; +} + +export const CODEX_DEFAULT_USABLE_PERCENT = 95; +export const CODEX_DEFAULT_COMPACT_PERCENT = 90; +/** codex-rs falls back to `medium` when a model lists no level at all. */ +const CODEX_FALLBACK_EFFORT = "medium"; + +export interface CodexModelInfo { + slug: string; + displayName: string; + visibility: "list" | "hide"; + window: number; + maxContextWindow?: number; + usablePercent: number; + compactPercent: number; + /** `window * usablePercent / 100`, rounded down: what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` gets. */ + usableTokens: number; + /** `window * compactPercent / 100`, lowered to the record's `auto_compact_token_limit` when that is smaller. */ + compactTokens: number; + /** Weakest first; empty when no source lists any (an unknown model). */ + efforts: string[]; + defaultEffort?: string; + useResponsesLite: boolean; + compHash?: string; + supportsSearchTool: boolean; + webSearchToolType?: string; + multiAgentReasoningEffort?: string; + defaultReasoningSummary?: string; + supportsReasoningSummaryParameter?: boolean; + inputModalities?: string[]; + /** Which source supplied each field. */ + sources: Record<string, CodexCatalogSource>; +} + +/** Where `models_cache.json` lives: the test override, else the primary `CODEX_HOME`, else `~/.codex`. */ +export function codexModelsCachePath(): string { + return ( + env.codex.getModelsCachePathOverride() ?? + join(primaryCodexHome() ?? join(homedir(), ".codex"), "models_cache.json") + ); +} + +let cacheMemo: { path: string; mtimeMs: number; records: WhamModelRecord[] } | null = null; + +/** + * Read the Codex CLI's model cache (read-only; the CLI owns the file). Memoised by path and mtime, so a request + * path may call it on every request. Missing or unreadable answers an empty list. + */ +export function readCodexModelsCache(path = codexModelsCachePath()): WhamModelRecord[] { + let mtimeMs: number; + try { + mtimeMs = statSync(path).mtimeMs; + } catch (err) { + logger.debug({ err, path }, "codex-models: no Codex CLI model cache"); + return []; + } + + if (cacheMemo && cacheMemo.path === path && cacheMemo.mtimeMs === mtimeMs) { + return cacheMemo.records; + } + + let records: WhamModelRecord[] = []; + try { + const parsed = SafeJSON.parse(readFileSync(path, "utf8"), { strict: true }); + const models = isObject(parsed) && Array.isArray(parsed.models) ? parsed.models : []; + records = models.filter(isApiModel).map(whamModelRecordFromApi); + } catch (err) { + logger.warn({ err, path }, "codex-models: Codex CLI model cache is unreadable; using the other sources"); + } + + cacheMemo = { path, mtimeMs, records }; + return records; +} + +function isApiModel(value: unknown): value is WhamApiModel { + return ( + isObject(value) && + typeof value.slug === "string" && + typeof value.display_name === "string" && + typeof value.context_window === "number" + ); +} + +type Layer = { source: CodexCatalogSource; record: WhamModelRecord | undefined }; + +/** The first layer whose record has the field set, and which layer that was. */ +function pick<K extends keyof WhamModelRecord>( + layers: Layer[], + key: K +): { value: WhamModelRecord[K]; source: CodexCatalogSource } | undefined { + for (const layer of layers) { + const value = layer.record?.[key]; + + if (value !== undefined) { + return { value, source: layer.source }; + } + } + + return undefined; +} + +/** + * Merge the three sources into one record per slug, field by field (live, then cache, then static), and apply the + * overrides. Pure: callers pass every source, so tests need no files and no network. + */ +export function mergeCodexModelRecords({ + live, + cache, + fallback = OPENAI_SUB_STATIC_CATALOG, + overrides = {}, +}: { + live?: WhamModelRecord[] | null; + cache?: WhamModelRecord[]; + fallback?: WhamModelRecord[]; + overrides?: Record<string, CodexModelOverride>; +}): Map<string, CodexModelInfo> { + const bySlug = (records: WhamModelRecord[] | null | undefined) => + new Map((records ?? []).map((record) => [record.slug, record])); + const liveMap = bySlug(live); + const cacheMap = bySlug(cache); + const staticMap = bySlug(fallback); + const slugs = [...new Set([...liveMap.keys(), ...cacheMap.keys(), ...staticMap.keys()])]; + const result = new Map<string, CodexModelInfo>(); + + for (const slug of slugs) { + const layers: Layer[] = [ + { source: "live", record: liveMap.get(slug) }, + { source: "cache", record: cacheMap.get(slug) }, + { source: "static", record: staticMap.get(slug) }, + ]; + const sources: Record<string, CodexCatalogSource> = {}; + const take = <K extends keyof WhamModelRecord>(key: K, name: string = key) => { + const found = pick(layers, key); + + if (found) { + sources[name] = found.source; + } + + return found?.value; + }; + + const override = overrides[slug] ?? {}; + const catalogWindow = take("contextWindow", "window") ?? 0; + const window = override.window ?? catalogWindow; + + if (override.window !== undefined) { + sources.window = "override"; + } + + const catalogPercent = take("effectiveContextWindowPercent", "usablePercent"); + const usablePercent = override.usablePercent ?? catalogPercent ?? CODEX_DEFAULT_USABLE_PERCENT; + sources.usablePercent = + override.usablePercent !== undefined ? "override" : (sources.usablePercent ?? "default"); + const compactPercent = override.compactPercent ?? CODEX_DEFAULT_COMPACT_PERCENT; + sources.compactPercent = override.compactPercent !== undefined ? "override" : "default"; + const autoCompactTokenLimit = take("autoCompactTokenLimit"); + const byPercent = Math.floor((window * compactPercent) / 100); + const efforts = take("supportedEfforts", "efforts") ?? []; + const catalogDefault = take("defaultEffort"); + const defaultEffort = override.defaultEffort ?? catalogDefault; + + if (override.defaultEffort !== undefined) { + sources.defaultEffort = "override"; + } + + result.set(slug, { + slug, + displayName: take("displayName") ?? slug, + visibility: take("visibility") ?? "list", + window, + maxContextWindow: take("maxContextWindow"), + usablePercent, + compactPercent, + usableTokens: Math.floor((window * usablePercent) / 100), + compactTokens: autoCompactTokenLimit !== undefined ? Math.min(byPercent, autoCompactTokenLimit) : byPercent, + efforts, + defaultEffort, + useResponsesLite: take("useResponsesLite") ?? false, + compHash: take("compHash"), + supportsSearchTool: take("supportsSearchTool") ?? false, + webSearchToolType: take("webSearchToolType"), + multiAgentReasoningEffort: take("multiAgentReasoningEffort"), + defaultReasoningSummary: take("defaultReasoningSummary"), + supportsReasoningSummaryParameter: take("supportsReasoningSummaryParameter"), + inputModalities: take("inputModalities"), + sources, + }); + } + + return result; +} + +export type CodexEffortResolution = + | { ok: true; effort: string; mappedFrom?: string } + | { ok: false; message: string; allowed: string[] }; + +/** "low, medium, high, xhigh, max, ultra (runs as xhigh)" for messages and help text. */ +export function describeCodexEfforts(model: Pick<CodexModelInfo, "efforts" | "multiAgentReasoningEffort">): string { + return model.efforts + .map((effort) => { + if (effort !== "ultra") { + return effort; + } + + return `ultra (runs as ${resolveUltra(model)})`; + }) + .join(", "); +} + +/** codex-rs `resolve_reasoning_effort` for `Ultra`. */ +function resolveUltra(model: Pick<CodexModelInfo, "efforts" | "multiAgentReasoningEffort">): string { + const multiAgent = model.multiAgentReasoningEffort; + + if (multiAgent && multiAgent !== "ultra" && model.efforts.includes(multiAgent)) { + return multiAgent; + } + + if (model.efforts.includes("max")) { + return "max"; + } + + return model.efforts.filter((effort) => effort !== "ultra").at(-1) ?? CODEX_FALLBACK_EFFORT; +} + +/** + * Check a requested effort against the model's own list. A listed effort passes unchanged (`max` stays `max`); + * `ultra` resolves the way codex-rs does and says so in `mappedFrom`; anything else is refused with the list. + */ +export function resolveCodexEffort( + model: Pick<CodexModelInfo, "slug" | "efforts" | "multiAgentReasoningEffort">, + requested: string +): CodexEffortResolution { + const effort = requested.trim().toLowerCase(); + + if (!model.efforts.includes(effort)) { + return { + ok: false, + allowed: model.efforts, + message: `${model.slug} has no reasoning effort "${requested}". Allowed: ${describeCodexEfforts(model)}.`, + }; + } + + if (effort !== "ultra") { + return { ok: true, effort }; + } + + return { ok: true, effort: resolveUltra(model), mappedFrom: "ultra" }; +} diff --git a/src/utils/ai/openai/native-session-import.ts b/src/utils/ai/openai/native-session-import.ts index b1c0840aa4..d58f109bfe 100644 --- a/src/utils/ai/openai/native-session-import.ts +++ b/src/utils/ai/openai/native-session-import.ts @@ -7,7 +7,10 @@ import type { NativeSessionImportContext, NativeSessionImportResult, } from "@genesiscz/utils/agent-sessions/types"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { SafeJSON } from "@genesiscz/utils/json"; +import { transcriptStem } from "@genesiscz/utils/providers/session-paths"; import { NETWORKED_LOCK_WAIT_MS, withFileLock } from "@genesiscz/utils/storage/file-lock"; import { genesisToolsDirIn } from "@genesiscz/utils/storage/root"; import { z } from "zod"; @@ -35,6 +38,11 @@ const recordSchema = z.object({ }); type ImportRecord = z.infer<typeof recordSchema>; +/** A rollout's text bytes: a `.jsonl.zst` (Codex compresses its old rollouts) decompressed, so the copy is plain JSONL. */ +async function rolloutBytes(path: string): Promise<Buffer> { + return isZstdPath(path) ? Buffer.from(readWholeTextSync(path), "utf8") : readFile(path); +} + function digest(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); } @@ -81,7 +89,7 @@ export async function importNativeCodexSession( } } const sourcePath = await realpath(session.filePath); - const bytes = await readFile(sourcePath); + const bytes = await rolloutBytes(sourcePath); const lines = bytes.toString("utf8").split("\n"); for (const line of lines) { if (line.trim()) { @@ -159,9 +167,9 @@ export async function importNativeCodexSession( createdAt: new Date().toISOString(), }; try { - const stagedPath = join(staging, basename(sourcePath)); + const stagedPath = join(staging, `${transcriptStem(basename(sourcePath))}.jsonl`); await writeFile(stagedPath, bytes, { mode: 0o600, flag: "wx" }); - if (digest(await readFile(sourcePath)) !== hash) { + if (digest(await rolloutBytes(sourcePath)) !== hash) { throw new Error("Source changed while staging its snapshot"); } await writeRecord(recordPath, record); @@ -185,7 +193,7 @@ export async function importNativeCodexSession( await writeRecord(recordPath, record); await verifyThread("thread/read", copiedId); await verifyThread("thread/resume", copiedId); - if (digest(await readFile(sourcePath)) !== hash) { + if (digest(await rolloutBytes(sourcePath)) !== hash) { throw new Error("Source changed during native import"); } record.status = "complete"; diff --git a/src/utils/ai/openai/sub-models.ts b/src/utils/ai/openai/sub-models.ts index 39863283ca..f8b9da6fa7 100644 --- a/src/utils/ai/openai/sub-models.ts +++ b/src/utils/ai/openai/sub-models.ts @@ -9,6 +9,57 @@ export interface WhamModelRecord { visibility: "list" | "hide"; inputModalities?: string[]; supportsParallelToolCalls?: boolean; + /** `supported_reasoning_levels[].effort`, weakest first, as the backend lists them. */ + supportedEfforts?: string[]; + /** `default_reasoning_level`: the effort Codex sends when the user picked none. */ + defaultEffort?: string; + /** `effective_context_window_percent`: the share of the window a client may fill (codex-rs default 95). */ + effectiveContextWindowPercent?: number; + /** `max_context_window`: the largest window a config override may ask for. */ + maxContextWindow?: number; + /** `auto_compact_token_limit`: a catalog compaction limit that may only LOWER codex-rs's 90%. */ + autoCompactTokenLimit?: number; + /** `use_responses_lite`: codex-rs sends Lite-only fields (`reasoning.context: "all_turns"`) for these. */ + useResponsesLite?: boolean; + /** `comp_hash`: models that share it can read each other's encrypted items. */ + compHash?: string; + supportsSearchTool?: boolean; + webSearchToolType?: string; + /** `multi_agent_reasoning_effort`: what `ultra` resolves to (codex-rs `resolve_reasoning_effort`). */ + multiAgentReasoningEffort?: string; + defaultReasoningSummary?: string; + supportsReasoningSummaryParameter?: boolean; +} + +/** + * The GPT-6 and GPT-5.6 records as the live WHAM `/models` list gave them on 2026-10-25 21:24 (account plan Pro): + * efforts low to max (plus `ultra` where `ultra` is set), 95% usable window, `max_context_window` 872000, + * Responses Lite, `comp_hash` "3000", the native search tool. `ultra: "xhigh"` is the record's + * `multi_agent_reasoning_effort`. The live list and `~/.codex/models_cache.json` win over these (codex-models.ts). + */ +function gpt6( + slug: string, + displayName: string, + contextWindow: number, + options: { defaultEffort: string; ultra?: true | string } +): WhamModelRecord { + return { + slug, + displayName, + contextWindow, + visibility: "list", + inputModalities: ["text", "image"], + supportedEfforts: ["low", "medium", "high", "xhigh", "max", ...(options.ultra ? ["ultra"] : [])], + defaultEffort: options.defaultEffort, + effectiveContextWindowPercent: 95, + maxContextWindow: 872_000, + useResponsesLite: true, + compHash: "3000", + supportsSearchTool: true, + webSearchToolType: "text_and_image", + multiAgentReasoningEffort: typeof options.ultra === "string" ? options.ultra : undefined, + defaultReasoningSummary: "none", + }; } /** @@ -21,13 +72,13 @@ export interface WhamModelRecord { * unsupported ids surface WHAM's own 400 to the caller. */ export const OPENAI_SUB_STATIC_CATALOG: WhamModelRecord[] = [ - { slug: "gpt-6-astra", displayName: "GPT-6-Astra", contextWindow: 272_000, visibility: "list" }, - { slug: "gpt-6.1-sol", displayName: "GPT-6.1-Sol", contextWindow: 500_000, visibility: "list" }, - { slug: "gpt-6-sol", displayName: "GPT-6-Sol", contextWindow: 272_000, visibility: "list" }, - { slug: "gpt-6-luna", displayName: "GPT-6-Luna", contextWindow: 272_000, visibility: "list" }, - { slug: "gpt-5.6-sol", displayName: "GPT-5.6-Sol", contextWindow: 272_000, visibility: "list" }, - { slug: "gpt-5.6-terra", displayName: "GPT-5.6-Terra", contextWindow: 272_000, visibility: "list" }, - { slug: "gpt-5.6-luna", displayName: "GPT-5.6-Luna", contextWindow: 272_000, visibility: "list" }, + gpt6("gpt-6-astra", "GPT-6-Astra", 272_000, { defaultEffort: "medium", ultra: "xhigh" }), + gpt6("gpt-6.1-sol", "GPT-6.1-Sol", 500_000, { defaultEffort: "low", ultra: "xhigh" }), + gpt6("gpt-6-sol", "GPT-6-Sol", 272_000, { defaultEffort: "medium", ultra: true }), + gpt6("gpt-6-luna", "GPT-6-Luna", 272_000, { defaultEffort: "medium" }), + gpt6("gpt-5.6-sol", "GPT-5.6-Sol", 272_000, { defaultEffort: "low", ultra: true }), + gpt6("gpt-5.6-terra", "GPT-5.6-Terra", 272_000, { defaultEffort: "medium", ultra: true }), + gpt6("gpt-5.6-luna", "GPT-5.6-Luna", 272_000, { defaultEffort: "medium" }), { slug: "gpt-5.5", displayName: "GPT-5.5", contextWindow: 272_000, visibility: "list" }, { slug: "gpt-5.4", displayName: "GPT-5.4", contextWindow: 272_000, visibility: "list" }, { slug: "gpt-5.4-mini", displayName: "GPT-5.4-Mini", contextWindow: 272_000, visibility: "list" }, @@ -39,13 +90,23 @@ export const OPENAI_SUB_STATIC_CATALOG: WhamModelRecord[] = [ * match). Config aliases (per proxy account) win over built-ins; unknown ids * pass through unchanged so WHAM's own 400 surfaces for bad slugs. */ -export const OPENAI_SUB_BUILTIN_ALIAS_NAMES = ["latest", "codex", "mini", "astra", "terra", "luna", "sol"] as const; +export const OPENAI_SUB_BUILTIN_ALIAS_NAMES = [ + "latest", + "codex", + "mini", + "astra", + "terra", + "luna", + "sol", + "sol6", +] as const; const OPENAI_SUB_BUILTIN_ALIASES: Record<string, (catalog: WhamModelRecord[]) => string | undefined> = { astra: () => "gpt-6-astra", terra: () => "gpt-5.6-terra", luna: () => "gpt-6-luna", sol: () => "gpt-6.1-sol", + sol6: () => "gpt-6-sol", latest: (catalog) => catalog.find((record) => record.visibility === "list")?.slug, codex: (catalog) => catalog.find((record) => record.visibility === "list" && record.slug.includes("codex"))?.slug ?? @@ -71,15 +132,58 @@ export function resolveOpenAiSubModel(id: string, aliases?: Record<string, strin const WHAM_CLIENT_VERSION = "1.0.26"; +/** One record of WHAM `GET /models`, and of the Codex CLI's `models_cache.json` (the same schema). */ +export interface WhamApiModel { + slug: string; + display_name: string; + context_window: number; + visibility: "list" | "hide"; + input_modalities?: string[]; + supports_parallel_tool_calls?: boolean; + supported_reasoning_levels?: Array<{ effort?: string }>; + default_reasoning_level?: string; + effective_context_window_percent?: number; + max_context_window?: number; + auto_compact_token_limit?: number | null; + use_responses_lite?: boolean; + comp_hash?: string; + supports_search_tool?: boolean; + web_search_tool_type?: string; + multi_agent_reasoning_effort?: string | null; + default_reasoning_summary?: string; + supports_reasoning_summary_parameter?: boolean | null; +} + interface WhamModelsResponse { - models: Array<{ - slug: string; - display_name: string; - context_window: number; - visibility: "list" | "hide"; - input_modalities?: string[]; - supports_parallel_tool_calls?: boolean; - }>; + models: WhamApiModel[]; +} + +/** Map one API record onto `WhamModelRecord`; a null or missing field stays undefined so a lower source can fill it. */ +export function whamModelRecordFromApi(m: WhamApiModel): WhamModelRecord { + const efforts = (m.supported_reasoning_levels ?? []) + .map((level) => level.effort) + .filter((effort): effort is string => typeof effort === "string"); + + return { + slug: m.slug, + displayName: m.display_name, + contextWindow: m.context_window, + visibility: m.visibility, + inputModalities: m.input_modalities, + supportsParallelToolCalls: m.supports_parallel_tool_calls ?? undefined, + supportedEfforts: efforts.length > 0 ? efforts : undefined, + defaultEffort: m.default_reasoning_level ?? undefined, + effectiveContextWindowPercent: m.effective_context_window_percent ?? undefined, + maxContextWindow: m.max_context_window ?? undefined, + autoCompactTokenLimit: m.auto_compact_token_limit ?? undefined, + useResponsesLite: m.use_responses_lite ?? undefined, + compHash: m.comp_hash ?? undefined, + supportsSearchTool: m.supports_search_tool ?? undefined, + webSearchToolType: m.web_search_tool_type ?? undefined, + multiAgentReasoningEffort: m.multi_agent_reasoning_effort ?? undefined, + defaultReasoningSummary: m.default_reasoning_summary ?? undefined, + supportsReasoningSummaryParameter: m.supports_reasoning_summary_parameter ?? undefined, + }; } /** @@ -107,14 +211,7 @@ export async function tryFetchWhamModels(accessToken: string, accountId?: string const data = (await res.json()) as WhamModelsResponse; - return data.models.map((m) => ({ - slug: m.slug, - displayName: m.display_name, - contextWindow: m.context_window, - visibility: m.visibility, - inputModalities: m.input_modalities, - supportsParallelToolCalls: m.supports_parallel_tool_calls, - })); + return data.models.map(whamModelRecordFromApi); } catch (err) { logger.debug({ err }, "codex: live WHAM model fetch failed"); return null; diff --git a/src/utils/ai/prompt-cache-key.test.ts b/src/utils/ai/prompt-cache-key.test.ts new file mode 100644 index 0000000000..4c795c5851 --- /dev/null +++ b/src/utils/ai/prompt-cache-key.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "bun:test"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { asCacheKey, claudeSessionIdFromMetadata, resolvePromptCacheKey, uuidFromHash } from "./prompt-cache-key"; + +const SESSION = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"; +const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe("claudeSessionIdFromMetadata", () => { + it("reads session_id from Claude Code's JSON user_id, and from the older string form", () => { + const json = SafeJSON.stringify({ device_id: "dev", account_uuid: "acct", session_id: SESSION }); + + expect(claudeSessionIdFromMetadata({ user_id: json })).toBe(SESSION); + expect( + claudeSessionIdFromMetadata({ + user_id: `user_abc_account_${"1".repeat(8)}-1111-1111-1111-111111111111_session_${SESSION}`, + }) + ).toBe(SESSION); + expect(claudeSessionIdFromMetadata({ user_id: "plain-user" })).toBeUndefined(); + expect(claudeSessionIdFromMetadata(undefined)).toBeUndefined(); + }); +}); + +describe("resolvePromptCacheKey", () => { + const content = { model: "gpt-x", system: "You are terse.", first: { role: "user", content: "hi" } }; + + it("forwards a key the client chose, unchanged, over every policy", () => { + expect(resolvePromptCacheKey({ clientKey: "thread-7", claudeSessionId: SESSION, content })).toEqual({ + key: "thread-7", + source: "client", + }); + }); + + it("uses Claude Code's session id by default, and falls back to the record, then the content", () => { + expect(resolvePromptCacheKey({ claudeSessionId: SESSION, recordId: "rec", content })).toEqual({ + key: SESSION, + source: "claude-session", + }); + expect(resolvePromptCacheKey({ recordId: SESSION, content })).toEqual({ key: SESSION, source: "record" }); + expect(resolvePromptCacheKey({ content })?.source).toBe("content"); + expect(resolvePromptCacheKey({})).toBeUndefined(); + }); + + it("content policy: same conversation, same key; a changed system prompt or model, a new key", () => { + const key = resolvePromptCacheKey({ policy: "content", claudeSessionId: SESSION, content })?.key; + + expect(key).toMatch(UUID_SHAPE); + expect(resolvePromptCacheKey({ policy: "content", content })?.key).toBe(key); + expect( + resolvePromptCacheKey({ policy: "content", content: { ...content, system: "You are verbose." } })?.key + ).not.toBe(key); + expect(resolvePromptCacheKey({ policy: "content", content: { ...content, model: "gpt-y" } })?.key).not.toBe( + key + ); + }); + + it("sends a UUID: an id that is not one is mapped to a stable UUID-shaped hash", () => { + expect(asCacheKey(SESSION.toUpperCase())).toBe(SESSION); + expect(asCacheKey("not-a-uuid")).toMatch(UUID_SHAPE); + expect(asCacheKey("not-a-uuid")).toBe(uuidFromHash(["not-a-uuid"])); + }); +}); diff --git a/src/utils/ai/prompt-cache-key.ts b/src/utils/ai/prompt-cache-key.ts new file mode 100644 index 0000000000..6805610597 --- /dev/null +++ b/src/utils/ai/prompt-cache-key.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { isObject } from "@genesiscz/utils/object"; + +/** + * One stable cache key per conversation, for every agent that routes the prompt cache by a key. + * + * Measured 2026-10-25 on the ChatGPT backend (gpt-6.1-sol, a 7,280-token prefix, three requests per arm): a fixed + * session HEADER gave cache hits on 11 of 12 warm requests, a fixed `prompt_cache_key` with a random or absent header + * gave 0 of 12. So the Codex agent sends the key as the body key AND as its session headers. codex-rs uses its own + * session id for both (openai/codex 970111f6 `core/src/client.rs` 658-688). xAI takes the same value as the body + * key and the `x-grok-conv-id` header. + * + * Policies: + * - `claude-session` (default): Claude Code's own session id from Anthropic `metadata.user_id`, so the key, the + * ai-proxy session tag and Claude Code's transcript name are one id. It survives `--resume`. + * - `content`: a UUID-shaped sha256 of model, system prompt and first message (switcheroo's formula, + * `tools/switcheroo/src/proxy/translate-request.ts` 148-154 in henderson-tech/pultik). Each subagent gets its own + * key; a changed system prompt makes a new one. + * - `record`: the session record id (the fallback when the body names no session). + */ +export type PromptCacheKeyPolicy = "claude-session" | "content" | "record"; + +export const PROMPT_CACHE_KEY_POLICIES: readonly PromptCacheKeyPolicy[] = ["claude-session", "content", "record"]; + +export type PromptCacheKeySource = "client" | "claude-session" | "record" | "content"; + +export interface PromptCacheKey { + key: string; + source: PromptCacheKeySource; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const LEGACY_SESSION = /session_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; + +/** + * Claude Code's session id from an Anthropic request's `metadata.user_id`. Claude Code 2.1.x sends a JSON string with + * `session_id` (litellm reads it the same way); older builds sent `user_<hash>_account_<uuid>_session_<uuid>`. + * Verified 2026-10-25 on 2.1.296: equal to the `session_id` that `claude -p --output-format json` prints, before + * and after `--resume`. + */ +export function claudeSessionIdFromMetadata(metadata: unknown): string | undefined { + const userId = isObject(metadata) ? metadata.user_id : undefined; + + if (typeof userId !== "string" || userId.length === 0) { + return undefined; + } + + if (userId.startsWith("{")) { + try { + const parsed = SafeJSON.parse(userId, { strict: true }); + + if (isObject(parsed) && typeof parsed.session_id === "string" && parsed.session_id.length > 0) { + return parsed.session_id; + } + } catch (err) { + logger.debug({ err }, "prompt-cache-key: metadata.user_id is not JSON; trying the older string form"); + } + } + + return userId.match(LEGACY_SESSION)?.[1]; +} + +/** A sha256 of the parts, shaped like a UUID (what codex-rs sends as its key). */ +export function uuidFromHash(parts: unknown[]): string { + const hash = createHash("sha256") + .update(SafeJSON.stringify(parts, { strict: true })) + .digest("hex"); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`; +} + +/** A key the upstream accepts: a UUID as is, anything else mapped to a UUID-shaped hash of itself. */ +export function asCacheKey(value: string): string { + return UUID.test(value) ? value.toLowerCase() : uuidFromHash([value]); +} + +/** + * Pick the key. A key the client sent itself always wins (a native Codex CLI or an SDK that manages its own cache), + * so the proxy forwards it unchanged. Otherwise the policy decides; a policy whose input is missing falls back to + * the next one in the order claude-session, record, content. + */ +export function resolvePromptCacheKey({ + policy = "claude-session", + clientKey, + claudeSessionId, + recordId, + content, +}: { + policy?: PromptCacheKeyPolicy; + clientKey?: string; + claudeSessionId?: string; + recordId?: string; + /** Model, system prompt, first message: the `content` policy's inputs. */ + content?: { model: string; system: unknown; first: unknown }; +}): PromptCacheKey | undefined { + if (clientKey && clientKey.trim().length > 0) { + return { key: clientKey.trim(), source: "client" }; + } + + const contentKey = (): PromptCacheKey | undefined => + content + ? { key: uuidFromHash([content.model, content.system ?? "", content.first ?? ""]), source: "content" } + : undefined; + const sessionKey = (): PromptCacheKey | undefined => + claudeSessionId ? { key: asCacheKey(claudeSessionId), source: "claude-session" } : undefined; + const recordKey = (): PromptCacheKey | undefined => + recordId ? { key: asCacheKey(recordId), source: "record" } : undefined; + + if (policy === "content") { + return contentKey() ?? sessionKey() ?? recordKey(); + } + + if (policy === "record") { + return recordKey() ?? sessionKey() ?? contentKey(); + } + + return sessionKey() ?? recordKey() ?? contentKey(); +} diff --git a/src/utils/ai/providers/AICloudProvider.ts b/src/utils/ai/providers/AICloudProvider.ts index 35e1dbf658..884976e5e0 100644 --- a/src/utils/ai/providers/AICloudProvider.ts +++ b/src/utils/ai/providers/AICloudProvider.ts @@ -24,6 +24,8 @@ import { sniffAudioExt } from "@genesiscz/utils/audio/detect-format"; import type { AIProviderType } from "@genesiscz/utils/config/ai.types"; import { env } from "@genesiscz/utils/env"; import { logger } from "@genesiscz/utils/logger"; +// The exported model type: deriving it from generateText's parameters cost about 40k type instantiations here. +import type { LanguageModel } from "ai"; type LlmCloudType = "openai" | "groq" | "openrouter"; type TranscribeOnlyCloudType = "assemblyai" | "deepgram" | "gladia"; @@ -228,9 +230,7 @@ export class AICloudProvider return results; } - private async getLanguageModel( - modelSpec?: string - ): Promise<Parameters<typeof import("ai").generateText>[0]["model"]> { + private async getLanguageModel(modelSpec?: string): Promise<LanguageModel> { if (modelSpec) { return this.resolveModel(modelSpec); } @@ -255,7 +255,7 @@ export class AICloudProvider throw new Error("No cloud LLM API key available. Set GROQ_API_KEY, OPENROUTER_API_KEY, or OPENAI_API_KEY."); } - private async resolveModel(spec: string): Promise<Parameters<typeof import("ai").generateText>[0]["model"]> { + private async resolveModel(spec: string): Promise<LanguageModel> { const [providerName, ...modelParts] = spec.split("/"); const modelId = modelParts.join("/"); diff --git a/src/utils/ai/stt/fixture.ts b/src/utils/ai/stt/fixture.ts index 3fb87626bc..f09824d612 100644 --- a/src/utils/ai/stt/fixture.ts +++ b/src/utils/ai/stt/fixture.ts @@ -1,13 +1,24 @@ +import { abortableSleep } from "@genesiscz/utils/async"; import type { LiveSttSession, LiveTranscriptEvent, SttProviderId } from "./types"; -/** Replays supplied events; accepts and discards audio so callers need no special case. */ +/** + * Replays supplied events; accepts and discards audio so callers need no special case. With `followsAudio` it + * answers like a live provider: an event comes at its `startedAtMs` after the session opened, and the replay ends + * after `end()` (the audio ran out or the user stopped), so a test sees partial text while audio still flows. + */ export function createFixtureStt(options: { events: LiveTranscriptEvent[]; accountId?: string; signal?: AbortSignal; + followsAudio?: boolean; }): LiveSttSession { let closed = false; let audioBytes = 0; + const opened = Date.now(); + let markEnded = () => {}; + const ended = new Promise<void>((resolve) => { + markEnded = resolve; + }); return { provider: "fixture" satisfies SttProviderId, accountId: options.accountId, @@ -16,6 +27,7 @@ export function createFixtureStt(options: { }, end() { // Nothing pending: the fixture never buffers. + markEnded(); }, async *events() { for (const event of options.events) { @@ -24,11 +36,23 @@ export function createFixtureStt(options: { return; } + if (options.followsAudio) { + const wait = event.startedAtMs - (Date.now() - opened); + if (wait > 0) { + await abortableSleep(wait, options.signal); + } + } + yield event; } + + if (options.followsAudio && !closed) { + await ended; + } }, async close() { closed = true; + markEnded(); void audioBytes; }, }; diff --git a/src/utils/ai/stt/resolve.ts b/src/utils/ai/stt/resolve.ts index c124493bd2..2bd98ab2a5 100644 --- a/src/utils/ai/stt/resolve.ts +++ b/src/utils/ai/stt/resolve.ts @@ -109,7 +109,12 @@ export async function openLiveStt(options: OpenLiveSttOptions): Promise<LiveSttS options.signal?.throwIfAborted(); if (provider === "fixture") { - return createFixtureStt({ events: options.events ?? [], accountId: options.account, signal: options.signal }); + return createFixtureStt({ + events: options.events ?? [], + accountId: options.account, + signal: options.signal, + followsAudio: options.fixtureFollowsAudio, + }); } const account = await resolveSttAccount({ provider, account: options.account }); diff --git a/src/utils/ai/stt/types.ts b/src/utils/ai/stt/types.ts index e6081d8c4a..df1eee16d1 100644 --- a/src/utils/ai/stt/types.ts +++ b/src/utils/ai/stt/types.ts @@ -40,6 +40,11 @@ export interface OpenLiveSttOptions { signal?: AbortSignal; /** Fixture replay events (`provider: "fixture"` only). */ events?: LiveTranscriptEvent[]; + /** + * Real audio flows into this session (fixture only): each event waits for its `startedAtMs` and the replay ends + * only after `end()`, as a provider answering live audio does. Without it the fixture replays at once. + */ + fixtureFollowsAudio?: boolean; } export interface LiveSttSession { diff --git a/src/utils/ai/transcripts/activity-tail.ts b/src/utils/ai/transcripts/activity-tail.ts index 92446d1ac5..3ef447895f 100644 --- a/src/utils/ai/transcripts/activity-tail.ts +++ b/src/utils/ai/transcripts/activity-tail.ts @@ -45,6 +45,14 @@ export function readClaudeActivity(filePath: string, options: ReadClaudeActivity .split("\n") .map((line) => parseTranscriptLine(line)) .filter((record): record is Record<string, unknown> => record !== null); + if (records.length === 0) { + // The state then comes from the modification time alone; say so, or a wrong state has no trace. + logger.debug( + { filePath, tailBytes: ACTIVITY_TAIL_BYTES, size: stat.size }, + "[transcripts] the activity tail holds no whole record; the state comes from mtime alone" + ); + } + const events = claudeRecordsToEvents(records); const state = classifyActivity({ events, diff --git a/src/utils/ai/transcripts/around.ts b/src/utils/ai/transcripts/around.ts index c1f4d1adaf..5eaa6d157c 100644 --- a/src/utils/ai/transcripts/around.ts +++ b/src/utils/ai/transcripts/around.ts @@ -1,13 +1,12 @@ -import { statSync } from "node:fs"; import type { TranscriptAnchor } from "@genesiscz/utils/agent/source-anchor"; import type { ConversationMessage } from "@genesiscz/utils/claude/types"; import { logger } from "@genesiscz/utils/logger"; import { claudeMessagesToTurns } from "./claude"; import { codexNativeLinesToTurns } from "./codex"; import { grokNativeLinesToTurns } from "./grok"; +import { readTailWindow } from "./native-scan"; import { parseTranscriptLine } from "./parse-line"; import type { ResolvedTranscript } from "./resolve"; -import { readRange } from "./turn-index"; import { isoFromRecordTimestamp, type TranscriptTurn } from "./types"; export interface TranscriptAround { @@ -206,10 +205,9 @@ export function transcriptAround({ detail: "Receipt context currently supports native Claude, Codex and Grok transcripts.", }; } - const size = statSync(resolved.filePath).size; const budget = Math.max(1, Math.min(8 * 1024 * 1024, Math.floor(maxBytes))); - const start = Math.max(0, size - budget); - const buffer = readRange(resolved.filePath, start, size); + // A `.jsonl.zst` rollout is windowed over its decompressed content; `size` and offsets count those bytes. + const { buffer, start, size } = readTailWindow(resolved.filePath, budget); result.bytesRead = buffer.length; result.fileSize = size; result.truncated = start > 0; diff --git a/src/utils/ai/transcripts/load.test.ts b/src/utils/ai/transcripts/load.test.ts index 8664df97e8..c34d1806e4 100644 --- a/src/utils/ai/transcripts/load.test.ts +++ b/src/utils/ai/transcripts/load.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import { appendFileSync, mkdirSync, mkdtempSync, renameSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import { codexNativeLinesToTurns, createCodexTurnParser } from "./codex"; import * as fileScan from "./file-scan"; @@ -371,3 +372,61 @@ describe("verified prefix continuation", () => { } }); }); + +describe("a compressed Codex rollout (.jsonl.zst, one zstd frame as Codex 0.164 writes it)", () => { + const at = "2026-10-16T18:00:00.000Z"; + const item = (payload: Record<string, unknown>) => + SafeJSON.stringify({ type: "response_item", timestamp: at, payload }); + const message = (role: "user" | "assistant", text: string) => + item({ type: "message", role, content: [{ type: role === "user" ? "input_text" : "output_text", text }] }); + const text = `${[ + SafeJSON.stringify({ + type: "session_meta", + timestamp: at, + payload: { id: "fixture", base_instructions: { text: "x".repeat(20_000) } }, + }), + SafeJSON.stringify({ type: "turn_context", timestamp: at, payload: { model: "gpt-fixture" } }), + message("user", "first"), + item({ type: "function_call", call_id: "c1", name: "exec_command", arguments: '{"cmd":"ls"}' }), + item({ type: "function_call_output", call_id: "c1", output: "done" }), + message("assistant", "first answer"), + message("user", "second žluťoučký"), + message("assistant", "second answer"), + ].join("\n")}\n`; + + function pair(): { plain: string; zst: string } { + const plain = join(fixtureRoot(), "rollout-2026-10-16T18-00-00-fixture.jsonl"); + writeFileSync(plain, text); + const zst = `${plain}.zst`; + writeFileSync(zst, compressBytes(text)); + return { plain, zst }; + } + + const resolvedOf = (filePath: string): ResolvedTranscript => ({ + provider: "codex", + source: "native", + sessionId: "fixture", + filePath, + }); + + test("loads the same turns as its plain form", async () => { + const { plain, zst } = pair(); + const expected = await transcriptEnvelope(resolvedOf(plain)); + const actual = await transcriptEnvelope(resolvedOf(zst)); + + expect(expected.turns.length).toBeGreaterThan(2); + expect(actual.turns).toEqual(expected.turns); + expect(actual.turnCount).toBe(expected.turnCount); + }); + + test("records and the turn fold read its decompressed lines; a small one is left to the full parse", () => { + const { plain, zst } = pair(); + const full = codexNativeLinesToTurns(fs.readFileSync(plain, "utf8").split("\n")); + + expect(readRecordsAppendOnly(zst)).toEqual(readRecordsAppendOnly(plain)); + expect(readRecordsAppendOnly(zst)).toHaveLength(8); + expect(foldTurnsAppendOnly(zst, createCodexTurnParser, { minBytes: 0 })).toEqual(full); + // The size gate counts decompressed bytes, as the plain form's stat does. + expect(foldTurnsAppendOnly(zst, createCodexTurnParser)).toBeNull(); + }); +}); diff --git a/src/utils/ai/transcripts/native-scan.ts b/src/utils/ai/transcripts/native-scan.ts index a433592033..01e21d4f4a 100644 --- a/src/utils/ai/transcripts/native-scan.ts +++ b/src/utils/ai/transcripts/native-scan.ts @@ -1,4 +1,5 @@ import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { isZstdPath, readZstdFramesSync, zstdContentBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import type { NativeCall, NativeScan, ToolTiming } from "./insights-types"; import { parseTranscriptLine } from "./parse-line"; @@ -218,30 +219,128 @@ export function readTail(path: string, bytes: number): string { return readTailAt(path, bytes).text; } -/** `readTail`, plus the byte offset in the file where the returned text begins (a whole line's start). */ -/** `raw` is the tail's bytes, for callers that count byte offsets: re-encoding `text` changes the length of a line that holds invalid UTF-8. */ -export function readTailAt(path: string, bytes: number): { text: string; start: number; raw: Buffer } { +/** The last bytes of a file's content, cut anywhere: `start` is where `buffer` begins, `size` the content's length. */ +export interface TailWindow { + buffer: Buffer; + start: number; + size: number; +} + +/** + * The last `bytes` of a `.zst` file's decompressed content, frames read from the end until they cover the window. + * A Codex rollout is one frame, so it decompresses whole; a seekable file costs its last frames. Offsets count + * decompressed bytes: the file never grows, so they are stable. + */ +function zstdTailWindow(path: string, bytes: number): TailWindow { + const known = zstdContentBytes(path); + const frames: Buffer[] = []; + let kept = 0; + let seen = 0; + + for (const frame of readZstdFramesSync(path, { fromEnd: true })) { + seen += frame.length; + + if (kept < bytes) { + frames.push(Buffer.from(frame.buffer, frame.byteOffset, frame.length)); + kept += frame.length; + } + + if (kept >= bytes && known !== undefined) { + break; + } + } + + // When the frame headers do not give the content size, every frame was decompressed to count it. + const size = known ?? seen; + const content = Buffer.concat(frames.reverse()); + const buffer = content.subarray(Math.max(0, content.length - bytes)); + return { buffer, start: size - buffer.length, size }; +} + +/** + * The last `bytes` of a file, or of a `.zst` file's decompressed content (Codex 0.164 compresses its old rollouts), + * with no line handling. Throws when the file cannot be read. + */ +export function readTailWindow(path: string, bytes: number): TailWindow { + if (isZstdPath(path)) { + return zstdTailWindow(path, bytes); + } + const fd = openSync(path, "r"); try { const size = fstatSync(fd).size; const start = Math.max(0, size - bytes); const buffer = Buffer.alloc(size - start); - readSync(fd, buffer, 0, buffer.length, start); + let read = 0; + + while (read < buffer.length) { + const got = readSync(fd, buffer, read, buffer.length - read, start + read); - if (start === 0) { - return { text: buffer.toString("utf8"), start: 0, raw: buffer }; + if (got === 0) { + break; + } + + read += got; } - // The window almost certainly starts mid-line: drop that fragment, counted in bytes. - const firstNewline = buffer.indexOf(0x0a); - if (firstNewline === -1) { - return { text: "", start: size, raw: Buffer.alloc(0) }; + return { buffer: buffer.subarray(0, read), start, size }; + } finally { + closeSync(fd); + } +} + +/** + * The first `bytes` of a file, or of a `.zst` file's decompressed content, cut anywhere: for a field near the start of + * a first line too long to read whole (a Codex `session_meta` carries ~20 KB of instructions). Throws when the file + * cannot be read. + */ +export function readHead(path: string, bytes: number): Buffer { + if (isZstdPath(path)) { + const frames: Buffer[] = []; + let kept = 0; + + for (const frame of readZstdFramesSync(path)) { + frames.push(Buffer.from(frame.buffer, frame.byteOffset, frame.length)); + kept += frame.length; + + if (kept >= bytes) { + break; + } } - const raw = buffer.subarray(firstNewline + 1); - return { text: raw.toString("utf8"), start: start + firstNewline + 1, raw }; + return Buffer.concat(frames).subarray(0, bytes); + } + + const fd = openSync(path, "r"); + + try { + const buffer = Buffer.alloc(bytes); + const read = readSync(fd, buffer, 0, bytes, 0); + return buffer.subarray(0, read); } finally { closeSync(fd); } } + +/** + * `readTail`, plus the byte offset where the returned text begins (a whole line's start). For a `.zst` the offsets + * count decompressed bytes. `raw` is the tail's bytes, for callers that count byte offsets: re-encoding `text` + * changes the length of a line that holds invalid UTF-8. + */ +export function readTailAt(path: string, bytes: number): { text: string; start: number; raw: Buffer } { + const { buffer, start, size } = readTailWindow(path, bytes); + + if (start === 0) { + return { text: buffer.toString("utf8"), start: 0, raw: buffer }; + } + + // The window almost certainly starts mid-line: drop that fragment, counted in bytes. + const firstNewline = buffer.indexOf(0x0a); + if (firstNewline === -1) { + return { text: "", start: size, raw: Buffer.alloc(0) }; + } + + const raw = buffer.subarray(firstNewline + 1); + return { text: raw.toString("utf8"), start: start + firstNewline + 1, raw }; +} diff --git a/src/utils/ai/transcripts/record-cache.ts b/src/utils/ai/transcripts/record-cache.ts index cab55bbcf5..17b1bc8bd2 100644 --- a/src/utils/ai/transcripts/record-cache.ts +++ b/src/utils/ai/transcripts/record-cache.ts @@ -1,4 +1,6 @@ import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readLinesSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; import { readPrefixMark } from "./file-scan"; import { parseTranscriptLine } from "./parse-line"; @@ -109,10 +111,38 @@ function readBytes(fd: number, offset: number, length: number): Buffer { return buffer.subarray(0, read); } +/** + * A `.jsonl.zst` rollout (Codex 0.164 compresses old ones) never grows, so nothing is kept: its lines are parsed once + * from its decompressed frames. Its size on disk says nothing about its records, so the size window does not apply. + */ +function readCompressedRecords(path: string): Record<string, unknown>[] { + cache.delete(path); + const records: Record<string, unknown>[] = []; + + for (const line of readLinesSync(path)) { + const parsed = parseTranscriptLine(line); + + if (parsed) { + records.push(parsed); + } + } + + return records; +} + export function readRecordsAppendOnly( path: string, { minCacheBytes = CACHE_MIN_BYTES }: { minCacheBytes?: number } = {} ): Record<string, unknown>[] { + if (isZstdPath(path)) { + try { + return readCompressedRecords(path); + } catch (error) { + logger.debug({ error, path }, "[transcripts] compressed transcript records unreadable"); + throw error; + } + } + let fd: number | null = null; try { fd = openSync(path, "r"); diff --git a/src/utils/ai/transcripts/render/raw.ts b/src/utils/ai/transcripts/render/raw.ts index 90e8156a6c..ec1fa232be 100644 --- a/src/utils/ai/transcripts/render/raw.ts +++ b/src/utils/ai/transcripts/render/raw.ts @@ -1,4 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import type { TranscriptEnvelope } from "../types"; import { type RenderContext, TranscriptRenderer } from "./renderer"; @@ -32,7 +34,10 @@ export class RawRenderer extends TranscriptRenderer { this.offset = 0; } - const bytes = readFileSync(envelope.filePath); + // A `.jsonl.zst` rollout (Codex 0.164 compresses old ones) prints its decompressed lines; it never grows. + const bytes = isZstdPath(envelope.filePath) + ? Buffer.from(readWholeTextSync(envelope.filePath)) + : readFileSync(envelope.filePath); if (bytes.length <= this.offset) { return; } diff --git a/src/utils/ai/transcripts/render/render.test.ts b/src/utils/ai/transcripts/render/render.test.ts index b7e53871d9..4e229a78ab 100644 --- a/src/utils/ai/transcripts/render/render.test.ts +++ b/src/utils/ai/transcripts/render/render.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import { grokWorkerTextToTurns } from "../grok"; import { type TranscriptEnvelope, type TranscriptTurn, terminatedOf, totalsOf } from "../types"; @@ -299,6 +300,19 @@ describe("RawRenderer", () => { renderer.close(ctx); expect(lines).toEqual(['{"type":"text","data":"héllo → wörld"}', '{"type":"end"}']); }); + + test("a compressed rollout (.jsonl.zst) prints its decompressed lines, once", () => { + const dir = mkdtempSync(join(tmpdir(), "raw-render-")); + const path = join(dir, "rollout-fixture.jsonl.zst"); + writeFileSync(path, compressBytes('{"type":"text","data":"héllo"}\n{"type":"end"}\n')); + + const { ctx, lines } = capture({ follow: true }); + const renderer = rendererFor("raw"); + renderer.envelope(envelopeOf([], { filePath: path }), ctx); + renderer.envelope(envelopeOf([], { filePath: path }), ctx); + renderer.close(ctx); + expect(lines).toEqual(['{"type":"text","data":"héllo"}', '{"type":"end"}']); + }); }); describe("formatTotals", () => { diff --git a/src/utils/ai/transcripts/resolve.test.ts b/src/utils/ai/transcripts/resolve.test.ts index f581858bfb..ab9d4704b4 100644 --- a/src/utils/ai/transcripts/resolve.test.ts +++ b/src/utils/ai/transcripts/resolve.test.ts @@ -92,6 +92,37 @@ describe("resolveTranscript", () => { expect(hit.sessionId).toBe(id); }); + test("finds a compressed codex rollout (.jsonl.zst) and one directly in archived_sessions, with the thread id", async () => { + const root = fixtureRoot(); + const roots = { + grokHome: join(root, "empty-grok"), + grokWorker: join(root, "empty-worker"), + claudeProjects: join(root, "empty-claude"), + codexHome: join(root, "codex"), + codexWorker: join(root, "empty-codex-w"), + }; + const compressed = "eeeeeeee-1111-2222-3333-444444444444"; + const dir = join(root, "codex", "sessions", "2026", "10", "01"); + mkdirSync(dir, { recursive: true }); + const zst = join(dir, `rollout-2026-10-01T09-00-00-${compressed}.jsonl.zst`); + writeFileSync(zst, "not read here"); + const archived = "dddddddd-1111-2222-3333-444444444444"; + mkdirSync(join(root, "codex", "archived_sessions"), { recursive: true }); + const flat = join(root, "codex", "archived_sessions", `rollout-2026-03-10T20-11-50-${archived}.jsonl.zst`); + writeFileSync(flat, "not read here"); + + expect(await resolveTranscript(compressed.slice(0, 8), roots)).toMatchObject({ + provider: "codex", + source: "native", + filePath: zst, + sessionId: compressed, + }); + expect(await resolveTranscript(archived.slice(0, 8), roots)).toMatchObject({ + filePath: flat, + sessionId: archived, + }); + }); + test("finds a claude jsonl under an injected projects dir", async () => { const root = fixtureRoot(); const id = "12345678-aaaa-bbbb-cccc-ddddeeee0001"; diff --git a/src/utils/ai/transcripts/resolve.ts b/src/utils/ai/transcripts/resolve.ts index 93a1051ff0..7da537dfd2 100644 --- a/src/utils/ai/transcripts/resolve.ts +++ b/src/utils/ai/transcripts/resolve.ts @@ -7,7 +7,7 @@ import { sessionsDir as codexWorkerDir } from "@genesiscz/utils/codex/worker-pat import { env } from "@genesiscz/utils/env"; import { sessionsDir as grokWorkerDir } from "@genesiscz/utils/grok/worker-paths"; import { SafeJSON } from "@genesiscz/utils/json"; -import { nativeSessionRoots } from "@genesiscz/utils/providers/session-paths"; +import { isNativeTranscript, nativeSessionRoots, transcriptStem } from "@genesiscz/utils/providers/session-paths"; import type { TranscriptProvider } from "./types"; export type TranscriptSource = "native" | "worker"; @@ -250,7 +250,7 @@ function resolvePath( provider?: TranscriptProvider ): ResolvedTranscript | null { const filePath = resolve(path); - const stem = basename(filePath).replace(/\.jsonl$/, ""); + const stem = transcriptStem(basename(filePath)); const located: Array<{ root: string; provider: TranscriptProvider; source: TranscriptSource }> = [ ...claudeRoots.map((root) => ({ root, provider: "claude" as const, source: "native" as const })), { root: roots.claudeWorker, provider: "claude", source: "worker" }, @@ -431,48 +431,55 @@ function splitCodexHomes(codexHome: string): string[] { } function codexNativeSessionId(entry: string): string { - const stem = basename(entry, ".jsonl").replace(/^rollout-/, ""); + const stem = transcriptStem(basename(entry)).replace(/^rollout-/, ""); const uuid = stem.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); return uuid?.[0] ?? stem; } +/** Files of one Codex bucket: the `YYYY/MM/DD` tree, and files directly in it (`archived_sessions` is flat). */ +function* codexBucketEntries(root: string, fs: FolderReads): Generator<{ dir: string; entry: string }> { + for (const year of fs.listDir(root)) { + const yearDir = join(root, year); + if (!fs.isDir(yearDir)) { + yield { dir: root, entry: year }; + continue; + } + for (const month of fs.listDir(yearDir)) { + const monthDir = join(yearDir, month); + if (!fs.isDir(monthDir)) { + continue; + } + for (const day of fs.listDir(monthDir)) { + const dayDir = join(monthDir, day); + if (!fs.isDir(dayDir)) { + continue; + } + for (const entry of fs.listDir(dayDir)) { + yield { dir: dayDir, entry }; + } + } + } + } +} + function findCodexNative(query: string, codexHome: string, fs: FolderReads): RankedHit[] { const hits: RankedHit[] = []; const homes = splitCodexHomes(codexHome); for (const home of homes) { for (const bucket of ["sessions", "archived_sessions"]) { - const root = join(home, bucket); - for (const year of fs.listDir(root)) { - const yearDir = join(root, year); - if (!fs.isDir(yearDir)) { + for (const { dir, entry } of codexBucketEntries(join(home, bucket), fs)) { + // Codex 0.164 compresses old rollouts to `.jsonl.zst`. + if (!isNativeTranscript("codex", entry) || !idMatches(query, entry)) { continue; } - for (const month of fs.listDir(yearDir)) { - const monthDir = join(yearDir, month); - if (!fs.isDir(monthDir)) { - continue; - } - for (const day of fs.listDir(monthDir)) { - const dayDir = join(monthDir, day); - if (!fs.isDir(dayDir)) { - continue; - } - for (const entry of fs.listDir(dayDir)) { - if (!entry.endsWith(".jsonl") || !idMatches(query, entry)) { - continue; - } - const filePath = join(dayDir, entry); - const id = codexNativeSessionId(entry); - hits.push({ - provider: "codex", - source: "native", - sessionId: id, - filePath, - mtime: fs.mtimeOf(filePath), - }); - } - } - } + const filePath = join(dir, entry); + hits.push({ + provider: "codex", + source: "native", + sessionId: codexNativeSessionId(entry), + filePath, + mtime: fs.mtimeOf(filePath), + }); } } } diff --git a/src/utils/ai/transcripts/tail.test.ts b/src/utils/ai/transcripts/tail.test.ts index 0efe4bfa7f..007270be01 100644 --- a/src/utils/ai/transcripts/tail.test.ts +++ b/src/utils/ai/transcripts/tail.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import type { ResolvedTranscript } from "./resolve"; import { followTranscript } from "./tail"; @@ -262,4 +263,43 @@ describe("followTranscript", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("a compressed rollout (.jsonl.zst) emits its envelope once and is not followed", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-tail-")); + const file = join(dir, "rollout-fixture.jsonl.zst"); + writeFileSync( + file, + compressBytes( + `${SafeJSON.stringify({ + type: "event_msg", + timestamp: "2026-10-16T18:00:00.000Z", + payload: { type: "agent_message", message: "from the archive" }, + })}\n` + ) + ); + const envelopes: TranscriptEnvelope[] = []; + const ac = new AbortController(); + const done = followTranscript( + { provider: "codex", source: "native", sessionId: "tail-zst", filePath: file }, + { + signal: ac.signal, + onEnvelope: (envelope) => { + envelopes.push(envelope); + }, + } + ); + + try { + // Returns by itself: nothing appends to a compressed rollout. The sleep only runs out on a regression. + const outcome = await Promise.race([ + done.then(() => "returned"), + Bun.sleep(1500).then(() => "still following"), + ]); + expect(outcome).toBe("returned"); + expect(envelopes.map((envelope) => envelope.turns[0]?.text)).toEqual(["from the archive"]); + } finally { + ac.abort(); + await done; + } + }); }); diff --git a/src/utils/ai/transcripts/tail.ts b/src/utils/ai/transcripts/tail.ts index 54c99a535d..364c45c1a7 100644 --- a/src/utils/ai/transcripts/tail.ts +++ b/src/utils/ai/transcripts/tail.ts @@ -1,3 +1,4 @@ +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; import { FileTailer } from "@genesiscz/utils/fs/file-tailer"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; @@ -93,6 +94,15 @@ export async function followTranscript(resolved: ResolvedTranscript, opts: Follo await emit(); + if (isZstdPath(resolved.filePath)) { + // A `.jsonl.zst` rollout is one Codex closed and compressed: nothing appends to it, so there is nothing to follow. + logger.debug( + { path: resolved.filePath }, + "transcript follow: a compressed transcript is closed; not following" + ); + return; + } + const onLine = (): void => { opts.onGrowth?.(); schedule(); diff --git a/src/utils/ai/transcripts/turn-fold-cache.ts b/src/utils/ai/transcripts/turn-fold-cache.ts index d29500de88..39558b42f9 100644 --- a/src/utils/ai/transcripts/turn-fold-cache.ts +++ b/src/utils/ai/transcripts/turn-fold-cache.ts @@ -1,4 +1,6 @@ import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs"; +import { isZstdPath, zstdContentBytes } from "@genesiscz/utils/compress/zstd"; +import { readLinesSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; import { readPrefixMark } from "./file-scan"; import { parseTranscriptLine } from "./parse-line"; @@ -55,6 +57,54 @@ function scheduleSweep(): void { sweep.unref?.(); } +/** + * A `.jsonl.zst` rollout (Codex 0.164 compresses old ones): its lines go through a fresh parser once, frame by frame, + * and nothing is kept, because the file never grows. The size gate counts its decompressed bytes; a small file, or an + * app-server event file, is left to the full parse as in the plain form. + */ +function foldCompressed(path: string, create: () => TurnFold, minBytes: number): TranscriptTurn[] | null { + folds.delete(path); + + try { + const contentBytes = zstdContentBytes(path); + + if (contentBytes !== undefined && contentBytes < minBytes) { + return null; + } + + const fold = create(); + let records: Record<string, unknown>[] = []; + + for (const line of readLinesSync(path)) { + const record = parseTranscriptLine(line); + + if (!record) { + continue; + } + + if ("method" in record) { + return null; + } + + records.push(record); + + if (records.length >= FOLD_BATCH_RECORDS) { + fold.push(records); + records = []; + } + } + + if (records.length > 0) { + fold.push(records); + } + + return fold.snapshot(); + } catch (error) { + logger.debug({ error, path }, "[transcripts] compressed transcript fold unreadable; parsing the whole file"); + return null; + } +} + function keep(path: string, entry: FoldEntry): void { folds.delete(path); folds.set(path, entry); @@ -84,6 +134,10 @@ export function foldTurnsAppendOnly( create: () => TurnFold, { minBytes = FOLD_MIN_BYTES }: { minBytes?: number } = {} ): TranscriptTurn[] | null { + if (isZstdPath(path)) { + return foldCompressed(path, create, minBytes); + } + let fd: number | null = null; try { // A stat, not an open, for the common small file: the full parse opens it next. diff --git a/src/utils/ai/transcripts/turn-state.test.ts b/src/utils/ai/transcripts/turn-state.test.ts index a220dcc344..f19caebefd 100644 --- a/src/utils/ai/transcripts/turn-state.test.ts +++ b/src/utils/ai/transcripts/turn-state.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { appendFileSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { compressBytes, SeekableZstdWriter } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; +import { readHead, readTailAt } from "./native-scan"; import { claudeTurnState, codexTurnState, @@ -383,6 +385,68 @@ describe("readTurnState", () => { appendFileSync(file, `${SafeJSON.stringify(grokLine(1, "user_message_chunk", text("second")))}\n`); expect(readTurnState("grok", file, { stallTimeoutMs: 900_000 })?.turnStartOffset).toBe(before); }); + + it("reads a compressed Codex rollout (.jsonl.zst) from its content, and a closed one is FINISHED, never RUNNING", () => { + const dir = mkdtempSync(join(tmpdir(), "turn-state-")); + const plain = join(dir, "rollout-fixture.jsonl"); + const lines = [ + codexLine(0, "event_msg", { type: "task_started" }), + codexLine(4, "event_msg", { type: "task_complete", last_agent_message: "Shipped." }), + codexLine(5, "event_msg", { type: "task_started" }), + codexLine(6, "response_item", { type: "reasoning" }), + ]; + writeFileSync(plain, `${lines.map((line) => SafeJSON.stringify(line)).join("\n")}\n`); + const zst = `${plain}.zst`; + writeFileSync(zst, compressBytes(readFileSync(plain))); + // Just written, mid-turn: the plain file reads as running, as a live rollout would. + const options = { now: Date.now(), stallTimeoutMs: 900_000 }; + const live = readTurnState("codex", plain, options); + const closed = readTurnState("codex", zst, options); + + expect(live?.state).toBe("RUNNING"); + expect(closed?.state).toBe("FINISHED"); + expect(closed?.turnStartedAt).toBe(live?.turnStartedAt ?? -1); + expect(closed?.lastText).toBe(live?.lastText ?? "-"); + expect(closed?.tailStartOffset).toBe(0); + + // A turn that ended waits at the prompt in a live session; nobody waits at a closed one. + const ended = join(dir, "ended.jsonl.zst"); + writeFileSync( + ended, + compressBytes( + `${lines + .slice(0, 2) + .map((line) => SafeJSON.stringify(line)) + .join("\n")}\n` + ) + ); + const endedState = readTurnState("codex", ended, options); + expect(endedState?.state).toBe("FINISHED"); + expect(endedState?.lastText).toBe("Shipped."); + }); + + it("cuts a .zst tail and head at the same content offsets as the plain file, across seekable frames too", async () => { + const dir = mkdtempSync(join(tmpdir(), "turn-state-")); + const plain = join(dir, "rollout-fixture.jsonl"); + const text = Array.from({ length: 200 }, (_, i) => `{"n":${i},"text":"${"é".repeat(i % 13)}"}\n`).join(""); + writeFileSync(plain, text); + const single = `${plain}.zst`; + writeFileSync(single, compressBytes(text)); + const framed = join(dir, "framed.jsonl.zst"); + const parts: Uint8Array[] = []; + const writer = new SeekableZstdWriter({ write: (bytes) => parts.push(bytes), frameBytes: 256 }); + await writer.push(Buffer.from(text)); + const stats = await writer.finish(); + writeFileSync(framed, Buffer.concat(parts)); + + expect(stats.frames).toBeGreaterThan(4); + for (const zst of [single, framed]) { + for (const bytes of [1, 100, 700, text.length + 10]) { + expect(readTailAt(zst, bytes)).toEqual(readTailAt(plain, bytes)); + expect(readHead(zst, bytes)).toEqual(readHead(plain, bytes)); + } + } + }); }); const INFINITE = Number.POSITIVE_INFINITY; diff --git a/src/utils/ai/transcripts/turn-state.ts b/src/utils/ai/transcripts/turn-state.ts index c3de888897..593a44ed05 100644 --- a/src/utils/ai/transcripts/turn-state.ts +++ b/src/utils/ai/transcripts/turn-state.ts @@ -13,6 +13,7 @@ * one `stat`, one bounded tail read, no process spawn and no write. */ import { statSync } from "node:fs"; +import { isZstdPath } from "@genesiscz/utils/compress/zstd"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { type ActivityEvent, type ActivityState, classifyActivity, claudeRecordsToEvents } from "./activity"; @@ -128,6 +129,12 @@ export interface TurnStateInput { stallTimeoutMs: number; /** The byte offset in the file where each record's line starts, aligned with `records`. */ offsets?: ReadonlyArray<number>; + /** + * True when nothing writes the transcript any more: a `.jsonl.zst` rollout, which Codex 0.164 writes only for a + * session it closed. The state is then `FINISHED`, whatever the last turn did; `asksQuestion`, `lastText` and the + * rest still describe that turn. + */ + closed?: boolean; } function isRecord(value: unknown): value is JsonRecord { @@ -292,12 +299,15 @@ function snapshotOf({ }): TurnSnapshot { const lastEventAt = events.at(-1)?.ts ?? null; const lastActivityAt = Math.max(lastEventAt ?? 0, input.lastModified); - const state = classifyActivity({ - events, - lastModified: input.lastModified, - now: input.now, - stallTimeoutMs: input.stallTimeoutMs, - }); + // A closed transcript has no process behind it: the session is over, and nobody waits at its prompt any more. + const state: ActivityState = input.closed + ? "FINISHED" + : classifyActivity({ + events, + lastModified: input.lastModified, + now: input.now, + stallTimeoutMs: input.stallTimeoutMs, + }); return { // A turn that ended is never a stall, however long the session then sits at its prompt. @@ -934,6 +944,9 @@ export interface ReadTurnStateOptions { * * A last record longer than `TURN_TAIL_BYTES` leaves no whole record in the tail. The state then comes * from the modification time alone: `RUNNING` or `STALLED`, never a finish. + * + * A `.jsonl.zst` rollout is read from its decompressed content (offsets count decompressed bytes) and is closed + * (`TurnStateInput.closed`): Codex compresses only a rollout it no longer writes. */ export function readTurnState( provider: TurnProvider, @@ -975,6 +988,7 @@ export function readTurnState( lastModified: stat.mtimeMs, now, stallTimeoutMs: options.stallTimeoutMs, + ...(isZstdPath(filePath) ? { closed: true } : {}), }), tailStartOffset: tail.start, }; diff --git a/src/utils/ai/usage/account-rank.test.ts b/src/utils/ai/usage/account-rank.test.ts new file mode 100644 index 0000000000..8123e62cef --- /dev/null +++ b/src/utils/ai/usage/account-rank.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test"; +import type { AccountUsageSnapshot, LimitWindow } from "@genesiscz/utils/ai/providers/account-features"; +import { pickRanked, rankAccounts } from "@genesiscz/utils/ai/usage/account-rank"; + +const NOW = new Date("2026-10-25T20:00:00.000Z"); +const inHours = (hours: number) => new Date(NOW.getTime() + hours * 3_600_000).toISOString(); + +function codex(name: string, fiveHour: number, weekly: number, weeklyResetHours = 100): AccountUsageSnapshot { + const limits: LimitWindow[] = [ + { key: "primary", label: "5h", kind: "session", percentUsed: fiveHour, resetsAt: inHours(2) }, + { key: "secondary", label: "Weekly", kind: "weekly", percentUsed: weekly, resetsAt: inHours(weeklyResetHours) }, + ]; + + return { provider: "openai-sub", accountId: name, accountName: name, fetchedAt: NOW.toISOString(), limits }; +} + +function grok(name: string, monthly: number): AccountUsageSnapshot { + return { + provider: "grok-sub", + accountId: name, + accountName: name, + fetchedAt: NOW.toISOString(), + limits: [{ key: "monthly", label: "Monthly", kind: "monthly", percentUsed: monthly, resetsAt: inHours(300) }], + }; +} + +describe("rankAccounts (Codex)", () => { + it("puts ready first by weekly headroom, then session-starved, and never picks blocked or unknown accounts", () => { + const ranked = rankAccounts( + [ + codex("work", 20, 70), + codex("personal", 10, 30), + codex("side", 100, 10), + codex("shop", 5, 100), + { ...codex("spare", 0, 0), limits: [], error: "poll failed" }, + ], + { now: NOW } + ); + + expect(ranked.map((account) => `${account.accountName}:${account.tier}`)).toEqual([ + "personal:ready", + "work:ready", + "side:session-starved", + "shop:weekly-blocked", + "spare:no-data", + ]); + expect(pickRanked(ranked)).toMatchObject({ + pick: { accountName: "personal" }, + runnerUp: { accountName: "work" }, + }); + }); + + it("spreads sessions: headroom is shared by the sessions already on an account", () => { + const ranked = rankAccounts([codex("work", 0, 20), codex("personal", 0, 40)], { + now: NOW, + sessions: { work: 2 }, + }); + + // work: 80 / 3 = 26.7, personal: 60 / 1 = 60. + expect(pickRanked(ranked)?.pick.accountName).toBe("personal"); + }); + + it("prefers headroom that resets unused within a day", () => { + const ranked = rankAccounts([codex("work", 0, 50), codex("personal", 0, 60, 6)], { now: NOW }); + + // personal: 40 left, resets in 6 h, bonus 40 * 0.75 = 30, score 70 > work's 50. + expect(ranked[0]).toMatchObject({ accountName: "personal", resetBonus: 30, score: 70 }); + }); + + it("has no pick when every account is blocked or unknown", () => { + expect(pickRanked(rankAccounts([codex("work", 0, 100)], { now: NOW }))).toBeUndefined(); + }); +}); + +describe("rankAccounts (Grok)", () => { + it("ranks by the monthly window when there is no short one", () => { + const ranked = rankAccounts([grok("work", 90), grok("personal", 40)], { now: NOW }); + + expect(ranked.map((account) => [account.accountName, account.tier, account.longHeadroomPct])).toEqual([ + ["personal", "ready", 60], + ["work", "ready", 10], + ]); + }); +}); diff --git a/src/utils/ai/usage/account-rank.ts b/src/utils/ai/usage/account-rank.ts new file mode 100644 index 0000000000..1eb304da7b --- /dev/null +++ b/src/utils/ai/usage/account-rank.ts @@ -0,0 +1,110 @@ +import type { AccountUsageSnapshot, LimitWindow } from "@genesiscz/utils/ai/providers/account-features"; + +/** + * The shared `auto` account pick (P7) for Codex and Grok, over the provider-neutral usage snapshot. Claude keeps its + * own scorer (`scoreAccounts`), which knows the per-model weekly windows. + * + * Tiers first: `ready`, then `session-starved` (the short window is spent, the long one is not). A `weekly-blocked` + * or `no-data` account is never picked: guessing onto a dead account is the failure `auto` exists to prevent. Inside + * a tier the best share wins: the long window's headroom, plus the part of it that resets unused within a day, + * divided by 1 + the sessions already on the account, so parallel sessions spread (the idea of switcheroo's + * `pickCodexRow`). + */ +export type RankTier = "ready" | "session-starved" | "weekly-blocked" | "no-data"; + +export interface RankedAccount { + accountName: string; + tier: RankTier; + /** 100 minus the long window's percent (weekly; monthly for Grok). */ + longHeadroomPct: number; + sessionHeadroomPct: number; + /** Long-window headroom that resets within a day, weighted by how soon. */ + resetBonus: number; + sessions: number; + score: number; + why: string; +} + +const TIER_ORDER: readonly RankTier[] = ["ready", "session-starved", "weekly-blocked", "no-data"]; +const RESET_SOON_MS = 24 * 3_600_000; +const LONG_KINDS: ReadonlySet<LimitWindow["kind"]> = new Set(["weekly", "monthly"]); + +function mostUsed(windows: LimitWindow[]): LimitWindow | undefined { + return windows.reduce<LimitWindow | undefined>( + (top, window) => (!top || window.percentUsed > top.percentUsed ? window : top), + undefined + ); +} + +function headroom(window: LimitWindow | undefined): number { + return window ? Math.max(0, 100 - window.percentUsed) : 100; +} + +function resetBonusOf(window: LimitWindow | undefined, now: number): number { + const resetsAt = window?.resetsAt ? Date.parse(window.resetsAt) : Number.NaN; + + if (!window || !Number.isFinite(resetsAt) || resetsAt <= now || resetsAt - now >= RESET_SOON_MS) { + return 0; + } + + return headroom(window) * (1 - (resetsAt - now) / RESET_SOON_MS); +} + +function rankOne(snapshot: AccountUsageSnapshot, sessions: number, now: number): RankedAccount { + const base = { accountName: snapshot.accountName, sessions, resetBonus: 0, score: 0 }; + const long = mostUsed(snapshot.limits.filter((window) => LONG_KINDS.has(window.kind) && !window.scopeModel)); + const short = mostUsed(snapshot.limits.filter((window) => window.kind === "session")); + + if (snapshot.error || snapshot.needsLogin || (!long && !short)) { + return { + ...base, + tier: "no-data", + longHeadroomPct: 0, + sessionHeadroomPct: 0, + why: snapshot.needsLogin + ? `needs a login: ${snapshot.needsLogin.remedy}` + : (snapshot.error ?? "no usage data"), + }; + } + + const longHeadroomPct = headroom(long); + const sessionHeadroomPct = headroom(short); + const resetBonus = resetBonusOf(long, now); + const tier: RankTier = + longHeadroomPct <= 0 ? "weekly-blocked" : sessionHeadroomPct <= 0 ? "session-starved" : "ready"; + const score = (longHeadroomPct + resetBonus) / (1 + sessions); + const parts = [ + `${long?.label ?? "long"} ${Math.round(longHeadroomPct)}% left`, + `${short?.label ?? "short"} ${Math.round(sessionHeadroomPct)}% left`, + ...(resetBonus > 0 ? [`resets within a day (+${Math.round(resetBonus)})`] : []), + ...(sessions > 0 ? [`${sessions} session${sessions === 1 ? "" : "s"} on it`] : []), + ]; + + return { ...base, tier, longHeadroomPct, sessionHeadroomPct, resetBonus, score, why: parts.join(", ") }; +} + +/** Every account ranked: tier, then share, then short-window headroom, then name (a stable order). */ +export function rankAccounts( + snapshots: AccountUsageSnapshot[], + options: { sessions?: Record<string, number>; now?: Date } = {} +): RankedAccount[] { + const now = (options.now ?? new Date()).getTime(); + + return snapshots + .map((snapshot) => rankOne(snapshot, options.sessions?.[snapshot.accountName] ?? 0, now)) + .sort( + (a, b) => + TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier) || + b.score - a.score || + b.sessionHeadroomPct - a.sessionHeadroomPct || + a.accountName.localeCompare(b.accountName) + ); +} + +/** The pick and the runner-up among the accounts `auto` may use; undefined when none may be used. */ +export function pickRanked(ranked: RankedAccount[]): { pick: RankedAccount; runnerUp?: RankedAccount } | undefined { + const usable = ranked.filter((account) => account.tier === "ready" || account.tier === "session-starved"); + const [pick, runnerUp] = usable; + + return pick ? { pick, ...(runnerUp ? { runnerUp } : {}) } : undefined; +} diff --git a/src/utils/ai/usage/cache-stats.ts b/src/utils/ai/usage/cache-stats.ts new file mode 100644 index 0000000000..f146ff8c57 --- /dev/null +++ b/src/utils/ai/usage/cache-stats.ts @@ -0,0 +1,276 @@ +/** + * Prompt-cache arithmetic shared by `tools ai-proxy` (sessions, stats, calls), `tools ai-spend` and session-analyzer. + * + * One definition of the hit rate everywhere: cache read / (uncached input + cache read + cache write), the + * session-analyzer definition (GenesisClaude `user/skills/session-analyzer/SKILL.md`, "Cache hit"). Two usage + * conventions feed it: + * - Anthropic-shaped (`input_tokens` or `prompt_tokens` plus `cache_read_input_tokens`, `cache_creation_input_tokens`): + * the cache counts sit OUTSIDE the input count. + * - OpenAI-shaped (`prompt_tokens` with `cached_prompt_tokens` from `prompt_tokens_details.cached_tokens` or + * Responses `input_tokens_details.cached_tokens`): the cached count is a PART of the prompt count. + */ + +/** The prompt-side fields of an ai-proxy usage row (`TokenUsage`), in either convention. */ +export interface PromptTokenFields { + prompt_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + cached_prompt_tokens?: number; +} + +export interface PromptTokenSplit { + /** Input the upstream processed in full. */ + uncached: number; + cacheRead: number; + cacheWrite: number; +} + +/** Split one usage row into uncached input, cache read and cache write, whichever convention it uses. */ +export function splitPromptTokens(usage: PromptTokenFields | undefined): PromptTokenSplit { + const prompt = usage?.prompt_tokens ?? 0; + + if (usage?.cache_read_input_tokens !== undefined || usage?.cache_creation_input_tokens !== undefined) { + return { + uncached: prompt, + cacheRead: usage.cache_read_input_tokens ?? 0, + cacheWrite: usage.cache_creation_input_tokens ?? 0, + }; + } + + const cached = Math.min(usage?.cached_prompt_tokens ?? 0, prompt); + return { uncached: prompt - cached, cacheRead: cached, cacheWrite: 0 }; +} + +/** Cache read as a percent of all input (uncached + read + write); undefined when there was no input at all. */ +export function hitPercent(split: PromptTokenSplit): number | undefined { + const total = split.uncached + split.cacheRead + split.cacheWrite; + return total > 0 ? (split.cacheRead / total) * 100 : undefined; +} + +/** One request of a session, in session-analyzer's `TraceCall` terms (tokens split by {@link splitPromptTokens}). */ +export interface CacheCall { + /** epoch ms */ + t: number; + model: string; + /** Uncached input tokens. */ + input: number; + cacheRead: number; + cacheWrite: number; + output?: number; + /** Cache write lifetime, when the usage says (Anthropic `cache_creation.ephemeral_*`). */ + ttl?: "5m" | "1h" | "mixed"; + /** The prompt cache key the request carried, when known. */ + key?: string; + /** Claude Code's compaction turn. */ + compaction?: boolean; +} + +// Shared with session-analyzer scripts/session-dashboard-shared.ts ctxOf; ported 2026-10-25 from GenesisClaude 937ab1d. +// Keep both copies in step until session-analyzer imports this module. +export const ctxOf = (c: Pick<CacheCall, "input" | "cacheRead" | "cacheWrite">): number => + c.input + c.cacheRead + c.cacheWrite; + +const r2 = (n: number): number => Math.round(n * 100) / 100; + +// Shared with session-analyzer scripts/claude/session-dashboard.ts ttlOf; ported 2026-10-25 from GenesisClaude 937ab1d. +// Keep both copies in step until session-analyzer imports this module. +export function cacheTtlOf(usage: { + cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number }; +}): "5m" | "1h" | "mixed" | undefined { + const h1 = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0; + const m5 = usage.cache_creation?.ephemeral_5m_input_tokens ?? 0; + + if (h1 && m5) { + return "mixed"; + } + + if (h1) { + return "1h"; + } + + return m5 ? "5m" : undefined; +} + +// Shared with session-analyzer scripts/session-dashboard-shared.ts missReason; ported 2026-10-25 from GenesisClaude +// 937ab1d. Keep both copies in step until session-analyzer imports this module. +/** Why the cache missed, from what the session shows around the call. */ +export function missReason(c: CacheCall, prev: CacheCall | undefined, compactedSincePrev: boolean): string { + if (!prev) { + return "first call of the session (nothing cached yet)"; + } + + if (compactedSincePrev) { + return "compaction rewrote the conversation"; + } + + if (prev.model !== c.model) { + return `model changed (${prev.model} to ${c.model})`; + } + + const gapMin = (c.t - prev.t) / 60_000; + + if (gapMin > 60) { + return `idle ${Math.round(gapMin)} min: past the 1 h cache lifetime`; + } + + if (gapMin > 5 && c.ttl !== "1h") { + return `idle ${Math.round(gapMin)} min: past the 5 min cache lifetime`; + } + + return "prefix changed (tools, system prompt or an edited earlier message)"; +} + +/** A call whose prompt was mostly written to the cache again, or sent uncached, rather than read from it. */ +export interface CacheMiss { + call: number; + t: number; + ctx: number; + /** Tokens the call did not read from the cache: written (Anthropic) or sent uncached (Codex, Grok). */ + written: number; + /** Minutes since the previous call. */ + gapMin: number | null; + reason: string; +} + +// Shared with session-analyzer scripts/session-dashboard-shared.ts findMisses; ported 2026-10-25 from GenesisClaude +// 937ab1d (takes the calls instead of a SessionTrace; a call's own `compaction` flag replaces the compaction list). +// Keep both copies in step until session-analyzer imports this module. +/** Misses: a call where at least `minTokens` and at least `minShare` of its prompt did not come from the cache. */ +export function findMisses(calls: CacheCall[], minTokens = 20_000, minShare = 0.3): CacheMiss[] { + const out: CacheMiss[] = []; + + for (let i = 0; i < calls.length; i++) { + const c = calls[i]; + const prev = calls[i - 1]; + const ctx = ctxOf(c); + const missed = c.cacheWrite + c.input; + + if (missed < minTokens || missed < minShare * ctx) { + continue; + } + + out.push({ + call: i, + t: c.t, + ctx, + written: missed, + gapMin: prev ? r2((c.t - prev.t) / 60_000) : null, + reason: missReason(c, prev, prev?.compaction === true), + }); + } + + return out; +} + +/** A request whose cache read fell sharply against the previous request of the same session. */ +export interface CacheLoss { + call: number; + t: number; + /** Cache read of the previous request, then of this one. */ + before: number; + after: number; + gapMin: number; + cause: "key changed" | "model changed" | "compaction" | "idle" | "unknown"; + reason: string; +} + +/** + * Cache losses: the cache read dropped by more than `minShare` (default 50%) and by at least `minTokens` (default + * 2,048) against the previous request. Ordered calls of ONE session or thread; never pair a main loop with a + * subagent. The cause is the first that applies: the key changed, the model changed, the previous request was a + * compaction, the gap passed the cache lifetime, else unknown. + */ +export function findCacheLosses(calls: CacheCall[], minShare = 0.5, minTokens = 2_048): CacheLoss[] { + const out: CacheLoss[] = []; + + for (let i = 1; i < calls.length; i++) { + const c = calls[i]; + const prev = calls[i - 1]; + const fell = prev.cacheRead - c.cacheRead; + + if (fell < minTokens || fell <= minShare * prev.cacheRead) { + continue; + } + + const gapMin = r2((c.t - prev.t) / 60_000); + let cause: CacheLoss["cause"] = "unknown"; + + if (prev.key !== undefined && c.key !== undefined && prev.key !== c.key) { + cause = "key changed"; + } else if (prev.model !== c.model) { + cause = "model changed"; + } else if (prev.compaction) { + cause = "compaction"; + } else if (gapMin > 60 || (gapMin > 5 && c.ttl !== "1h")) { + cause = "idle"; + } + + const reason = + cause === "key changed" + ? `cache key changed (${prev.key} to ${c.key})` + : missReason(c, prev, prev.compaction === true); + out.push({ call: i, t: c.t, before: prev.cacheRead, after: c.cacheRead, gapMin, cause, reason }); + } + + return out; +} + +export interface CacheSummary { + requests: number; + uncached: number; + cacheRead: number; + cacheWrite: number; + output: number; + /** Over the whole list: read / (uncached + read + write). */ + hitPercent: number | undefined; + /** Per-request hit %, for the spread. */ + p10: number | undefined; + median: number | undefined; + p90: number | undefined; + losses: number; +} + +function quantile(sorted: number[], q: number): number | undefined { + if (sorted.length === 0) { + return undefined; + } + + const at = (sorted.length - 1) * q; + const lo = Math.floor(at); + const hi = Math.ceil(at); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo); +} + +/** Totals, hit % and its spread for one list of calls (one session, or a pool of requests). */ +export function summarizeCache(calls: CacheCall[], losses = findCacheLosses(calls).length): CacheSummary { + const totals = { uncached: 0, cacheRead: 0, cacheWrite: 0, output: 0 }; + const perRequest: number[] = []; + + for (const c of calls) { + totals.uncached += c.input; + totals.cacheRead += c.cacheRead; + totals.cacheWrite += c.cacheWrite; + totals.output += c.output ?? 0; + const hit = hitPercent({ uncached: c.input, cacheRead: c.cacheRead, cacheWrite: c.cacheWrite }); + + if (hit !== undefined) { + perRequest.push(hit); + } + } + + perRequest.sort((a, b) => a - b); + + return { + requests: calls.length, + ...totals, + hitPercent: hitPercent({ + uncached: totals.uncached, + cacheRead: totals.cacheRead, + cacheWrite: totals.cacheWrite, + }), + p10: quantile(perRequest, 0.1), + median: quantile(perRequest, 0.5), + p90: quantile(perRequest, 0.9), + losses, + }; +} diff --git a/src/utils/ai/usage/usage.test.ts b/src/utils/ai/usage/usage.test.ts index 7930650ed0..db509fa1da 100644 --- a/src/utils/ai/usage/usage.test.ts +++ b/src/utils/ai/usage/usage.test.ts @@ -7,6 +7,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; import { openRouterPricingSync, resetOpenRouterCatalogCache } from "../catalog/openrouter"; import { createEvaluatorWithProviderFactory } from "../evaluation/service"; import { JEV_USAGE_APP, jevCalls, jevSpend, UNLABELED_JEV_USE } from "../evaluation/spend"; +import { type CacheCall, cacheTtlOf, findCacheLosses, findMisses, missReason, summarizeCache } from "./cache-stats"; import { dayFilePath, usageDir, utcDayOf } from "./paths"; import { queryUsage } from "./query"; import { recordUsage } from "./record"; @@ -572,3 +573,87 @@ describe("Jev spend", () => { expect(Object.hasOwn(Object.prototype, "calls")).toBe(false); }); }); + +describe("cache stats (the session-analyzer fixtures, same answers)", () => { + const MIN = 60_000; + const call = (t: number, over: Partial<CacheCall> = {}): CacheCall => ({ + t, + model: "m1", + input: 10, + cacheRead: 400_000, + cacheWrite: 2_000, + output: 500, + ...over, + }); + + test("missReason names the cause from the gap, the TTL, a compaction and a model change", () => { + const prev = call(0); + + expect(missReason(call(MIN), undefined, false)).toContain("first call"); + expect(missReason(call(MIN), prev, true)).toContain("compaction"); + expect(missReason(call(MIN, { model: "m2" }), prev, false)).toContain("model changed"); + expect(missReason(call(10 * MIN), prev, false)).toContain("5 min"); + expect(missReason(call(10 * MIN, { ttl: "1h" }), prev, false)).toContain("prefix changed"); + expect(missReason(call(90 * MIN, { ttl: "1h" }), prev, false)).toContain("1 h"); + }); + + test("findMisses keeps only big rewrites and counts uncached input as missed (Codex has no cache writes)", () => { + const misses = findMisses([ + call(0, { cacheRead: 0, cacheWrite: 300_000 }), + call(MIN), + call(2 * MIN, { cacheRead: 100_000, cacheWrite: 0, input: 200_000 }), + call(30 * MIN, { cacheRead: 0, cacheWrite: 410_000 }), + ]); + + expect(misses.map((miss) => miss.call)).toEqual([0, 2, 3]); + expect(misses[1].written).toBe(200_000); + expect(misses[2].reason).toContain("idle 28 min"); + expect(misses[2].gapMin).toBe(28); + expect( + findMisses([call(0, { compaction: true }), call(2 * MIN, { cacheRead: 0, cacheWrite: 90_000 })])[0]?.reason + ).toContain("compaction"); + }); + + test("findCacheLosses flags a fall of over half and at least 2,048 tokens, with its cause", () => { + const losses = findCacheLosses([ + call(0, { key: "a" }), + call(MIN, { key: "b", cacheRead: 1_000 }), + call(2 * MIN, { key: "b", cacheRead: 300_000 }), + call(3 * MIN, { key: "b", cacheRead: 200_000 }), + call(4 * MIN, { key: "b", compaction: true }), + call(5 * MIN, { key: "b", cacheRead: 5_000 }), + call(6 * MIN, { key: "b", cacheRead: 4_000 }), + call(20 * MIN, { key: "b", cacheRead: 0 }), + ]); + + // 300,000 to 200,000 fell by a third: not a loss. 5,000 to 4,000 fell under 2,048 tokens: not a loss. + expect(losses.map((loss) => [loss.call, loss.cause])).toEqual([ + [1, "key changed"], + [5, "compaction"], + [7, "idle"], + ]); + expect(losses[0]).toMatchObject({ before: 400_000, after: 1_000 }); + // 4,000 to 0 is all of it, but under 2,048 tokens fell: not a loss. + expect(findCacheLosses([call(0, { cacheRead: 4_000 }), call(20 * MIN, { cacheRead: 0 })], 0.5, 5_000)).toEqual( + [] + ); + expect(findCacheLosses([call(0), call(20 * MIN, { cacheRead: 0 })])[0]?.cause).toBe("idle"); + }); + + test("summarizeCache: one hit % over the whole list, and the per-request spread", () => { + const summary = summarizeCache([ + call(0, { input: 1_000, cacheRead: 0, cacheWrite: 0 }), + call(MIN, { input: 100, cacheRead: 900, cacheWrite: 0 }), + call(2 * MIN, { input: 0, cacheRead: 1_000, cacheWrite: 0 }), + ]); + + expect(summary).toMatchObject({ requests: 3, uncached: 1_100, cacheRead: 1_900, cacheWrite: 0, output: 1_500 }); + expect(summary.hitPercent).toBeCloseTo((1_900 / 3_000) * 100, 6); + expect(summary.median).toBe(90); + expect(summary.p10).toBeCloseTo(18, 6); + expect(summary.p90).toBeCloseTo(98, 6); + expect(cacheTtlOf({ cache_creation: { ephemeral_1h_input_tokens: 5, ephemeral_5m_input_tokens: 1 } })).toBe( + "mixed" + ); + }); +}); diff --git a/src/utils/ai/voice/session.ts b/src/utils/ai/voice/session.ts index 2acb81b366..9f6847069f 100644 --- a/src/utils/ai/voice/session.ts +++ b/src/utils/ai/voice/session.ts @@ -40,6 +40,8 @@ export async function createVoiceSession( realtime?: boolean; maxDurationMs?: number; onEvent: (event: VoiceEvent) => void; + /** Each captured frame (s16le mono at the source rate), as it goes to the provider: `voice listen --save-audio`. */ + onAudio?: (frame: Uint8Array) => void; } ): Promise<VoiceSession> { options.signal?.throwIfAborted(); @@ -63,7 +65,11 @@ export async function createVoiceSession( throw new Error("Input none is reserved for fixture replay"); } - opened = await openLiveStt({ ...options, signal: transport.signal }); + opened = await openLiveStt({ + ...options, + signal: transport.signal, + fixtureFollowsAudio: options.input !== "none", + }); options.signal?.throwIfAborted(); if (options.input !== "none") { source = await openPcmSource({ @@ -118,6 +124,7 @@ export async function createVoiceSession( for await (const frame of source.frames()) { capture.signal.throwIfAborted(); session.write(frame); + options.onAudio?.(frame); options.onEvent({ kind: "level", rms: pcmRms(frame) }); } } diff --git a/src/utils/app-links/targets.ts b/src/utils/app-links/targets.ts index 98deb7915e..6c4ee5f056 100644 --- a/src/utils/app-links/targets.ts +++ b/src/utils/app-links/targets.ts @@ -20,6 +20,13 @@ export const HUB_LINK_KEYS = [ "tab", "filter", "worktree", + // Who asked for the place, for the hub's navigation history (`tools hub nav open`): `actor` is user, agent or + // link (a link without it counts as link), `note` says why, `from` is the agent's session id, `nav` the id the + // CLI already stored the entry under, so the hub updates that entry instead of adding a second one. + "actor", + "note", + "from", + "nav", ] as const; export type HubLinkQuery = Partial<Record<(typeof HUB_LINK_KEYS)[number], string>>; @@ -34,6 +41,10 @@ export interface LinkTargets { /** The GenesisTools hub on a PR, a commit in it, a repository or a session. */ open: HubLinkQuery; }; + finder: { + /** Show a file or a folder in Finder. Only the router serves it: no app scheme opens Finder. */ + reveal: { target: string }; + }; "claude-find": { /** Scroll to DECISION `decision`; oldest by default, or the requested position. */ decision: { session: string; decision: number; at?: "oldest" | "newest" }; @@ -84,6 +95,11 @@ export const APP_LINKS: AppLinkTable = { )}`, }, }, + finder: { + reveal: { + path: (data) => `finder/reveal${linkQuery([["target", data.target]], { keepSlash: true })}`, + }, + }, "claude-find": { decision: { path: (data) => diff --git a/src/utils/async.ts b/src/utils/async.ts index a51483867a..09da4fa640 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -192,6 +192,39 @@ export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> }); } +/** + * Settle with `promise`, or reject as soon as `signal` aborts. For work that takes no signal (a DNS + * lookup, a build another caller shares): the work keeps running in the background; only the wait + * on it ends. + */ +export function untilAborted<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> { + if (!signal) { + return promise; + } + + if (signal.aborted) { + // The caller already gets the abort; the lookup's own outcome is no longer wanted, but its + // rejection must still be observed, or it surfaces later as an unhandled rejection. + promise.catch(() => undefined); + return Promise.reject(signal.reason); + } + + return new Promise<T>((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} + export { startWakefulInterval, WAKEFUL_JUMP_THRESHOLD_MS, diff --git a/src/utils/browser-router/capabilities.ts b/src/utils/browser-router/capabilities.ts index 20e2d3b8cf..5487540ba0 100644 --- a/src/utils/browser-router/capabilities.ts +++ b/src/utils/browser-router/capabilities.ts @@ -6,6 +6,7 @@ import { detectGenesisTools } from "@genesiscz/utils/cli/genesis-tools"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { GENESIS_APP_BUNDLE_ID, genesisAppBundlePath } from "@genesiscz/utils/macos/genesis-app"; +import { appPathForBundleId } from "@genesiscz/utils/macos/launch-services"; /** What a preset needs on this Mac before its routes are written and its links are offered. */ export type Capability = @@ -74,25 +75,15 @@ function evaluate(capability: Capability): boolean { } } -/** Whether Spotlight knows an app with this bundle id, wherever it is installed. */ +/** Whether Launch Services knows an app with this bundle id, wherever it is installed (what `open -b` asks). */ function appInstalled(bundleId: string): boolean { - if (process.platform !== "darwin") { - return false; - } + const path = appPathForBundleId(bundleId); - const run = Bun.spawnSync(["mdfind", `kMDItemCFBundleIdentifier == '${bundleId}'`], { - stdout: "pipe", - stderr: "pipe", - // A busy or broken Spotlight index must not hang `presets` or a route sync. - timeout: 5000, - }); - - if (run.exitCode !== 0) { - logger.debug({ bundleId, stderr: run.stderr.toString() }, "browser-router: mdfind could not look up an app"); - return false; + if (!path) { + logger.debug({ bundleId }, "browser-router: Launch Services knows no app with this bundle id"); } - return run.stdout.toString().trim() !== ""; + return path !== null; } function isRecord(value: unknown): value is Record<string, unknown> { diff --git a/src/utils/browser-router/links.test.ts b/src/utils/browser-router/links.test.ts index 7c5622699e..4b3d119d03 100644 --- a/src/utils/browser-router/links.test.ts +++ b/src/utils/browser-router/links.test.ts @@ -3,11 +3,12 @@ import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, s import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { env } from "@genesiscz/utils/env"; +import { type ProbeFacts, probeGenesisTools } from "@genesiscz/utils/genesis-tools/probe"; import { SafeJSON } from "@genesiscz/utils/json"; import { type LinkGate, linkGate, linkGatePath } from "./link-gate"; import { linkFor, mintLink, planMintedLink, presetEnabled } from "./links"; import { presets } from "./presets"; -import { defaultRouterConfig } from "./route"; +import { defaultRouterConfig, type RouterConfig, route } from "./route"; import { routerLink, schemeLink } from "./router-link"; import { type RouterStatus, routerStatus } from "./status"; import { mintToken, takeToken, tokenFile, withTokenLock } from "./tokens"; @@ -463,3 +464,55 @@ describe("routerLink and the link gate", () => { } }); }); + +describe("probeGenesisTools", () => { + const linkHost = "links.example.test"; + const base = defaultRouterConfig(); + const routesOf = (ids: string[]) => + presets({ config: { ...base, linkHost, routes: [] }, check: () => true }) + .filter((preset) => ids.includes(preset.id)) + .flatMap((preset) => preset.routes); + const config: RouterConfig = { ...base, linkHost, routes: routesOf(["genesis-md", "finder", "claude-find"]) }; + const facts = (overrides: Partial<ProbeFacts> = {}): ProbeFacts => ({ + platform: "darwin", + toolsPath: "/opt/example/tools", + appInstalled: true, + httpsHandler: "com.genesiscz.genesistools", + config, + markdownPath: "/Applications/Example Markdown.app", + cmuxPath: "/opt/example/cmux", + ...overrides, + }); + + test("a kind opens only with a route on this Mac and the program its route names", () => { + const links = probeGenesisTools({ facts: facts(), now: 1 }).links; + + expect(links.md).toMatchObject({ opens: true, action: "open genesis-md://open?path=/probe.md" }); + expect(links.finder).toMatchObject({ opens: true, action: "run /usr/bin/open -R /" }); + expect(links["claude-find"].opens).toBe(true); + // No hub route: the click goes to the browser. + expect(links.hub).toMatchObject({ opens: false, problem: "no-route" }); + + const bare = probeGenesisTools({ facts: facts({ markdownPath: null, toolsPath: null }) }).links; + expect(bare.md.problem).toBe("target-missing"); + expect(bare["claude-find"].problem).toBe("target-missing"); + expect(bare.finder.opens).toBe(true); + }); + + test("every link is dead without the app, the https handler, a link host or macOS", () => { + expect(probeGenesisTools({ facts: facts({ appInstalled: false }) }).links.finder.problem).toBe("app-missing"); + expect(probeGenesisTools({ facts: facts({ httpsHandler: "com.example.browser" }) }).links.md.problem).toBe( + "not-https-handler" + ); + expect( + probeGenesisTools({ facts: facts({ config: { ...config, linkHost: undefined } }) }).links.md.problem + ).toBe("no-link-host"); + expect(probeGenesisTools({ facts: facts({ platform: "linux" }) }).links.md.problem).toBe("not-darwin"); + }); + + test("the finder preset reveals the target it is given", () => { + const decision = route(`https://${linkHost}/finder/reveal?target=/tmp/My%20Notes`, config); + + expect(decision).toMatchObject({ kind: "run", argv: ["/usr/bin/open", "-R", "/tmp/My Notes"] }); + }); +}); diff --git a/src/utils/browser-router/presets.test.ts b/src/utils/browser-router/presets.test.ts index 4610d635c2..549a5bec72 100644 --- a/src/utils/browser-router/presets.test.ts +++ b/src/utils/browser-router/presets.test.ts @@ -83,6 +83,7 @@ describe("preset kinds", () => { "core", "local-services", "genesis-md", + "finder", "genesis-hub", "mail", "decide", diff --git a/src/utils/browser-router/presets.ts b/src/utils/browser-router/presets.ts index e7bc187839..fe4412af07 100644 --- a/src/utils/browser-router/presets.ts +++ b/src/utils/browser-router/presets.ts @@ -117,6 +117,25 @@ function catalog({ linkHost, options }: CatalogContext): PresetSpec[] { { preset: "genesis-md", pattern: on(host, "md/(.*)"), action: { type: "open", to: "genesis-md://$1" } }, ]), }, + { + id: "finder", + title: "Show in Finder", + kind: "installable", + description: + "https://<link host>/finder/reveal?target=<path> shows that file or folder in Finder (open -R).", + enabledIf: ["platform:darwin"], + needsLinkHost: true, + probe: linkHost ? `https://${linkHost}/finder/reveal?target=/` : undefined, + routes: withHost((host) => [ + { + preset: "finder", + name: "Show in Finder", + pattern: on(host, "finder/reveal"), + // Reveals and nothing else, so it runs at once; a missing path makes Finder say so. + action: { type: "run", argv: ["/usr/bin/open", "-R", "{target}"], approval: "allow" }, + }, + ]), + }, { id: "genesis-hub", title: "GenesisTools hub", diff --git a/src/utils/browser-router/router-link.ts b/src/utils/browser-router/router-link.ts index 0a2a8d85eb..af07d728d3 100644 --- a/src/utils/browser-router/router-link.ts +++ b/src/utils/browser-router/router-link.ts @@ -68,6 +68,7 @@ const ROUTING: RoutingTable = { transports: ["router", "scheme"], }, }, + finder: { reveal: {} }, "claude-find": { decision: {}, text: {} }, }; diff --git a/src/utils/bun/preload-test-sandbox.ts b/src/utils/bun/preload-test-sandbox.ts index 855441e606..fc00894e57 100644 --- a/src/utils/bun/preload-test-sandbox.ts +++ b/src/utils/bun/preload-test-sandbox.ts @@ -122,5 +122,20 @@ function isolateObsidian(): void { }); } +/** + * The Codex model catalog reads the Codex CLI's own `models_cache.json` from the real home, so a test that reached it + * depended on whichever models this Mac's Codex CLI last fetched. A missing file answers "no cache"; a test that needs + * one writes a fixture and passes its path. + */ +function isolateCodexModelsCache(): void { + if (env.test.allowsRealHome()) { + return; + } + + const missing = join(mkdtempSync(join(tmpdir(), "gt-test-codex-models-")), "models_cache.json"); + env.testing.set("GENESIS_TOOLS_CODEX_MODELS_CACHE", env.getTrimmed("GENESIS_TOOLS_CODEX_MODELS_CACHE") ?? missing); +} + installSandbox(); isolateObsidian(); +isolateCodexModelsCache(); diff --git a/src/utils/claude/executable.ts b/src/utils/claude/executable.ts new file mode 100644 index 0000000000..d787c0916d --- /dev/null +++ b/src/utils/claude/executable.ts @@ -0,0 +1,90 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { env } from "@genesiscz/utils/env"; +import { logger } from "@genesiscz/utils/logger"; + +/** Where Claude Code's installers put the binary when the shell that runs us has no PATH entry for it. */ +export function claudeInstallLocations(home: string = homedir()): string[] { + return [ + join(home, ".local", "bin", "claude"), + join(home, ".claude", "local", "claude"), + join(home, ".bun", "bin", "claude"), + join(home, ".npm-global", "bin", "claude"), + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + ]; +} + +export interface FindClaudeExecutableDeps { + which: (name: string) => string | null; + exists: (path: string) => boolean; + /** `command -v claude` in the user's interactive shell, so a PATH set only in an rc file counts. */ + shellLookup: () => Promise<string | null>; + home: string; +} + +const SHELL_LOOKUP_TIMEOUT_MS = 3000; + +async function interactiveShellLookup(): Promise<string | null> { + const shell = env.paths.getShell("/bin/sh"); + const proc = Bun.spawn({ cmd: [shell, "-ic", "command -v claude"], stdio: ["ignore", "pipe", "ignore"] }); + let timer: ReturnType<typeof setTimeout> | undefined; + + try { + const stdout = await Promise.race([ + new Response(proc.stdout).text(), + new Promise<string>((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), SHELL_LOOKUP_TIMEOUT_MS); + }), + ]); + await proc.exited; + // An rc file may print to stdout; only a clean exit whose last line is an absolute path counts (an alias or + // function name cannot be spawned). + const last = stdout.trim().split("\n").pop()?.trim() ?? ""; + + return proc.exitCode === 0 && last.startsWith("/") && !/\s/.test(last) ? last : null; + } catch (error) { + logger.debug({ error, shell }, "[findClaudeExecutable] interactive shell lookup failed"); + return null; + } finally { + clearTimeout(timer); + proc.kill(); + } +} + +const defaultDeps: FindClaudeExecutableDeps = { + which: (name) => Bun.which(name), + exists: existsSync, + shellLookup: interactiveShellLookup, + home: homedir(), +}; + +/** + * The absolute path of the `claude` binary, or null when this Mac has none we can spawn. PATH first, then the + * installers' own folders, then the user's interactive shell. `findClaudeCommand` answers a different question (the + * shell command a user types, which may be an alias such as `ccc`); a `Bun.spawn` needs a real file. + */ +export async function findClaudeExecutable(deps: Partial<FindClaudeExecutableDeps> = {}): Promise<string | null> { + const d = { ...defaultDeps, ...deps }; + const onPath = d.which("claude"); + + if (onPath) { + return onPath; + } + + const installed = claudeInstallLocations(d.home).find((path) => d.exists(path)); + + if (installed) { + logger.debug( + { path: installed }, + "[findClaudeExecutable] claude is not on PATH, found it in an install folder" + ); + return installed; + } + + const fromShell = await d.shellLookup(); + logger.debug({ path: fromShell }, "[findClaudeExecutable] interactive shell lookup"); + + return fromShell; +} diff --git a/src/utils/claude/index.test.ts b/src/utils/claude/index.test.ts index 417f2d3f8c..8285318bec 100644 --- a/src/utils/claude/index.test.ts +++ b/src/utils/claude/index.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { _setFindClaudeCommandTestHooks, findClaudeCommand } from "./index"; +import { + _setFindClaudeCommandTestHooks, + claudeInstallLocations, + findClaudeCommand, + findClaudeExecutable, +} from "./index"; describe("findClaudeCommand timeout cleanup", () => { afterEach(() => { @@ -27,3 +32,45 @@ describe("findClaudeCommand timeout cleanup", () => { expect(after).toBe(""); }); }); + +describe("findClaudeExecutable", () => { + const home = "/Users/alice"; + const noShell = async () => null; + + test("PATH wins, then the installers' folders, then the interactive shell", async () => { + expect( + await findClaudeExecutable({ + which: () => "/usr/bin/claude", + exists: () => true, + shellLookup: noShell, + home, + }) + ).toBe("/usr/bin/claude"); + + const local = `${home}/.local/bin/claude`; + expect( + await findClaudeExecutable({ + which: () => null, + exists: (path) => path === local, + shellLookup: noShell, + home, + }) + ).toBe(local); + + expect( + await findClaudeExecutable({ + which: () => null, + exists: () => false, + shellLookup: async () => "/opt/tools/claude", + home, + }) + ).toBe("/opt/tools/claude"); + }); + + test("a Mac without Claude Code answers null, so tools update skips the plugin steps instead of failing them", async () => { + expect( + await findClaudeExecutable({ which: () => null, exists: () => false, shellLookup: noShell, home }) + ).toBeNull(); + expect(claudeInstallLocations(home)).toContain(`${home}/.claude/local/claude`); + }); +}); diff --git a/src/utils/claude/index.ts b/src/utils/claude/index.ts index 9fde936394..745e8a9e82 100644 --- a/src/utils/claude/index.ts +++ b/src/utils/claude/index.ts @@ -5,6 +5,7 @@ export { cleanPromptText, cleanTranscriptText } from "../ai/transcripts/clean-text"; export * from "./auth"; +export * from "./executable"; export * from "./projects"; export { humanTextOf } from "./session.utils"; export { extractToolInputSummary, extractToolResultText, isAssistantEndTurn } from "./session-helpers"; diff --git a/src/utils/claude/peer-message.test.ts b/src/utils/claude/peer-message.test.ts new file mode 100644 index 0000000000..c4ca3fe311 --- /dev/null +++ b/src/utils/claude/peer-message.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { liveClaudeSessionIds } from "./peer-message"; + +describe("liveClaudeSessionIds", () => { + function registry(entries: Record<string, unknown>[]): string { + const dir = mkdtempSync(join(tmpdir(), "claude-sessions-")); + for (const entry of entries) { + writeFileSync(join(dir, `${entry.pid}.json`), SafeJSON.stringify(entry)); + } + writeFileSync(join(dir, "123.abc.key"), "not a registry file"); + return dir; + } + + it("keeps sessions whose pid runs, with or without a messaging socket", () => { + const dir = registry([ + { pid: 1, sessionId: "AAAA-live", messagingSocketPath: "/tmp/s" }, + { pid: 2, sessionId: "bbbb-nosocket" }, + { pid: 3, sessionId: "cccc-dead" }, + ]); + const ids = liveClaudeSessionIds(dir, () => new Set([1, 2])); + expect(ids).toEqual(new Set(["aaaa-live", "bbbb-nosocket"])); + }); + + it("drops a parked entry, whose id moved to a background job", () => { + const dir = registry([{ pid: 1, sessionId: "parent", parkedJobId: "child" }]); + expect(liveClaudeSessionIds(dir, () => new Set([1]))).toEqual(new Set()); + }); + + it("answers null, not empty, when the registry cannot be read", () => { + expect(liveClaudeSessionIds(join(tmpdir(), "no-such-registry-dir"), () => new Set())).toBeNull(); + }); +}); diff --git a/src/utils/claude/peer-message.ts b/src/utils/claude/peer-message.ts index 00773b70df..f8f595c876 100644 --- a/src/utils/claude/peer-message.ts +++ b/src/utils/claude/peer-message.ts @@ -35,6 +35,10 @@ export interface ClaudeLiveSession { socketPath: string; /** The registry file this entry came from (for error messages; never the key file). */ file: string; + /** When the process last wrote its registry entry (epoch ms): its last turn or status change. */ + updatedAt?: number; + /** Set when the conversation moved to a background job under a new id: the socket answers, nothing reads it. */ + parkedJobId?: string; } function claudeDir(): string { @@ -108,6 +112,8 @@ export function parseRegistryEntry(raw: string, file: string): ClaudeLiveSession return null; } + const parkedJobId = text(entry.parkedJobId); + return { pid, sessionId, @@ -117,9 +123,61 @@ export function parseRegistryEntry(raw: string, file: string): ClaudeLiveSession kind: text(entry.kind), socketPath, file, + ...(typeof entry.updatedAt === "number" ? { updatedAt: entry.updatedAt } : {}), + ...(parkedJobId ? { parkedJobId } : {}), }; } +/** + * Ids (lower case) of every Claude session whose process is still running, from the registry alone: no + * messaging socket needed. A parked entry (`parkedJobId`, the conversation moved to a background job under a + * new id) does not count, because its id is frozen. null when the registry cannot be read, so a caller can + * tell "unknown" from "not running". + * + * Liveness is a signal-0 probe only, without `liveClaudePids`' batched `ps`: measured 2026-10-23 on 26 + * entries, 0.39 ms median against 213 ms, and this runs on every monitor poll. Claude deletes its entry on + * exit, so only a crash leaves one behind, and a recycled pid then reads as alive: the fail-open side. + */ +export function liveClaudeSessionIds( + dir: string = claudeSessionsDir(), + liveOf: (pids: number[]) => Set<number> = (pids) => new Set(pids.filter((pid) => isProcessAlive(pid))) +): Set<string> | null { + let names: string[]; + + try { + names = readdirSync(dir); + } catch (error) { + log.debug({ error, dir }, "no Claude session registry"); + return null; + } + + const byPid = new Map<number, string>(); + + for (const name of names) { + if (!/^\d+\.json$/.test(name)) { + continue; + } + + try { + const parsed: unknown = SafeJSON.parse(readFileSync(join(dir, name), "utf8"), { strict: true }); + + if ( + isRecord(parsed) && + typeof parsed.pid === "number" && + text(parsed.sessionId) && + !text(parsed.parkedJobId) + ) { + byPid.set(parsed.pid, String(parsed.sessionId).toLowerCase()); + } + } catch (error) { + log.debug({ error, name }, "unreadable session registry file"); + } + } + + const live = liveOf([...byPid.keys()]); + return new Set([...byPid].filter(([pid]) => live.has(pid)).map(([, id]) => id)); +} + /** Every live Claude session that advertises a messaging socket. `liveOf` checks the whole inventory at once. */ export function listClaudeLiveSessions( dir: string = claudeSessionsDir(), @@ -153,7 +211,13 @@ export function listClaudeLiveSessions( const entry = parseRegistryEntry(raw, file); - if (entry) { + // A parked entry is no receiver: a decision "delivered" to its socket was lost (Kalendora, 2026-10-25 18:03). + if (entry?.parkedJobId) { + log.info( + { pid: entry.pid, sessionId: entry.sessionId, parkedJobId: entry.parkedJobId }, + "Claude session is parked in a background job; not a message receiver" + ); + } else if (entry) { entries.push(entry); } } diff --git a/src/utils/cli/failures/caller.ts b/src/utils/cli/failures/caller.ts index 5efd4f64e9..9854c24045 100644 --- a/src/utils/cli/failures/caller.ts +++ b/src/utils/cli/failures/caller.ts @@ -54,7 +54,10 @@ function codexRollout(threadId: string, codexHome: string, now: Date): string | continue; } - const name = readdirSync(dir).find((entry) => entry.endsWith(`-${threadId}.jsonl`)); + // Codex 0.164 compresses old rollouts to `.jsonl.zst`; `readLastLines` below reads either form. + const name = readdirSync(dir).find( + (entry) => entry.endsWith(`-${threadId}.jsonl`) || entry.endsWith(`-${threadId}.jsonl.zst`) + ); if (name) { return join(dir, name); } diff --git a/src/utils/env/envVariables.ts b/src/utils/env/envVariables.ts index b914f6836c..376673a14c 100644 --- a/src/utils/env/envVariables.ts +++ b/src/utils/env/envVariables.ts @@ -96,6 +96,11 @@ export const env = { codex: { /** Codex CLI data root override. Codex accepts a comma-separated list of homes. */ getHomeOverride: () => getTrimmed("CODEX_HOME"), + /** + * `GENESIS_TOOLS_CODEX_MODELS_CACHE`: the Codex CLI model cache GenesisTools reads instead of + * `<CODEX_HOME>/models_cache.json`. The test preload points it at an empty folder. + */ + getModelsCachePathOverride: () => getTrimmed("GENESIS_TOOLS_CODEX_MODELS_CACHE"), }, copilot: { @@ -129,6 +134,13 @@ export const env = { */ getGrokMessagesRoute: (): "responses" | "shim" => getTrimmed("AI_PROXY_GROK_MESSAGES_ROUTE")?.toLowerCase() === "shim" ? "shim" : "responses", + /** + * Which path serves Anthropic /v1/messages for the Codex agent. "responses" (default) translates straight to + * the WHAM Responses wire and carries encrypted reasoning across turns; "chat" restores the older hop through + * the OpenAI chat shape, which has no slot for reasoning. + */ + getCodexMessagesRoute: (): "responses" | "chat" => + getTrimmed("AI_PROXY_CODEX_MESSAGES_ROUTE")?.toLowerCase() === "chat" ? "chat" : "responses", }, github: { @@ -211,6 +223,8 @@ export const env = { * run. Read through the facade so `env.testing` overrides are seen; a * direct `process.env` read in the guard was invisible to them. */ + /** Set by `tools update` on the child it starts after the pull, so the steps after it run the new code. */ + isUpdatePostPull: () => getRaw("GENESIS_TOOLS_UPDATE_POST_PULL") === "1", allowsRealMigration: () => getTrimmed("GENESIS_TOOLS_ALLOW_REAL_MIGRATION") === "1", getHomeEnvKey: () => (isNonEmpty("GENESIS_TOOLS_HOME") ? "GENESIS_TOOLS_HOME" : undefined), getPath: () => getTrimmed("GENESIS_TOOLS_PATH"), @@ -412,6 +426,8 @@ export const env = { agents: { /** AGENTS_HOOKS_DISABLE=1 is the emergency stop: every `tools agents` hook exits 0 at once. */ areHooksDisabled: () => getRaw("AGENTS_HOOKS_DISABLE") === "1", + /** GENESIS_TOOLS_AGENT names the agent of this session (for example `Fable`, `Codex`), for tools that sign its work. */ + getName: () => getTrimmed("GENESIS_TOOLS_AGENT"), }, task: { diff --git a/src/utils/fs/read-lines.test.ts b/src/utils/fs/read-lines.test.ts index b00b8c43e3..517e33d18f 100644 --- a/src/utils/fs/read-lines.test.ts +++ b/src/utils/fs/read-lines.test.ts @@ -2,7 +2,8 @@ import { afterAll, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { readFirstLines, readLastLines, readLinesBackwardSync } from "./read-lines"; +import { compressBytes } from "@genesiscz/utils/compress/zstd"; +import { readFirstLines, readLastLines, readLinesBackwardSync, readWholeTextSync } from "./read-lines"; const dir = mkdtempSync(join(tmpdir(), "read-lines-")); @@ -129,3 +130,28 @@ describe("readLastLines window bound", () => { expect(readLastLines(path, { maxBytes: 8 })).toEqual({ lines: ["abc", "def"], wholeFile: false }); }); }); + +describe("compressed files (Codex 0.164 compresses its old rollouts to .jsonl.zst)", () => { + const text = 'old\n{"a":1}\nžluťoučký kůň\n\nsplit across frames\nnewest without newline'; + const plain = file("rollout.jsonl", text); + // Codex writes one frame; the repo's own writer writes many. Two frames cut mid-line test the carry. + const single = file("rollout-single.jsonl.zst", ""); + writeFileSync(single, compressBytes(text)); + const cut = text.indexOf("across"); + const multi = file("rollout-multi.jsonl.zst", ""); + writeFileSync(multi, Buffer.concat([compressBytes(text.slice(0, cut)), compressBytes(text.slice(cut))])); + + it("reads the same lines backwards as the plain file, from one frame and across frames", () => { + const expected = [...readLinesBackwardSync(plain)]; + + expect(expected[0]).toBe("newest without newline"); + expect([...readLinesBackwardSync(single)]).toEqual(expected); + expect([...readLinesBackwardSync(multi)]).toEqual(expected); + }); + + it("readWholeTextSync gives the plain text of either form", () => { + expect(readWholeTextSync(plain)).toBe(text); + expect(readWholeTextSync(single)).toBe(text); + expect(readWholeTextSync(multi)).toBe(text); + }); +}); diff --git a/src/utils/fs/read-lines.ts b/src/utils/fs/read-lines.ts index 98d0656c47..6a7fa52fee 100644 --- a/src/utils/fs/read-lines.ts +++ b/src/utils/fs/read-lines.ts @@ -1,4 +1,4 @@ -import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs"; import { isZstdPath, readZstdFrames, readZstdFramesSync } from "@genesiscz/utils/compress/zstd"; import { cPath, @@ -54,6 +54,19 @@ export function* readTextChunksSync(path: string, { chunkBytes = CHUNK_BYTES }: yield* plainChunksSync(path, chunkBytes); } +/** + * The whole text of a file, plain or `.zst` (its frames joined): for callers that parse the full text anyway, such as + * a Codex rollout that Codex 0.164 compressed. Prefer {@link readLinesSync} when lines are enough. Throws like + * `readFileSync` when the file cannot be opened. + */ +export function readWholeTextSync(path: string): string { + if (!isZstdPath(path)) { + return readFileSync(path, "utf8"); + } + + return Buffer.concat([...readZstdFramesSync(path)].map(asBuffer)).toString("utf8"); +} + /** * {@link readTextChunksSync} for async readers: a plain file as `Bun.file().stream()` chunks, a `.zst` file as its * frames, the next ones decompressing on the thread pool while the caller works on the current one. @@ -316,54 +329,67 @@ export function readLastLines(path: string, { maxBytes = END_BYTES, method }: Ed } } -/** - * The lines of a plain file from its END backwards, newest first, read in chunks of `chunkBytes` (256 KB by - * default): for an append-only journal whose readers want only its recent part. Stop iterating as soon as a line is - * older than you need, and the rest of the file is never read. A line without its trailing newline (a writer in the - * middle of it) still comes out. Throws when the file cannot be opened; `.zst` files are not supported. - */ -export function* readLinesBackwardSync( - path: string, - { chunkBytes = 256 * 1024 }: { chunkBytes?: number } = {} -): Generator<string> { +/** A plain file's bytes from its end, in chunks of `chunkBytes`; a `.zst` file's frames from its last one. */ +function* chunksFromEndSync(path: string, chunkBytes: number): Generator<Buffer> { + if (isZstdPath(path)) { + for (const frame of readZstdFramesSync(path, { fromEnd: true })) { + yield asBuffer(frame); + } + + return; + } + const fd = openSync(path, "r"); try { let position = fstatSync(fd).size; - // Bytes of a line that started before the chunk just read. - let carry = Buffer.alloc(0); while (position > 0) { const size = Math.min(chunkBytes, position); position -= size; const chunk = Buffer.allocUnsafe(size); const read = readSync(fd, chunk, 0, size, position); - const buffer = Buffer.concat([chunk.subarray(0, read), carry]); - let end = buffer.length; - - for ( - let newline = buffer.lastIndexOf(10, end - 1); - newline !== -1; - newline = buffer.lastIndexOf(10, end - 1) - ) { - if (newline + 1 < end) { - yield buffer.toString("utf8", newline + 1, end); - } - - end = newline; - - if (end === 0) { - break; - } + yield chunk.subarray(0, read); + } + } finally { + closeSync(fd); + } +} + +/** + * The lines of a file from its END backwards, newest first, read in chunks of `chunkBytes` (256 KB by default): for + * an append-only journal whose readers want only its recent part. Stop iterating as soon as a line is older than you + * need, and the rest of the file is never read. A line without its trailing newline (a writer in the middle of it) + * still comes out. A `.zst` path is read frame by frame from its last frame (a single-frame file, as Codex writes its + * old rollouts, decompresses whole). Throws when the file cannot be opened. + */ +export function* readLinesBackwardSync( + path: string, + { chunkBytes = 256 * 1024 }: { chunkBytes?: number } = {} +): Generator<string> { + // Bytes of a line that started before the chunk just read. + let carry = Buffer.alloc(0); + + for (const chunk of chunksFromEndSync(path, chunkBytes)) { + const buffer = Buffer.concat([chunk, carry]); + let end = buffer.length; + + for (let newline = buffer.lastIndexOf(10, end - 1); newline !== -1; newline = buffer.lastIndexOf(10, end - 1)) { + if (newline + 1 < end) { + yield buffer.toString("utf8", newline + 1, end); } - carry = buffer.subarray(0, end); - } + end = newline; - if (carry.length > 0) { - yield carry.toString("utf8"); + if (end === 0) { + break; + } } - } finally { - closeSync(fd); + + carry = buffer.subarray(0, end); + } + + if (carry.length > 0) { + yield carry.toString("utf8"); } } diff --git a/src/utils/genesis-tools/probe.ts b/src/utils/genesis-tools/probe.ts new file mode 100644 index 0000000000..3321e823a1 --- /dev/null +++ b/src/utils/genesis-tools/probe.ts @@ -0,0 +1,209 @@ +import { genesisMdHandlerPath } from "@genesiscz/utils/app-links/open-file"; +import { APP_LINKS } from "@genesiscz/utils/app-links/targets"; +import { cmuxBinary, hasCapability, httpsHandler } from "@genesiscz/utils/browser-router/capabilities"; +import { readRouterConfig } from "@genesiscz/utils/browser-router/config"; +import { type RouteDecision, type RouterConfig, route } from "@genesiscz/utils/browser-router/route"; +import { detectGenesisTools } from "@genesiscz/utils/cli/genesis-tools"; +import { GENESIS_APP_BUNDLE_ID, genesisAppBundlePath } from "@genesiscz/utils/macos/genesis-app"; + +/** + * What of GenesisTools this Mac has, and which of its links a click opens. A link to genesis.tools opens something + * only when GenesisTools.app is installed, macOS hands https links to it, the router config has a route for that + * path, and the app or command the route names is there; otherwise the click goes to the browser and lands on a + * page that does not exist. Every link kind is judged by the real config through `route()`, so a hand-made route + * counts as much as a preset's. + * + * Reads only: no file is written, the router gate included. `tools genesis-tools probe` prints it, and the mods + * plugin, which cannot import this file, runs that command. + */ + +/** The link kinds code and agents write: a note, a folder, the hub, a decision in a live Claude chat. */ +export const GENESIS_LINK_KINDS = ["md", "finder", "hub", "claude-find"] as const; +export type GenesisLinkKind = (typeof GENESIS_LINK_KINDS)[number]; + +/** Why a link opens nothing on this Mac. */ +export type LinkProblem = + | "not-darwin" + | "app-missing" + | "not-https-handler" + | "no-link-host" + | "no-route" + | "target-missing"; + +export interface LinkState { + opens: boolean; + problem: LinkProblem | null; + /** The problem in a sentence, or null when the link opens. */ + reason: string | null; + /** What a click does: `open genesis-md://…`, `run /usr/bin/open -R /`, or `forward …` to the browser. */ + action: string | null; +} + +export interface GenesisToolsProbe { + version: 1; + at: number; + platform: string; + /** The `tools` this process would run (`detectGenesisTools`), or null when none is found. */ + tools: { path: string | null }; + /** GenesisTools.app, which routes the links and opens `genesis-tools://`. */ + app: { installed: boolean; path: string }; + router: { httpsHandler: string | null; defaultHandler: boolean; configured: boolean; linkHost: string | null }; + /** The app that opens `genesis-md://` links (Genesis Markdown), found by Launch Services. */ + markdown: { path: string | null }; + cmux: { path: string | null }; + links: Record<GenesisLinkKind, LinkState>; +} + +/** The facts the probe reads; a test passes its own. */ +export interface ProbeFacts { + platform: string; + toolsPath: string | null; + appInstalled: boolean; + httpsHandler: string | null; + config: RouterConfig | null; + markdownPath: string | null; + cmuxPath: string | null; +} + +export function readProbeFacts(): ProbeFacts { + return { + platform: process.platform, + toolsPath: detectGenesisTools()?.binPath ?? null, + appInstalled: hasCapability("browser-router:installed"), + httpsHandler: httpsHandler(), + config: readRouterConfig(), + markdownPath: genesisMdHandlerPath(), + cmuxPath: cmuxBinary() ?? null, + }; +} + +/** A sample link of each kind: what the route must match. */ +const SAMPLE_PATHS: Record<GenesisLinkKind, string> = { + md: APP_LINKS["genesis-md"].open.path({ path: "/probe.md" }), + finder: APP_LINKS.finder.reveal.path({ target: "/" }), + hub: APP_LINKS["genesis-hub"].open.path({ pr: "acme/app#1" }), + "claude-find": APP_LINKS["claude-find"].decision.path({ + session: "00000000-0000-4000-8000-000000000000", + decision: 1, + }), +}; + +function problem(code: LinkProblem, reason: string, action: string | null = null): LinkState { + return { opens: false, problem: code, reason, action }; +} + +function actionOf(decision: RouteDecision): string { + if (decision.kind === "run") { + return `run ${decision.argv.join(" ")}`; + } + + if (decision.kind === "tool") { + return `tool ${decision.tool} ${decision.args.join(" ")}`; + } + + return `${decision.via === "route" ? "open" : "forward"} ${decision.url}`; +} + +/** The program a route needs that this Mac lacks, or null when it has everything. */ +function missingTarget(decision: RouteDecision, facts: ProbeFacts): string | null { + if (decision.kind === "open" && decision.url.startsWith("genesis-md:") && !facts.markdownPath) { + return "Genesis Markdown, the app the route opens, is not installed"; + } + + if (decision.kind === "open" && decision.url.startsWith("genesis-tools:") && !facts.appInstalled) { + return "GenesisTools.app, the app the route opens, is not installed"; + } + + const argv = decision.kind === "run" ? decision.argv : decision.kind === "tool" ? ["tools", decision.tool] : []; + + if (argv[0] === "tools" && !facts.toolsPath) { + return "the route runs the GenesisTools CLI, and no checkout of it is found"; + } + + if (argv.some((word) => word === "cmux" || word.endsWith("/cmux")) && !facts.cmuxPath) { + return "the route needs cmux, which is not installed"; + } + + return null; +} + +/** Whether a click on `url` opens what it names on this Mac, and why not. */ +export function linkState(url: string, facts: ProbeFacts): LinkState { + if (facts.platform !== "darwin") { + return problem("not-darwin", "GenesisTools links open only on macOS"); + } + + if (!facts.appInstalled) { + return problem("app-missing", "GenesisTools.app is not installed, so nothing routes the link"); + } + + if (facts.httpsHandler !== GENESIS_APP_BUNDLE_ID) { + return problem( + "not-https-handler", + `macOS opens https links with ${facts.httpsHandler ?? "no app"}, so a click never reaches the router` + ); + } + + if (!facts.config) { + return problem("no-link-host", "there is no browser-router config"); + } + + let decision: RouteDecision; + + try { + decision = route(url, facts.config); + } catch (error) { + return problem( + "no-route", + `the router refuses the link: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const action = actionOf(decision); + + if (decision.via !== "route") { + return problem("no-route", "no route on this Mac matches it, so the browser gets it", action); + } + + const missing = missingTarget(decision, facts); + + return missing ? problem("target-missing", missing, action) : { opens: true, problem: null, reason: null, action }; +} + +/** The probe of every part and link kind, from `facts` (read from this Mac when omitted). */ +export function probeGenesisTools({ + facts = readProbeFacts(), + now = Date.now(), +}: { + facts?: ProbeFacts; + now?: number; +} = {}): GenesisToolsProbe { + const linkHost = facts.config?.linkHost ?? null; + const stateOf = (kind: GenesisLinkKind): LinkState => + linkHost + ? linkState(`https://${linkHost}/${SAMPLE_PATHS[kind]}`, facts) + : problem("no-link-host", "the router has no link host to build the link on"); + const links: Record<GenesisLinkKind, LinkState> = { + md: stateOf("md"), + finder: stateOf("finder"), + hub: stateOf("hub"), + "claude-find": stateOf("claude-find"), + }; + + return { + version: 1, + at: now, + platform: facts.platform, + tools: { path: facts.toolsPath }, + app: { installed: facts.appInstalled, path: genesisAppBundlePath() }, + router: { + httpsHandler: facts.httpsHandler, + defaultHandler: facts.httpsHandler === GENESIS_APP_BUNDLE_ID, + configured: facts.config !== null, + linkHost, + }, + markdown: { path: facts.markdownPath }, + cmux: { path: facts.cmuxPath }, + links, + }; +} diff --git a/src/utils/git/index.ts b/src/utils/git/index.ts index c871af1d20..65b40d9aa8 100644 --- a/src/utils/git/index.ts +++ b/src/utils/git/index.ts @@ -7,3 +7,4 @@ export * from "./porcelain"; export * from "./repo-config"; export * from "./types"; export * from "./worktree"; +export * from "./worktree-renames"; diff --git a/src/utils/git/origins/detector.test.ts b/src/utils/git/origins/detector.test.ts index 0fdae82e54..9bfdd49400 100644 --- a/src/utils/git/origins/detector.test.ts +++ b/src/utils/git/origins/detector.test.ts @@ -866,6 +866,16 @@ describe("GitLab MR list and view", () => { expect(asked.filter((cmd) => cmd.includes("--paginate"))).toHaveLength(1); }); + it("reads every page of an MR's commits, which glab --paginate prints back to back", () => { + const commit = (id: string) => ({ id, title: `Fix ${id}`, message: `Fix ${id}\n\nsee [--flag] [x]` }); + const detail = parseGlabMrView({ + mr: SafeJSON.stringify(SafeJSON.parse(GL_MRS, { strict: true })[0]), + // Newest first, as GitLab lists them; a "]" inside a message must not end a page. + commits: SafeJSON.stringify([commit("c3"), commit("c2")]) + SafeJSON.stringify([commit("c1")]), + }); + expect(detail.commits.map((entry) => entry.sha)).toEqual(["c1", "c2", "c3"]); + }); + it("keeps a commit's message body without the title line GitLab repeats", () => { const detail = parseGlabMrView({ mr: SafeJSON.stringify(SafeJSON.parse(GL_MRS, { strict: true })[0]), diff --git a/src/utils/git/origins/prs.ts b/src/utils/git/origins/prs.ts index 10046099f5..5e743ea1ce 100644 --- a/src/utils/git/origins/prs.ts +++ b/src/utils/git/origins/prs.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { SafeJSON } from "@genesiscz/utils/json"; +import { parseConcatenatedJson, SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; import { classifyOriginUrl } from "./detector"; import { toState as ghState } from "./gh"; @@ -129,6 +129,16 @@ function parseJson(json: string, what: string): unknown { } } +/** `glab api --paginate` prints one array per page back to back (`[…][…]`): the pages as one list. */ +function parseJsonPages(json: string, what: string): unknown[] { + try { + return parseConcatenatedJson(json).flatMap((page) => (Array.isArray(page) ? page : [page])); + } catch (err) { + log.debug({ err, what }, "unparsable host output"); + throw new PrParseError(`unparsable ${what} output`); + } +} + function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -842,7 +852,7 @@ export function parseGlabMrView({ body: str(row.description) ?? "", // GitLab lists an MR's commits newest first, gh oldest first: every caller gets the oldest first. commits: commits - ? records(parseJson(commits, "glab commits")) + ? records(parseJsonPages(commits, "glab commits")) .slice() .reverse() .map((commit) => ({ diff --git a/src/utils/git/worktree-renames.test.ts b/src/utils/git/worktree-renames.test.ts new file mode 100644 index 0000000000..1dba0856b5 --- /dev/null +++ b/src/utils/git/worktree-renames.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { createGit } from "./core"; +import { pairStatusRenames, pairWorktreeRenames, type WorktreeSide } from "./worktree-renames"; + +interface Contract { + cases: { name: string; deleted: WorktreeSide[]; untracked: WorktreeSide[]; pairs: [string, string][] }[]; +} + +describe("pairWorktreeRenames", () => { + // The one copy of the cases, shared with GenesisKit's GitWorktreeRenamesTests (src/macos/lib/kit-fixtures.ts reads + // the same folder; this package cannot import the app alias). + const fixture = join( + import.meta.dir, + "../../macos/GenesisKit/Sources/GenesisKitFixtures/Git/contract-worktree-renames.json" + ); + const contract = SafeJSON.parse(readFileSync(fixture, "utf8"), { strict: true }) as Contract; + + it("has the cases GenesisKit also passes", () => { + expect(contract.cases.length).toBeGreaterThanOrEqual(6); + }); + + for (const item of contract.cases) { + it(item.name, () => { + const pairs = pairWorktreeRenames(item.deleted, item.untracked); + expect(pairs.map((pair) => [pair.old, pair.new])).toEqual(item.pairs); + }); + } +}); + +describe("pairStatusRenames", () => { + const made: string[] = []; + + afterEach(() => { + for (const dir of made.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + async function run(root: string, args: string[]): Promise<void> { + const proc = Bun.spawn(["git", "-C", root, ...args], { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Alice", + GIT_AUTHOR_EMAIL: "alice@example.com", + GIT_COMMITTER_NAME: "Alice", + GIT_COMMITTER_EMAIL: "alice@example.com", + }, + }); + expect(await proc.exited).toBe(0); + } + + it("turns a folder moved outside git into renames, and leaves edits, empty files and real deletions alone", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-renames-")); + made.push(root); + mkdirSync(join(root, "data/run"), { recursive: true }); + writeFileSync(join(root, "data/run/compiler.ts"), "export const compiler = 1\n"); + writeFileSync(join(root, "data/run/notes.md"), "line one\n"); + writeFileSync(join(root, "data/run/empty.err"), ""); + writeFileSync(join(root, "gone.md"), "bye\n"); + await run(root, ["init", "-q", "-b", "main"]); + await run(root, ["add", "."]); + await run(root, ["commit", "-q", "-m", "First"]); + mkdirSync(join(root, "moved")); + renameSync(join(root, "data/run"), join(root, "moved/run")); + writeFileSync(join(root, "moved/run/notes.md"), "line one, edited\n"); + rmSync(join(root, "gone.md")); + + const status = await createGit({ cwd: root }).status({ untracked: "all" }); + const entries = await pairStatusRenames(root, status.entries); + const byPath = new Map(entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("moved/run/compiler.ts")).toMatchObject({ + kind: "renamed", + origPath: "data/run/compiler.ts", + worktree: "R", + }); + expect(byPath.has("data/run/compiler.ts")).toBe(false); + expect(byPath.get("data/run/notes.md")?.worktree).toBe("D"); + expect(byPath.get("moved/run/notes.md")?.kind).toBe("untracked"); + expect(byPath.get("data/run/empty.err")?.worktree).toBe("D"); + expect(byPath.get("gone.md")?.worktree).toBe("D"); + }); +}); diff --git a/src/utils/git/worktree-renames.ts b/src/utils/git/worktree-renames.ts new file mode 100644 index 0000000000..4e1d437b8e --- /dev/null +++ b/src/utils/git/worktree-renames.ts @@ -0,0 +1,250 @@ +/** + * Moves git does not report: a folder moved in Finder or by an agent, without `git mv`. Git pairs a rename only + * when both sides are in the index, so the old paths read as deleted and the new ones as untracked (2026-10-25: 147 + * files of a moved vault folder listed as deleted). A deleted file whose exact content is now an untracked file is + * that move. + * + * The same rule lives in GenesisKit (`Diff/Source/GitWorktreeRenames.swift`), for the apps' repository panel and + * diffs. `GenesisKitFixtures/Git/contract-worktree-renames.json` holds the cases both implementations pass. + * + * Cost on a vault with 650 deleted and 4,834 untracked files: one `cat-file --batch-check` for the deleted blobs' + * sizes, an `lstat` per untracked file, and `hash-object` only for files whose size equals a deleted blob's (about + * 50 ms), where git's own detection through a scratch index (`add -N`, then `status`) took 3.9 s. + */ + +import { lstatSync } from "node:fs"; +import { join } from "node:path"; +import type { StatusEntry } from "./porcelain"; + +export interface WorktreeSide { + path: string; + oid: string; +} + +export interface WorktreeRename { + old: string; + new: string; +} + +/** The empty blob in SHA-1 and SHA-256 repositories: every empty file shares it, so it proves no move. */ +const EMPTY_BLOBS = new Set([ + "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813", +]); + +function compareBytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +function commonCount(a: string[], b: string[]): number { + let count = 0; + + while (count < a.length && count < b.length && a[count] === b[count]) { + count++; + } + + return count; +} + +/** Lexicographic comparison of two score tuples: negative when `a` scores lower. */ +function compareScores(a: number[], b: number[]): number { + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return a[i] - b[i]; + } + } + + return 0; +} + +/** + * Pairs each deleted file with an untracked file of the same blob. Among several, the one whose path is most alike + * wins: the same file name first, then the most equal trailing folders, then the most equal leading ones, then byte + * order. Deleted files are taken in byte order; each untracked file pairs at most once. + */ +export function pairWorktreeRenames(deleted: WorktreeSide[], untracked: WorktreeSide[]): WorktreeRename[] { + const byOid = new Map<string, string[]>(); + + for (const side of untracked) { + if (EMPTY_BLOBS.has(side.oid)) { + continue; + } + + const list = byOid.get(side.oid) ?? []; + list.push(side.path); + byOid.set(side.oid, list); + } + + const taken = new Set<string>(); + const pairs: WorktreeRename[] = []; + + for (const side of [...deleted].sort((a, b) => compareBytes(a.path, b.path))) { + const candidates = EMPTY_BLOBS.has(side.oid) ? undefined : byOid.get(side.oid); + + if (!candidates) { + continue; + } + + const old = side.path.split("/"); + let best: { path: string; score: number[] } | null = null; + + for (const path of candidates) { + if (taken.has(path)) { + continue; + } + + const next = path.split("/"); + const score = [ + old.at(-1) === next.at(-1) ? 1 : 0, + commonCount([...old].reverse(), [...next].reverse()), + commonCount(old, next), + ]; + + if (best) { + const order = compareScores(score, best.score); + + if (order < 0 || (order === 0 && compareBytes(path, best.path) >= 0)) { + continue; + } + } + + best = { path, score }; + } + + if (best) { + taken.add(best.path); + pairs.push({ old: side.path, new: best.path }); + } + } + + return pairs; +} + +async function runGit(root: string, args: string[], input: string): Promise<string | null> { + const proc = Bun.spawn(["git", "-C", root, ...args], { + stdin: new TextEncoder().encode(input), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + return code === 0 ? stdout : null; +} + +/** + * The moves among `deleted` (repo-relative paths whose last version is at `revision`: `HEAD`, `""` for the index, or + * a commit) and `untracked` (repo-relative paths on disk). Reads only what can match. + */ +export async function findWorktreeRenames(opts: { + root: string; + deleted: string[]; + untracked: string[]; + revision: string; +}): Promise<WorktreeRename[]> { + const { root, revision } = opts; + // A path with a newline cannot travel through the line-based batch commands; it stays as git reports it. + const deleted = opts.deleted.filter((path) => !path.includes("\n")); + + if (deleted.length === 0 || opts.untracked.length === 0) { + return []; + } + + const checked = await runGit( + root, + ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], + deleted.map((path) => `${revision}:${path}\n`).join("") + ); + + if (checked === null) { + return []; + } + + const lines = checked.split("\n"); + const deletedSides: WorktreeSide[] = []; + const sizes = new Set<number>(); + + for (const [index, path] of deleted.entries()) { + const [oid, type, size] = (lines[index] ?? "").split(" "); + const bytes = Number(size); + + if (type === "blob" && bytes > 0) { + deletedSides.push({ path, oid }); + sizes.add(bytes); + } + } + + const candidates: string[] = []; + + for (const path of opts.untracked) { + if (path.includes("\n")) { + continue; + } + + try { + const stat = lstatSync(join(root, path)); + + if (stat.isFile() && sizes.has(stat.size)) { + candidates.push(path); + } + } catch { + // Gone since the status: nothing to pair. + } + } + + if (deletedSides.length === 0 || candidates.length === 0) { + return []; + } + + const hashed = await runGit(root, ["hash-object", "--stdin-paths"], candidates.map((path) => `${path}\n`).join("")); + const oids = hashed?.split("\n").filter(Boolean) ?? []; + + if (oids.length !== candidates.length) { + return []; + } + + return pairWorktreeRenames( + deletedSides, + candidates.map((path, index) => ({ path, oid: oids[index] })) + ); +} + +/** + * Status entries with each move paired: the deleted entry becomes a `renamed` entry at the new path (worktree letter + * `R`, `origPath` the old path), and its untracked twin is dropped. + */ +export function applyWorktreeRenames(entries: StatusEntry[], renames: WorktreeRename[]): StatusEntry[] { + if (renames.length === 0) { + return entries; + } + + const newByOld = new Map(renames.map((pair) => [pair.old, pair.new])); + const news = new Set(renames.map((pair) => pair.new)); + const result: StatusEntry[] = []; + + for (const entry of entries) { + if (entry.kind === "untracked" && news.has(entry.path)) { + continue; + } + + const next = entry.kind === "changed" && entry.worktree === "D" ? newByOld.get(entry.path) : undefined; + result.push( + next ? { ...entry, kind: "renamed", path: next, origPath: entry.path, worktree: "R", score: 100 } : entry + ); + } + + return result; +} + +/** A status's entries with their working-tree moves paired; the deleted side is the index's version. */ +export async function pairStatusRenames(root: string, entries: StatusEntry[]): Promise<StatusEntry[]> { + const renames = await findWorktreeRenames({ + root, + deleted: entries + .filter((entry) => entry.kind === "changed" && entry.worktree === "D") + .map((entry) => entry.path), + untracked: entries.filter((entry) => entry.kind === "untracked").map((entry) => entry.path), + revision: "", + }); + + return applyWorktreeRenames(entries, renames); +} diff --git a/src/utils/json.ts b/src/utils/json.ts index fffc96f068..d066e44e7d 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -103,3 +103,52 @@ export function parseJSON<T>(text: string, fallback?: T): T | null { return null; } } + +/** + * The JSON documents of a text that holds several back to back, as `glab api --paginate` and `gh api --paginate` + * print one array per page with nothing between them (`[…][…]`). Each document is parsed strictly; a text with one + * document gives one entry. Throws on malformed JSON, like `JSON.parse`. + */ +export function parseConcatenatedJson(text: string): unknown[] { + const documents: unknown[] = []; + let depth = 0; + let start = -1; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + + continue; + } + + if (char === '"') { + inString = true; + } else if (char === "[" || char === "{") { + if (depth === 0) { + start = index; + } + + depth++; + } else if (char === "]" || char === "}") { + depth--; + if (depth === 0) { + documents.push(SafeJSON.parse(text.slice(start, index + 1), { strict: true })); + } + } + } + + if (depth !== 0 || documents.length === 0) { + // Malformed or not an array or object at all: let the native parser name the problem. + return [SafeJSON.parse(text, { strict: true })]; + } + + return documents; +} diff --git a/src/utils/macos/launch-services.ts b/src/utils/macos/launch-services.ts new file mode 100644 index 0000000000..1d07cd2116 --- /dev/null +++ b/src/utils/macos/launch-services.ts @@ -0,0 +1,26 @@ +import { logger } from "@genesiscz/utils/logger"; +import { escapeJxa, runJxa } from "./jxa"; + +/** + * Where Launch Services finds the app with this bundle id, or null when it knows none. This is the lookup + * `open -b <id>` and a `<scheme>://` click use, so it answers "would it open" where Spotlight cannot: on + * 2026-10-23 `mdfind kMDItemCFBundleIdentifier == …` found no app at all on a Mac with indexing on, while both + * GenesisTools.app and Genesis Markdown.app opened. One osascript start, about 80 ms. + */ +export function appPathForBundleId(bundleId: string): string | null { + if (process.platform !== "darwin") { + return null; + } + + const script = + 'ObjC.import("AppKit"); const u = $.NSWorkspace.sharedWorkspace.URLForApplicationWithBundleIdentifier(' + + `"${escapeJxa(bundleId)}"); u.isNil() ? "none" : ObjC.unwrap(u.path)`; + + try { + const found = runJxa(script, 5_000); + return found && found !== "none" ? found : null; + } catch (error) { + logger.debug({ error, bundleId }, "launch-services: the app lookup failed"); + return null; + } +} diff --git a/src/utils/macos/notifications.ts b/src/utils/macos/notifications.ts index a8efabbae8..d5e72b5894 100644 --- a/src/utils/macos/notifications.ts +++ b/src/utils/macos/notifications.ts @@ -5,6 +5,7 @@ import { withTimeout } from "@genesiscz/utils/async"; import { moduleDir, requireSibling } from "@genesiscz/utils/fs/module-dir"; import { logger } from "@genesiscz/utils/logger"; import { escapeJxa } from "@genesiscz/utils/macos/jxa"; +import { recordNotification } from "@genesiscz/utils/notifications/ledger"; import { boundedCommand } from "@genesiscz/utils/process/bounded-command"; import { Storage } from "@genesiscz/utils/storage/storage"; @@ -442,6 +443,29 @@ export interface PostedNotification { * Prefer {@link sendNotification} unless you need the id back to retract it later. */ export async function postNotification(opts: NotificationOptions): Promise<PostedNotification> { + const posted = await postThroughChain(opts); + + // GenesisTools.app posts through GenesisKit's NotificationEngine, which records the line itself with + // the real outcome of the add. Recording here too would count every banner twice. + if (posted.backend === NotificationBackend.GenesisApp) { + return posted; + } + + recordNotification({ + source: opts.group ?? "tools", + channel: "macos", + outcome: "posted", + title: opts.title, + subtitle: opts.subtitle, + body: opts.message, + backend: posted.backend, + confirmed: posted.confirmed, + id: posted.id ?? opts.id ?? null, + }); + return posted; +} + +async function postThroughChain(opts: NotificationOptions): Promise<PostedNotification> { const chain = backendChain(opts.preferred); for (const backend of chain) { diff --git a/src/utils/net/pinned-fetch.ts b/src/utils/net/pinned-fetch.ts index 36b7d47c11..613978326c 100644 --- a/src/utils/net/pinned-fetch.ts +++ b/src/utils/net/pinned-fetch.ts @@ -1,8 +1,11 @@ import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import { Readable } from "node:stream"; +import { untilAborted } from "../async"; import { resolvePublicOutboundTarget } from "./outbound-policy"; +export { untilAborted }; + export interface PinnedRequestInput { url: URL; address: string; @@ -82,39 +85,6 @@ export function pinnedRequest({ url, address, signal, headers, method, body }: P }); } -/** - * Settle with `promise`, or reject as soon as `signal` aborts. DNS lookups take no signal, so - * without this a stalled resolver outlives the caller's deadline. The lookup itself keeps running - * in the background; only the wait on it ends. - */ -export function untilAborted<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> { - if (!signal) { - return promise; - } - - if (signal.aborted) { - // The caller already gets the abort; the lookup's own outcome is no longer wanted, but its - // rejection must still be observed, or it surfaces later as an unhandled rejection. - promise.catch(() => undefined); - return Promise.reject(signal.reason); - } - - return new Promise<T>((resolve, reject) => { - const onAbort = () => reject(signal.reason); - signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (value) => { - signal.removeEventListener("abort", onAbort); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", onAbort); - reject(error); - } - ); - }); -} - export async function fetchPinnedPublicUrl({ target, signal, diff --git a/src/utils/notifications/dispatch.ts b/src/utils/notifications/dispatch.ts index 0d8acbd2a8..b1f5a7d934 100644 --- a/src/utils/notifications/dispatch.ts +++ b/src/utils/notifications/dispatch.ts @@ -3,6 +3,7 @@ import { dispatchSystem } from "./channels/system"; import { dispatchTelegram } from "./channels/telegram"; import { dispatchWebhook } from "./channels/webhook"; import { notificationsConfig } from "./config"; +import { recordNotification } from "./ledger"; import type { ChannelName, NotificationEvent, SayChannelConfig } from "./types"; /** True when `tools say` spoke the message (or there was nothing to say). Never throws. */ @@ -49,6 +50,17 @@ export async function dispatchNotification(event: NotificationEvent): Promise<bo const only = event.only; const allows = (name: ChannelName): boolean => only === undefined || only.includes(name); + if (allows("system") && !channels.system.enabled) { + recordNotification({ + source: event.app, + channel: "macos", + outcome: "suppressed", + reason: "system channel off in notifications config", + title: event.title, + body: event.message, + }); + } + const channelNames = ["system", "telegram", "webhook", "say"] as const; const results = await Promise.allSettled([ allows("system") ? dispatchSystem(event, channels.system) : Promise.resolve(true), @@ -60,6 +72,16 @@ export async function dispatchNotification(event: NotificationEvent): Promise<bo for (let i = 0; i < results.length; i++) { const r = results[i]; + // The system channel records itself in postNotification, with the backend it used. + if (channelNames[i] !== "system" && allows(channelNames[i]) && channels[channelNames[i]].enabled) { + recordNotification({ + source: event.app, + channel: channelNames[i], + outcome: r.status === "fulfilled" && r.value ? "posted" : "failed", + title: event.title, + body: event.message, + }); + } if (r.status === "rejected") { logger.warn({ err: r.reason, app: event.app, channel: channelNames[i] }, "Channel dispatch failed"); diff --git a/src/utils/notifications/ledger.test.ts b/src/utils/notifications/ledger.test.ts new file mode 100644 index 0000000000..fbdde1d0c4 --- /dev/null +++ b/src/utils/notifications/ledger.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { NOTIFICATION_LEDGER_MAX_BYTES, notificationLedgerPath, recordNotification } from "./ledger"; + +describe("notification ledger", () => { + const saved = process.env.GENESIS_TOOLS_HOME; + + beforeEach(() => { + process.env.GENESIS_TOOLS_HOME = mkdtempSync(join(tmpdir(), "ledger-")); + }); + + afterEach(() => { + process.env.GENESIS_TOOLS_HOME = saved; + }); + + it("appends one JSON line per notification with the writer and time", () => { + recordNotification({ source: "hub", channel: "macos", outcome: "posted", title: "Merged", body: "#561" }); + recordNotification({ source: "hub", channel: "telegram", outcome: "failed", title: "Merged" }); + const lines = readFileSync(notificationLedgerPath(), "utf8") + .trim() + .split("\n") + .map((l) => SafeJSON.parse(l)); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ source: "hub", channel: "macos", outcome: "posted", body: "#561" }); + expect(lines[1].writer.app).toBe("tools"); + expect(Number.isNaN(Date.parse(lines[0].at))).toBe(false); + }); + + it("moves a full file aside before writing", () => { + recordNotification({ source: "x", channel: "macos", outcome: "posted" }); + writeFileSync(notificationLedgerPath(), "x".repeat(NOTIFICATION_LEDGER_MAX_BYTES + 1)); + recordNotification({ source: "y", channel: "macos", outcome: "posted" }); + expect(readFileSync(notificationLedgerPath(), "utf8").trim().split("\n")).toHaveLength(1); + expect(readFileSync(`${notificationLedgerPath()}.1`, "utf8").length).toBeGreaterThan( + NOTIFICATION_LEDGER_MAX_BYTES + ); + }); +}); diff --git a/src/utils/notifications/ledger.ts b/src/utils/notifications/ledger.ts new file mode 100644 index 0000000000..df66438550 --- /dev/null +++ b/src/utils/notifications/ledger.ts @@ -0,0 +1,56 @@ +import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs"; +import { dirname } from "node:path"; +import { SafeJSON } from "@genesiscz/utils/json"; +import { logger } from "@genesiscz/utils/logger"; +import { genesisToolsDir } from "@genesiscz/utils/storage/root"; + +/** + * The one record of every notification any GenesisTools or Genesis surface sent, or decided not to send: + * `~/.genesis-tools/notifications/ledger.jsonl`, one JSON object per line. The Swift apps write the same + * file with the same fields (GenesisKit `NotificationLedger.swift`), so "what pinged me in the last 40 hours, + * and why" is one read (Martin, 2026-10-23: "make sure GenesisTools and GenesisPlayground are being logged + * for every of those notifications"). Before this, Genesis.app's cache banners were logged nowhere, and the + * macOS unified log kept about 14 hours of them without titles. + */ +export function notificationLedgerPath(): string { + return genesisToolsDir("notifications", "ledger.jsonl"); +} + +/** Past this size the file moves to `ledger.jsonl.1` (replacing the older one) and a new file starts. */ +export const NOTIFICATION_LEDGER_MAX_BYTES = 20 * 1024 * 1024; + +export interface NotificationLedgerEntry { + /** What produced it: `claude-usage`, `hub`, `monitor.cache`, `disk`, ... */ + source: string; + /** Where it went: `macos`, `in-app`, `telegram`, `webhook`, `say`. */ + channel: string; + /** `posted`, `failed`, `suppressed` (a rule decided against it; `reason` says which), or `withdrawn`. */ + outcome: "posted" | "failed" | "suppressed" | "withdrawn"; + title?: string; + subtitle?: string; + body?: string; + reason?: string; + [field: string]: unknown; +} + +/** Appends one line. Never throws: a ledger write must not stop a notification. */ +export function recordNotification(entry: NotificationLedgerEntry): void { + const path = notificationLedgerPath(); + + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + + if (existsSync(path) && statSync(path).size > NOTIFICATION_LEDGER_MAX_BYTES) { + renameSync(path, `${path}.1`); + } + + const line = { + at: new Date().toISOString(), + ...entry, + writer: { app: "tools", pid: process.pid, argv: process.argv.slice(2, 4) }, + }; + appendFileSync(path, `${SafeJSON.stringify(line)}\n`, { mode: 0o600 }); + } catch (error) { + logger.warn({ error, path, source: entry.source }, "notification ledger: could not write the line"); + } +} diff --git a/src/utils/package-store.ts b/src/utils/package-store.ts index ad6fe2f0c0..6ef0711c75 100644 --- a/src/utils/package-store.ts +++ b/src/utils/package-store.ts @@ -22,6 +22,9 @@ export const STORE_PACKAGES: Readonly<Record<string, string>> = { "@huggingface/transformers": "4.2.0", "@lancedb/lancedb": "0.38.0", "@qdrant/js-client-rest": "1.19.0", + // Only the youtube server's checkout uses it. As a repo dependency its 5.9 MB of declarations were about 6% of + // every TypeScript program (2026-10-25). + stripe: "22.6.0", }; /** diff --git a/src/utils/providers/session-paths.test.ts b/src/utils/providers/session-paths.test.ts index 3ad3f05996..d09d5ef5f7 100644 --- a/src/utils/providers/session-paths.test.ts +++ b/src/utils/providers/session-paths.test.ts @@ -11,6 +11,7 @@ import { nativeSessionRootsForHome, nativeTranscriptMaxDepth, primaryCodexHome, + transcriptStem, } from "./session-paths"; describe("nativeSessionRoots", () => { @@ -127,6 +128,16 @@ describe("isNativeTranscript", () => { expect(isNativeTranscript("claude", "abc.jsonl")).toBe(true); expect(isNativeTranscript("codex", "rollout.jsonl")).toBe(true); }); + + test("codex also accepts the .jsonl.zst rollouts Codex 0.164 compresses; the stem is the same for both forms", () => { + const name = "rollout-2026-10-16T20-01-33-01a145e0-5602-7a41-93fa-ffc564c21132"; + + expect(isNativeTranscript("codex", `${name}.jsonl.zst`)).toBe(true); + expect(isNativeTranscript("claude", "abc.jsonl.zst")).toBe(false); + expect(isNativeTranscript("codex", "rollout.zst")).toBe(false); + expect(transcriptStem(`${name}.jsonl.zst`)).toBe(name); + expect(transcriptStem(`${name}.jsonl`)).toBe(name); + }); }); describe("nativeTranscriptMaxDepth", () => { diff --git a/src/utils/providers/session-paths.ts b/src/utils/providers/session-paths.ts index 6dd463f723..fb0052f67c 100644 --- a/src/utils/providers/session-paths.ts +++ b/src/utils/providers/session-paths.ts @@ -34,6 +34,23 @@ export function primaryCodexHome(): string | undefined { export type NativeSessionProvider = AccountProviderAlias; +/** + * A coding agent's own home folder: `CLAUDE_CONFIG_DIR` or `~/.claude`, the primary `CODEX_HOME` or `~/.codex`, + * `GROK_HOME` or `~/.grok`. The one place that builds these roots; lint-rules `no-hand-built-claude-home` and + * `no-hand-built-codex-grok-home` point here. + */ +export function nativeHome(kind: NativeSessionProvider, home = homedir()): string { + if (kind === "claude") { + return env.paths.getClaudeConfigDir() ?? join(home, ".claude"); + } + + if (kind === "codex") { + return primaryCodexHome() ?? join(home, ".codex"); + } + + return env.grok.getHomeOverride() ?? join(home, ".grok"); +} + /** * On-disk CLI session stores. Claude: `~/.claude/projects`. Grok: * `~/.grok/sessions`. Codex: `~/.codex/sessions` plus `archived_sessions`. @@ -108,13 +125,26 @@ export function nativeSessionRootsForHome(kind: NativeSessionProvider, home: str return [join(home, "sessions")]; } +/** Codex CLI 0.164 compresses its old rollouts to `rollout-….jsonl.zst` (single-frame zstd). */ +const CODEX_COMPRESSED_SUFFIX = ".jsonl.zst"; + export function isNativeTranscript(kind: NativeSessionProvider, fileName: string): boolean { if (kind === "grok") { return fileName === "updates.jsonl"; } + + if (kind === "codex" && fileName.endsWith(CODEX_COMPRESSED_SUFFIX)) { + return true; + } + return fileName.endsWith(".jsonl"); } +/** A transcript file name without `.jsonl` or `.jsonl.zst`: the id part, the same for both forms of one file. */ +export function transcriptStem(fileName: string): string { + return fileName.replace(/\.jsonl(?:\.zst)?$/, ""); +} + export function nativeTranscriptMaxDepth(kind: NativeSessionProvider): number { if (kind === "claude") { return 6; diff --git a/src/utils/security/vault-purpose.ts b/src/utils/security/vault-purpose.ts index 518e1eb292..92ab3357ae 100644 --- a/src/utils/security/vault-purpose.ts +++ b/src/utils/security/vault-purpose.ts @@ -303,7 +303,7 @@ export function redactCredentialFields(value: unknown): unknown { /** `tools <tool> <args>`, with credentials redacted before cutting to a length a window can show. */ export function currentCommandLine(argv: string[] = process.argv, max = 300): string { const words = redactCredentialArgs(["tools", currentToolName(argv), ...argv.slice(2)]).map((word) => - word.includes(" ") ? `"${word}"` : word + word === "" || word.includes(" ") ? `"${word}"` : word ); const line = words.join(" "); diff --git a/src/utils/session-changes/codex.ts b/src/utils/session-changes/codex.ts index ea045eade3..631d939785 100644 --- a/src/utils/session-changes/codex.ts +++ b/src/utils/session-changes/codex.ts @@ -49,15 +49,18 @@ function codexHomes(): string[] { } /** - * The rollout of a Codex thread: `<home>/sessions/YYYY/MM/DD/rollout-<ts>-<thread id>.jsonl`, or - * the same under `archived_sessions`. Newest days are searched first. + * The rollout of a Codex thread: `<home>/sessions/YYYY/MM/DD/rollout-<ts>-<thread id>.jsonl` (or `.jsonl.zst` once + * Codex compressed it), or the same in `archived_sessions`, which also keeps files directly in its root. Newest days + * are searched first. */ export function findCodexRollout(threadId: string, homes: string[] = codexHomes()): string | null { if (!/^[A-Za-z0-9-]+$/.test(threadId)) { return null; } - const suffix = `-${threadId}.jsonl`; + const isThread = (entry: string) => + entry.startsWith("rollout-") && + (entry.endsWith(`-${threadId}.jsonl`) || entry.endsWith(`-${threadId}.jsonl.zst`)); for (const home of homes) { for (const bucket of ["sessions", "archived_sessions"]) { @@ -67,13 +70,17 @@ export function findCodexRollout(threadId: string, homes: string[] = codexHomes( continue; } + const flat = listDir(root).find(isThread); + + if (flat) { + return join(root, flat); + } + for (const year of listDir(root)) { for (const month of listDir(join(root, year))) { for (const day of listDir(join(root, year, month))) { const dayDir = join(root, year, month, day); - const hit = listDir(dayDir).find( - (entry) => entry.startsWith("rollout-") && entry.endsWith(suffix) - ); + const hit = listDir(dayDir).find(isThread); if (hit) { return join(dayDir, hit); @@ -88,7 +95,7 @@ export function findCodexRollout(threadId: string, homes: string[] = codexHomes( } export function isCodexRollout(path: string): boolean { - return /(^|\/)rollout-[^/]+\.jsonl$/.test(path); + return /(^|\/)rollout-[^/]+\.jsonl(?:\.zst)?$/.test(path); } /** The text of a user message item, unless it is a harness preamble (`<environment_context>`, plugin lists). */ diff --git a/src/utils/session-changes/index.ts b/src/utils/session-changes/index.ts index 19595a701a..f2f02192c5 100644 --- a/src/utils/session-changes/index.ts +++ b/src/utils/session-changes/index.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readWholeTextSync } from "@genesiscz/utils/fs/read-lines"; import { logger } from "@genesiscz/utils/logger"; import { findCodexRollout, isCodexRollout, parseCodexRollout } from "./codex"; import { type ComputedSessionChanges, computeSessionChanges, type SessionChangesInput } from "./compute"; @@ -57,7 +57,7 @@ const { log } = logger.scoped("session-changes"); function readTranscript(sessionId: string, path: string): SessionTranscript { // Folded: a resident process (the hub server) then reads only what the session's files gained since its last ask. return isCodexRollout(path) - ? parseCodexRollout(sessionId, readFileSync(path, "utf8")) + ? parseCodexRollout(sessionId, readWholeTextSync(path)) : readClaudeTranscriptFolded(path); } diff --git a/src/utils/session-changes/session-changes.test.ts b/src/utils/session-changes/session-changes.test.ts index b36793fd88..ee08e2c4e5 100644 --- a/src/utils/session-changes/session-changes.test.ts +++ b/src/utils/session-changes/session-changes.test.ts @@ -3,6 +3,7 @@ import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { SafeJSON } from "@genesiscz/utils/json"; +import { findCodexRollout, isCodexRollout } from "./codex"; import { readClaudeTranscriptFolded } from "./fold"; import { analyzeCommand, @@ -852,3 +853,20 @@ describe("readClaudeTranscriptFolded", () => { expect(final.calls.find((call) => call.id === "t-sub")?.agentId).toBe("a1"); }); }); + +describe("findCodexRollout (Codex 0.164 compresses old rollouts to .jsonl.zst)", () => { + it("finds a compressed rollout in the day tree and one directly in archived_sessions", () => { + const home = mkdtempSync(join(tmpdir(), "gt-codex-rollouts-")); + const day = join(home, "sessions", "2026", "10", "01"); + mkdirSync(day, { recursive: true }); + mkdirSync(join(home, "archived_sessions"), { recursive: true }); + const compressed = join(day, "rollout-2026-10-01T09-00-00-thread-zst.jsonl.zst"); + const archived = join(home, "archived_sessions", "rollout-2026-03-10T20-11-50-thread-old.jsonl.zst"); + writeFileSync(compressed, ""); + writeFileSync(archived, ""); + + expect(findCodexRollout("thread-zst", [home])).toBe(compressed); + expect(findCodexRollout("thread-old", [home])).toBe(archived); + expect(isCodexRollout(compressed)).toBe(true); + }); +}); diff --git a/src/utils/tsconfig.json b/src/utils/tsconfig.json index 74793e16ba..e4e3fb05e2 100644 --- a/src/utils/tsconfig.json +++ b/src/utils/tsconfig.json @@ -3,9 +3,11 @@ // and Bun resolves a dependency's internal aliases from the dependency's own // nearest tsconfig — so @genesiscz/utils/* and @ui/* self-imports keep // working when the package is consumed from node_modules, where the repo - // root tsconfig.json does not exist. Mirrors the root compilerOptions. + // root tsconfig.json does not exist. Mirrors the root compilerOptions except `paths` (only the package's own + // aliases); the root's `false` flags are TypeScript's defaults. Keep `types` in step with the root. "compilerOptions": { "lib": ["ESNext", "DOM"], + "types": ["bun"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", diff --git a/src/utils/ui/custom/app-sidebar.tsx b/src/utils/ui/custom/app-sidebar.tsx index 6c8a6aa2f5..314f6113e3 100644 --- a/src/utils/ui/custom/app-sidebar.tsx +++ b/src/utils/ui/custom/app-sidebar.tsx @@ -16,7 +16,11 @@ import type React from "react"; import { type ReactNode, useState } from "react"; export type SidebarGroupTheme = "primary" | "purple" | "accent" | "neutral"; -type IconComponent = React.ElementType<{ className?: string }>; +// Component types, not React.ElementType: ElementType is a union of every HTML tag too, so each JSX use checked its +// props against all of them (about 175k type instantiations per file, 2026-10-25). +type IconComponent = React.ComponentType<{ className?: string }>; +/** The router's link (TanStack `Link`). */ +export type SidebarLinkComponent = React.ComponentType<{ to: string; className?: string; children?: ReactNode }>; export interface SidebarNavItem { title: string; @@ -50,9 +54,9 @@ interface AppSidebarProps { MenuItemComponent?: React.ComponentType<{ item: SidebarNavItem; active: boolean; - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; }>; - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; } const groupLabel: Record<SidebarGroupTheme, string> = { @@ -250,7 +254,7 @@ function MenuLink({ to, children, }: { - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; to: string; children: ReactNode; }) { diff --git a/src/utils/ui/custom/coming-soon-card.tsx b/src/utils/ui/custom/coming-soon-card.tsx index e271dc7376..e009eab6f9 100644 --- a/src/utils/ui/custom/coming-soon-card.tsx +++ b/src/utils/ui/custom/coming-soon-card.tsx @@ -11,7 +11,8 @@ import { FeatureCardNexusTitle, } from "./feature-card"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; const tint: Record< FeatureCardNexusColor, diff --git a/src/utils/ui/custom/empty-state.tsx b/src/utils/ui/custom/empty-state.tsx index 57eada7c34..ecf68e821a 100644 --- a/src/utils/ui/custom/empty-state.tsx +++ b/src/utils/ui/custom/empty-state.tsx @@ -1,7 +1,8 @@ import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; interface EmptyStateIconProps { icon: IconComponent; diff --git a/src/utils/ui/custom/feature-grid.tsx b/src/utils/ui/custom/feature-grid.tsx index 27a37ca39b..07fda8d507 100644 --- a/src/utils/ui/custom/feature-grid.tsx +++ b/src/utils/ui/custom/feature-grid.tsx @@ -9,7 +9,8 @@ import { FeatureCardNexusHeader, } from "./feature-card"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; const colorStyles: Record<FeatureCardNexusColor, { bg: string; icon: string; badge: string }> = { cyan: { bg: "bg-cyan-500/10", icon: "text-cyan-400", badge: "bg-cyan-500/20 text-cyan-400" }, @@ -33,7 +34,8 @@ export interface FeatureGridItem { interface FeatureGridProps { items: FeatureGridItem[]; - LinkComponent: React.ElementType; + /** A component type, not React.ElementType (see IconComponent). */ + LinkComponent: React.ComponentType<{ to: string; children?: React.ReactNode }>; } export function FeatureGrid({ items, LinkComponent }: FeatureGridProps) { diff --git a/src/utils/ui/custom/floating-action-button.tsx b/src/utils/ui/custom/floating-action-button.tsx index 7448fda11d..de48dd7a04 100644 --- a/src/utils/ui/custom/floating-action-button.tsx +++ b/src/utils/ui/custom/floating-action-button.tsx @@ -2,7 +2,8 @@ import { Button } from "@ui/components/button"; import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; interface FloatingActionButtonProps { icon: IconComponent; diff --git a/src/utils/ui/custom/index.ts b/src/utils/ui/custom/index.ts index 09efe98caf..2196da925e 100644 --- a/src/utils/ui/custom/index.ts +++ b/src/utils/ui/custom/index.ts @@ -1,7 +1,7 @@ export { AlertBlock } from "./alert-block"; export { AnimatedCard } from "./animated-card"; export { AppShell } from "./app-shell"; -export type { SidebarGroupTheme, SidebarNavGroup, SidebarNavItem } from "./app-sidebar"; +export type { SidebarGroupTheme, SidebarLinkComponent, SidebarNavGroup, SidebarNavItem } from "./app-sidebar"; export { AppSidebar } from "./app-sidebar"; export { AppleWindow } from "./apple-window"; export { ChartSkeleton } from "./chart-skeleton"; diff --git a/src/utils/ui/custom/tab-bar.tsx b/src/utils/ui/custom/tab-bar.tsx index d4cca4072d..29aad24ca0 100644 --- a/src/utils/ui/custom/tab-bar.tsx +++ b/src/utils/ui/custom/tab-bar.tsx @@ -1,7 +1,8 @@ import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; export interface TabDef<V extends string> { value: V; diff --git a/src/utils/ui/custom/view-mode-toggle.tsx b/src/utils/ui/custom/view-mode-toggle.tsx index e19ba09902..360f7ceb0b 100644 --- a/src/utils/ui/custom/view-mode-toggle.tsx +++ b/src/utils/ui/custom/view-mode-toggle.tsx @@ -2,7 +2,8 @@ import { IconButton } from "@ui/components/icon-button"; import { cn } from "@ui/lib/utils"; import type React from "react"; -type IconComponent = React.ElementType<{ className?: string }>; +// Not React.ElementType: its union of every HTML tag made each JSX use ~175k instantiations (see app-sidebar.tsx). +type IconComponent = React.ComponentType<{ className?: string }>; export interface ViewMode<V extends string> { value: V; diff --git a/src/utils/ui/dashboards.test.ts b/src/utils/ui/dashboards.test.ts index 440b3862be..72e283ad87 100644 --- a/src/utils/ui/dashboards.test.ts +++ b/src/utils/ui/dashboards.test.ts @@ -36,6 +36,7 @@ describe("port registry", () => { expect(WEB_SERVICES["ai-proxy"].port).toBe(8317); expect(WEB_SERVICES["mcp-gateway"].port).toBe(8318); expect(WEB_SERVICES["telegram-webhook"].port).toBe(8319); + expect(WEB_SERVICES["mcp-oauth-callback"].port).toBe(8320); expect(registryEntryForPort(9886)?.name).toBe("YouTube Server"); }); diff --git a/src/utils/ui/dashboards.ts b/src/utils/ui/dashboards.ts index c907dd502c..224e8be5c7 100644 --- a/src/utils/ui/dashboards.ts +++ b/src/utils/ui/dashboards.ts @@ -403,6 +403,17 @@ export const WEB_SERVICES = { note: `Bind 127.0.0.1 only. The tunnel rule for the webhook path points here (\`${toolCommand("telegram-bot webhook tunnel")}\`).`, matchProcess: matchGenesisTool("telegram-bot"), }, + "mcp-oauth-callback": { + key: "mcp-oauth-callback", + name: "MCP OAuth Callback", + description: "Loopback OAuth redirect for MCP servers with a pre-registered app (no dynamic registration).", + port: 8320, + launch: null, + portOverride: null, + serviceKind: "other", + note: `Bound only while ${toolCommand("mcp-manager auth login")} waits for the browser. The provider app must list http://127.0.0.1:8320/callback exactly.`, + matchProcess: matchGenesisTool("mcp-manager"), + }, } as const satisfies Record<string, WebServiceEntry>; export type WebServiceKey = keyof typeof WEB_SERVICES; diff --git a/src/utils/worker/contract.test.ts b/src/utils/worker/contract.test.ts index a444cb10a0..824921372e 100644 --- a/src/utils/worker/contract.test.ts +++ b/src/utils/worker/contract.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { buildWorkerContract, WORKER_REPORT_KEYS } from "./contract"; +import { buildWorkerContract, type ReviewManifest, reviewLines, WORKER_REPORT_KEYS } from "./contract"; const bus = { agentName: "codex_task", leadName: "lead", rendezvousSession: "s-1" }; @@ -78,4 +78,65 @@ describe("buildWorkerContract", () => { "loaded for reference" ); }); + + test("review: each tier stacks the rules below it, and low is triage without them", () => { + const low = reviewLines("low").join("\n"); + const medium = reviewLines("medium").join("\n"); + const high = reviewLines("high").join("\n"); + const xhigh = reviewLines("xhigh").join("\n"); + const max = reviewLines("max").join("\n"); + + expect(low).toContain("Triage only: name the three riskiest files"); + expect(low).not.toContain("Read every hunk"); + expect(medium).toContain("Read every hunk of every file in the manifest."); + expect(medium).not.toContain("Open the callers"); + expect(high).toContain("Read every hunk"); + expect(high).toContain("Open the callers and the tests of every changed function."); + expect(high).toContain("Label each finding observed"); + expect(high).not.toContain("per-file pass"); + expect(xhigh).toContain("Label each finding observed"); + expect(xhigh).toContain("Probe each finding (`bun -e`, a test) before you report it, or mark it inferred."); + expect(xhigh).toContain("second pass over every file that touches secrets, processes, concurrency or I/O"); + expect(xhigh).not.toContain("adversarial pass"); + expect(max).toContain("Probe each finding"); + expect(max).toContain("Read the area rules of every touched folder."); + expect(max).toContain("Make an adversarial pass"); + + for (const text of [low, medium, high, xhigh, max]) { + expect(text).toContain( + "`1. [Severity] path:line - defect. Scenario: <what triggers it>. Fix: <the change>.`" + ); + expect(text).toContain("`Files reviewed: <n> of <N>`"); + expect(text).toContain("`Not reviewed: none`"); + } + }); + + test("review: the manifest and tier ride after the report shape; without review the text is unchanged", () => { + const manifest: ReviewManifest = { + base: "origin/master", + files: [ + { path: "src/a.ts", added: 40, deleted: 2 }, + { path: "assets/logo.png", added: 0, deleted: 0, binary: true }, + ], + added: 40, + deleted: 2, + }; + + for (const backend of ["codex", "grok", "claude"] as const) { + const plain = buildWorkerContract({ backend, sandbox: "read-only" }); + const reviewer = buildWorkerContract({ + backend, + sandbox: "read-only", + review: { effort: "high", manifest }, + }); + + expect(buildWorkerContract({ backend, sandbox: "read-only", review: null })).toBe(plain); + // Without surfaces the review block is the only addition, appended after the report shape. + expect(reviewer.startsWith(`${plain}\n`)).toBe(true); + expect(reviewer).toContain("You are a code REVIEWER at the high tier."); + expect(reviewer).toContain("Files to review (2, +40 -2):\n- src/a.ts +40 -2\n- assets/logo.png (binary)"); + expect(reviewer).toContain("`git diff origin/master...HEAD -- <path>`"); + expect(reviewer.indexOf("OPEN: ")).toBeLessThan(reviewer.indexOf("code REVIEWER")); + } + }); }); diff --git a/src/utils/worker/contract.ts b/src/utils/worker/contract.ts index 1adcbe82e5..2550af5df4 100644 --- a/src/utils/worker/contract.ts +++ b/src/utils/worker/contract.ts @@ -17,6 +17,109 @@ export interface WorkerContractInput { bus?: WorkerBus | null; /** Which personal surfaces the worker loaded; on by default for every backend that can honour it. */ surfaces?: { skills: boolean; rules: boolean }; + /** Present when the worker is a code reviewer: the tier rules, the diff manifest and the review output shape. */ + review?: WorkerReviewInput | null; +} + +/** Review depth tiers, shallowest first. They reuse the reasoning-effort names the workers already take. */ +export const REVIEW_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +export type ReviewEffort = (typeof REVIEW_EFFORTS)[number]; + +export function isReviewEffort(value: unknown): value is ReviewEffort { + return typeof value === "string" && (REVIEW_EFFORTS as readonly string[]).includes(value); +} + +/** One changed file of `git diff --numstat <base>...HEAD`. */ +export interface ReviewManifestFile { + path: string; + added: number; + deleted: number; + /** git printed `-` for both counts: there is no text to read. */ + binary?: boolean; +} + +/** The files a review must cover, computed by the harness before the worker starts. */ +export interface ReviewManifest { + base: string; + files: ReviewManifestFile[]; + added: number; + deleted: number; +} + +export interface WorkerReviewInput { + effort: ReviewEffort; + manifest: ReviewManifest; +} + +/** Added plus deleted lines: the size the reasoning floor is measured against. */ +export function manifestChangedLines(manifest: Pick<ReviewManifest, "added" | "deleted">): number { + return manifest.added + manifest.deleted; +} + +/** `Files to review (3, +40 -2):` and one `path +a -d` line per file. */ +export function manifestLines(manifest: ReviewManifest): string[] { + return [ + `Base: ${manifest.base}. Read each change with \`git diff ${manifest.base}...HEAD -- <path>\`.`, + `Files to review (${manifest.files.length}, +${manifest.added} -${manifest.deleted}):`, + ...manifest.files.map((file) => + file.binary ? `- ${file.path} (binary)` : `- ${file.path} +${file.added} -${file.deleted}` + ), + ]; +} + +/** What each tier adds over the one below it; `reviewLines` stacks them up to the chosen tier. */ +const TIER_RULES: Record<ReviewEffort, string[]> = { + low: ["Triage only: name the three riskiest files of the manifest and why each one is risky."], + medium: [ + "Read every hunk of every file in the manifest.", + "Every finding names a concrete scenario that triggers it.", + ], + high: [ + "Open the callers and the tests of every changed function.", + "Run the focused tests your sandbox allows.", + "Label each finding observed (name the command that showed it) or inferred.", + ], + xhigh: [ + "Make a per-file pass over correctness, error paths, security, platform differences and tests.", + "Probe each finding (`bun -e`, a test) before you report it, or mark it inferred.", + "Make a second pass over every file that touches secrets, processes, concurrency or I/O.", + "Check the change's own follow-up fixes for new defects.", + ], + max: [ + "Read the area rules of every touched folder.", + "Trace interactions across files: process lifecycle, stacked branches, shared state.", + "Make an adversarial pass: how does this change break in production?", + ], +}; + +/** + * The review contract of one tier: the rules of every tier up to it (high includes medium's), then the + * output shape the depth gate parses. `low` is triage and has no medium rules under it. The shape lines + * come before the five report lines of {@link reportShapeLines}, which still end the message. + */ +export function reviewLines(effort: ReviewEffort): string[] { + const upTo = REVIEW_EFFORTS.indexOf(effort); + const rules = + effort === "low" ? TIER_RULES.low : REVIEW_EFFORTS.slice(1, upTo + 1).flatMap((tier) => TIER_RULES[tier]); + + return [ + `You are a code REVIEWER at the ${effort} tier. Do not change any file; report defects.`, + ...rules, + "Before the five report lines, your final message has this shape:", + "Numbered findings, one per item: `1. [Severity] path:line - defect. Scenario: <what triggers it>. Fix: <the change>.`", + "Severity is Critical, High, Medium or Low. With no findings, write the line `Findings: none`.", + "Then the line `Ran: <the commands you ran, or: nothing>`.", + "Then the line `Files reviewed: <n> of <N>`, where N counts every file of the manifest.", + "Then the line `Not reviewed: <path> (<reason>), ...` naming every file you did not read, or `Not reviewed: none`.", + ]; +} + +function reviewBlock(input: WorkerContractInput): string[] { + if (!input.review) { + return []; + } + + return [...reviewLines(input.review.effort), ...manifestLines(input.review.manifest)]; } /** The keys every worker's final message carries, in this order. `tools <backend> …` printers read RESULT. */ @@ -154,6 +257,7 @@ export function buildWorkerContract(input: WorkerContractInput): string { ...checkpointLines(), ...sandboxLines(input), ...reportShapeLines(), + ...reviewBlock(input), ...surfaceLines(input), ].join("\n"); } diff --git a/src/voice/index.ts b/src/voice/index.ts index 16749a1fa1..866d2ce85f 100644 --- a/src/voice/index.ts +++ b/src/voice/index.ts @@ -49,6 +49,7 @@ program .option("--events-file <file>", "Fixture transcript events") .option("--max-seconds <seconds>", "Maximum recording duration", "300") .option("--capsule", "Show the existing optional native Capsule") + .option("--save-audio <file>", "Also write the captured audio there (raw s16le, 16 kHz mono)") .option("--stop-on-stdin", "Stop when the owning UI closes stdin; unavailable with PCM stdin input") .option("--json", "Emit typed JSONL events") .action( @@ -61,6 +62,7 @@ program eventsFile?: string; maxSeconds: string; capsule?: boolean; + saveAudio?: string; stopOnStdin?: boolean; json?: boolean; }) => { @@ -88,6 +90,7 @@ program await withInterrupt( async (signal) => { const capsule = options.capsule ? openVoiceCapsule({ signal }) : null; + const audio = options.saveAudio ? Bun.file(options.saveAudio).writer() : undefined; let stop: (() => void) | undefined; const stopFromOwner = () => stop?.(); try { @@ -100,6 +103,7 @@ program signal, events, maxDurationMs, + onAudio: audio ? (frame) => audio.write(frame) : undefined, onEvent: (event) => { capsuleEvent(capsule, event); if (options.json) { @@ -127,6 +131,7 @@ program if (options.stopOnStdin) { process.stdin.pause(); } + await audio?.end(); await capsule?.close(); } }, diff --git a/src/youtube/commands/extension.ts b/src/youtube/commands/extension.ts index 8b2f77114b..86874f1e08 100644 --- a/src/youtube/commands/extension.ts +++ b/src/youtube/commands/extension.ts @@ -167,7 +167,9 @@ export async function buildExtension(opts: { devReload?: boolean; targets?: stri for (const target of targets) { const proc = Bun.spawn(["bun", "--bun", "vite", "build", "-c", resolve(root, "vite.config.ts")], { stdio: ["inherit", "inherit", "inherit"], - env: { ...process.env, EXT_TARGET: target, EXT_DEV: opts.devReload ? "1" : "0" }, + // A shell with NODE_ENV=development makes Vite emit jsxDEV calls next to production React, and the + // panel throws "jsxDEV is not a function" before it draws. + env: { ...process.env, NODE_ENV: "production", EXT_TARGET: target, EXT_DEV: opts.devReload ? "1" : "0" }, }); const exit = await proc.exited; diff --git a/src/youtube/lib/__tests__/billing-gateway.test.ts b/src/youtube/lib/__tests__/billing-gateway.test.ts index 3a147588a7..a36a82631a 100644 --- a/src/youtube/lib/__tests__/billing-gateway.test.ts +++ b/src/youtube/lib/__tests__/billing-gateway.test.ts @@ -1,5 +1,48 @@ import { describe, expect, it } from "bun:test"; -import { createStripeGateway } from "@app/youtube/lib/billing-gateway"; +import { + createStripeGateway, + type StripeCheckoutSessionParams, + type StripeLoader, +} from "@app/youtube/lib/billing-gateway"; + +/** A loader that installs nothing and returns a fake SDK recording every key and checkout request. */ +function fakeLoader(options: { failFirstLoad?: boolean } = {}) { + const keys: string[] = []; + const requests: StripeCheckoutSessionParams[] = []; + let ensures = 0; + let loads = 0; + const loader: StripeLoader = { + ensure: async () => { + ensures++; + }, + load: async () => { + loads++; + + if (options.failFirstLoad && loads === 1) { + throw new Error("import failed"); + } + + return { + default: class { + checkout = { + sessions: { + create: async (params: StripeCheckoutSessionParams) => { + requests.push(params); + return { id: `cs_${requests.length}`, url: `https://checkout.test/${requests.length}` }; + }, + }, + }; + + constructor(secretKey: string) { + keys.push(secretKey); + } + }, + }; + }, + }; + + return { loader, keys, requests, counts: () => ({ ensures, loads }) }; +} describe("createStripeGateway", () => { it("constructs offline and exposes both checkout methods", () => { @@ -8,4 +51,41 @@ describe("createStripeGateway", () => { expect(typeof gateway.createPackCheckout).toBe("function"); expect(typeof gateway.createSubscriptionCheckout).toBe("function"); }); + + it("loads the SDK once for concurrent checkouts and sends the attribution metadata", async () => { + const fake = fakeLoader(); + const gateway = createStripeGateway("sk_test_key", fake.loader); + + const [pack, subscription] = await Promise.all([ + gateway.createPackCheckout({ priceId: "price_pack", userId: 7, packId: "pack_1" }), + gateway.createSubscriptionCheckout({ priceId: "price_plan", userId: 7, planId: "plan_1" }), + ]); + + expect(fake.counts()).toEqual({ ensures: 1, loads: 1 }); + expect(fake.keys).toEqual(["sk_test_key"]); + expect([pack.id, subscription.id]).toEqual(["cs_1", "cs_2"]); + expect(fake.requests[0]).toMatchObject({ + mode: "payment", + client_reference_id: "7", + line_items: [{ price: "price_pack", quantity: 1 }], + payment_intent_data: { metadata: { packId: "pack_1", userId: "7" } }, + }); + expect(fake.requests[1]).toMatchObject({ + mode: "subscription", + subscription_data: { metadata: { planId: "plan_1", userId: "7" } }, + }); + }); + + it("retries the load on the next checkout after a failed import", async () => { + const fake = fakeLoader({ failFirstLoad: true }); + const gateway = createStripeGateway("sk_test_key", fake.loader); + const input = { priceId: "price_pack", userId: 1, packId: "pack_1" }; + + await expect(gateway.createPackCheckout(input)).rejects.toThrow("import failed"); + await expect(gateway.createPackCheckout(input)).resolves.toEqual({ + id: "cs_1", + url: "https://checkout.test/1", + }); + expect(fake.counts()).toEqual({ ensures: 2, loads: 2 }); + }); }); diff --git a/src/youtube/lib/billing-gateway.ts b/src/youtube/lib/billing-gateway.ts index aedc18a56c..55f18213b8 100644 --- a/src/youtube/lib/billing-gateway.ts +++ b/src/youtube/lib/billing-gateway.ts @@ -1,4 +1,5 @@ -import Stripe from "stripe"; +import { importStorePackage } from "@genesiscz/utils/package-store"; +import { ensurePackage } from "@genesiscz/utils/packages"; /** Per spec, success/cancel both return to YouTube — the extension polls balance; no landing page. */ const CHECKOUT_RETURN_URL = "https://www.youtube.com"; @@ -26,12 +27,64 @@ export interface StripeGateway { createSubscriptionCheckout(input: SubscriptionCheckoutInput): Promise<CheckoutSessionResult>; } -export function createStripeGateway(secretKey: string): StripeGateway { - const stripe = new Stripe(secretKey); +/** + * The part of the Stripe SDK this file calls. The SDK is a package-store package (src/utils/package-store.ts), + * installed on the first checkout, not a repo dependency: its 228 declaration files (5.9 MB) were about 6% of every + * TypeScript program that holds the youtube server, for this one file (2026-10-25). + */ +export interface StripeCheckoutSessionParams { + mode: "payment" | "subscription"; + client_reference_id: string; + line_items: Array<{ price: string; quantity: number }>; + metadata: Record<string, string>; + payment_intent_data?: { metadata: Record<string, string> }; + subscription_data?: { metadata: Record<string, string> }; + success_url: string; + cancel_url: string; +} + +export interface StripeCheckoutSession { + id: string; + url: string | null; +} + +export interface StripeClient { + checkout: { sessions: { create(params: StripeCheckoutSessionParams): Promise<StripeCheckoutSession> } }; +} + +export interface StripeModule { + default: new (secretKey: string) => StripeClient; +} + +/** How the SDK reaches the process; tests pass fakes so no package is installed and Stripe is never called. */ +export interface StripeLoader { + ensure: (name: string, options: { label: string }) => Promise<void>; + load: (name: string) => Promise<StripeModule>; +} + +const storeLoader: StripeLoader = { + ensure: ensurePackage, + load: (name) => importStorePackage<StripeModule>(name), +}; + +async function loadStripe(secretKey: string, loader: StripeLoader): Promise<StripeClient> { + await loader.ensure("stripe", { label: "Stripe SDK (youtube checkout)" }); + const { default: Stripe } = await loader.load("stripe"); + return new Stripe(secretKey); +} + +export function createStripeGateway(secretKey: string, loader: StripeLoader = storeLoader): StripeGateway { + let client: Promise<StripeClient> | undefined; + // Loaded on the first checkout; a failed install or import is retried by the next one. + const stripe = () => + (client ??= loadStripe(secretKey, loader).catch((err: unknown) => { + client = undefined; + throw err; + })); return { async createPackCheckout(input: PackCheckoutInput): Promise<CheckoutSessionResult> { - const session = await stripe.checkout.sessions.create({ + const session = await (await stripe()).checkout.sessions.create({ mode: "payment", client_reference_id: String(input.userId), line_items: [{ price: input.priceId, quantity: 1 }], @@ -46,7 +99,7 @@ export function createStripeGateway(secretKey: string): StripeGateway { return toResult(session); }, async createSubscriptionCheckout(input: SubscriptionCheckoutInput): Promise<CheckoutSessionResult> { - const session = await stripe.checkout.sessions.create({ + const session = await (await stripe()).checkout.sessions.create({ mode: "subscription", client_reference_id: String(input.userId), line_items: [{ price: input.priceId, quantity: 1 }], @@ -63,7 +116,7 @@ export function createStripeGateway(secretKey: string): StripeGateway { }; } -function toResult(session: Stripe.Checkout.Session): CheckoutSessionResult { +function toResult(session: StripeCheckoutSession): CheckoutSessionResult { if (!session.url) { throw new Error("stripe checkout session response missing url"); } diff --git a/src/youtube/lib/collection-ask.ts b/src/youtube/lib/collection-ask.ts index dc0d83b5f3..a7b6c2e618 100644 --- a/src/youtube/lib/collection-ask.ts +++ b/src/youtube/lib/collection-ask.ts @@ -31,7 +31,9 @@ const SYSTEM_PROMPT = [ ].join("\n"); export interface CollectionAskDeps { - callLLMStructured: (options: CallLLMStructuredOptions<AgentStep>) => Promise<CallLLMStructuredResult<AgentStep>>; + callLLMStructured: ( + options: CallLLMStructuredOptions<z.ZodType<AgentStep>> + ) => Promise<CallLLMStructuredResult<AgentStep>>; } export interface CollectionAskInput { diff --git a/src/youtube/lib/summarize.types.ts b/src/youtube/lib/summarize.types.ts index e8edcff12c..1b920929d2 100644 --- a/src/youtube/lib/summarize.types.ts +++ b/src/youtube/lib/summarize.types.ts @@ -13,6 +13,7 @@ import type { CallLLMStructuredResult, } from "@genesiscz/utils/ai/core/call"; import type { ProviderChoice } from "@genesiscz/utils/ask/types"; +import type { z } from "zod"; export interface SummaryProgressInfo { phase: "summarize"; @@ -75,7 +76,9 @@ export interface SummaryBin { export interface SummaryServiceDeps { callLLM: (opts: CallLLMOptions) => Promise<CallLLMResult>; - callLLMStructured: <T>(opts: CallLLMStructuredOptions<T>) => Promise<CallLLMStructuredResult<T>>; + callLLMStructured: <S extends z.ZodType>( + opts: CallLLMStructuredOptions<S> + ) => Promise<CallLLMStructuredResult<z.output<S>>>; } /** Pre-flight cost estimate for an LLM summarize/QA call (GET /videos/:id/estimate). */ diff --git a/src/youtube/ui/routes/__root.tsx b/src/youtube/ui/routes/__root.tsx index dc375eed3a..20229055dd 100644 --- a/src/youtube/ui/routes/__root.tsx +++ b/src/youtube/ui/routes/__root.tsx @@ -7,13 +7,12 @@ import { fetchUiConfig } from "@app/yt/config.client"; import { applyAppearance } from "@app/yt/lib/appearance"; import { pageTitleFromPath } from "@app/yt/lib/theme"; import { useEventStream } from "@app/yt/ws.client"; -import { AppShell, AppSidebar, type SidebarNavItem } from "@genesiscz/utils/ui/custom"; +import { AppShell, AppSidebar, type SidebarLinkComponent, type SidebarNavItem } from "@genesiscz/utils/ui/custom"; import { cn } from "@genesiscz/utils/ui/lib/utils"; import { useDashboardTheme } from "@genesiscz/utils/ui/theme/dashboard-theme"; import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router"; import { BriefcaseBusiness, History, Library, Newspaper, PlaySquare, Settings, Tv } from "lucide-react"; -import type React from "react"; import { useEffect } from "react"; interface RouterContext { @@ -42,7 +41,7 @@ function YtSidebarItem({ }: { item: SidebarNavItem; active: boolean; - LinkComponent: React.ElementType; + LinkComponent: SidebarLinkComponent; }) { const Icon = item.icon; diff --git a/tsconfig.json b/tsconfig.json index ef0e9b91ef..95f4e8e062 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,11 +14,18 @@ // `bun run typecheck:all` (src/spotify/ui/tsconfig.json). "src/spotify/ui", "src/monitor/ui", - "src/Internal/commands/reas/ui" + "src/Internal/commands/reas/ui", + // Build output (a 764 KB minified bundle under src/youtube/ui cost 0.6 s and 23k types). + "src/**/dist" ], "compilerOptions": { // Enable latest features "lib": ["ESNext", "DOM"], + // TypeScript 6 adds no @types package to the global scope unless named here (an import still loads its + // package's types). Without it Bun's globals came only from the one + // file that imports "bun", so a smaller program (a reviewer's, a per-folder one) reported + // "Cannot find name 'Bun'" in files that never import it. + "types": ["bun"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force",