Merge remote-tracking branch 'can1357/main' into fix/empty-stop-guard-tooluse
This commit is contained in:
@@ -1,6 +1,178 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added `display.smoothStreaming` setting (default `true`) to let users enable or disable smooth assistant-stream text reveal
|
||||
- Added `/tan <work>` slash command to fork the current conversation into a background agent so tangential work can continue asynchronously while your main session stays active
|
||||
- Added a background `/tan` dispatch message that records the handoff in the transcript and marks the delegated work as non-blocking
|
||||
- Added `providerPromptCacheKey` support to `CreateAgentSessionOptions` so `/tan` background sessions can reuse the parent session’s prompt-cache lineage
|
||||
- Added session cloning for `/tan` runs with copied artifacts and shared MCP proxy tools
|
||||
- Added `SessionManager.forkFrom`’s optional `suppressBreadcrumb` mode to avoid breadcrumb updates when forking background `/tan` sessions
|
||||
- Added OSC 5522 enhanced paste handling in `InputController`, so terminal clipboard events are decoded as image or text payloads and inserted without passing raw paste sequences to the editor
|
||||
- Added bracketed image-path paste support in `CustomEditor` so a single pasted image file path (PNG/JPEG/GIF/WEBP) is loaded from disk and inserted as an image candidate
|
||||
- Added direct support for `Image #N` insertion from pasted local image paths by routing successful image-path pastes through the same image normalization and resize flow as clipboard image pastes
|
||||
- Added `/fresh` to rotate the provider-facing session id and clear in-memory provider stream/cache state without changing the local session file.
|
||||
- Added a `ChatBlock` transcript primitive (`modes/components/chat-block.ts`) and a single `ctx.present(...)` sink (with `ctx.resetTranscript()`) so chat output is mounted in one place instead of the repeated `chatContainer.addChild(...)` + `ui.requestRender()` pattern scattered across controllers. `ChatBlock` carries a React/Svelte-style lifecycle — `onMount` starts effects, `onCleanup` registers teardown, `finish()` self-completes (stops timers and freezes the block at its final content), and `dispose()`/`resetTranscript()` tears everything down — so animated blocks own their own resources instead of leaking `setInterval`/`requestRender` bookkeeping into callers. The MCP "Connecting…" spinner is now such a block.
|
||||
- Added a `framedBlock` output-block helper (`tui/output-block.ts`) plus a `borderColor` override and `applyBg: false` (no background fill) on output blocks, a `renderStatusLine` `iconOverride`, and an `icon.search` (magnifier) theme symbol — so tool renderers can draw self-contained muted-outline frames and search-family tools can show a magnifier instead of a checkmark.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the bash tool frame to use a plain top rule instead of repeating "Bash" in the title bar, and folded minimizer raw-output artifact links into the status footer as `Artifact: <id>`.
|
||||
|
||||
- Changed grouped `read` output to use a white filled-circle mark for the group/single-read success state and omit duplicate per-file success marks inside multi-read groups.
|
||||
|
||||
- Changed assistant streaming output to reveal text incrementally at 30 FPS with grapheme-safe adaptive catch-up, instead of replacing the whole message chunk-by-chunk
|
||||
- Changed shimmer-driven TUI animations (working text, pending bash/eval borders, and theme activity-spinner documentation) to render at 30fps instead of 60fps.
|
||||
- Changed running `task` tool agent rows to use a static `•` marker and shimmer only the subagent name, leaving descriptions, stats, and nested tool detail text solid while removing the rotating status glyph from those rows.
|
||||
- Changed settings singleton method access to reuse bound methods for the active instance instead of allocating a new bound function on every `settings.get` lookup.
|
||||
- Changed plan-mode approval to keep the drafted `local://<slug>-plan.md` file at its original name as the canonical plan path, so approved plans are no longer renamed when leaving plan mode
|
||||
- Changed plan-mode write enforcement so only `local://` artifact files are writable during planning, blocking working-tree edits and allowing scratch or draft plan files in the local artifact area
|
||||
- Changed the `todo` tool result renderer to stop redrawing every phase's full task list on each update: when a multi-phase list is rendered collapsed (the default, not manually expanded), only phases the latest update touched — the phase holding the in_progress task, any phase with a just-completed task, and phases named by the ops that ran (`init` counts as touching all) — render their tasks; untouched phases collapse to a one-line `N. Name done/total` summary. When call args are unavailable (e.g. transcript rebuilds) it falls back to the in_progress/completed-transition signals, and the manual expand toggle still shows every task. Also dropped the blank separator line previously inserted between phases.
|
||||
- Changed non-agent API operations (title and commit-message generation, image generation, web search, eval `llm()`, auto-thinking classifier, memory consolidation) to use session-aware API key resolution with auth retries via `registry.resolver()` / `authStorage.resolver()`, refreshing the active credential before rotating to another account
|
||||
- Changed image generation to wrap every provider fetch branch in `withAuth`, so 401 / usage-limit errors trigger credential force-refresh and rotation for authStorage-backed providers (OpenAI-hosted, antigravity, xai-oauth) while env-only providers (openrouter, gemini) stay single-attempt
|
||||
- Changed web-search providers using `authStorage.getApiKey` (anthropic, exa, tavily, parallel, synthetic, zai, kimi) to wrap HTTP calls in `withAuth` for automatic credential rotation on 401 / usage-limit errors
|
||||
- Changed the directory grouping for `find`, `search`, `ast_grep`, `ast_edit`, and `lsp` diagnostics from a single flat `# dir/` heading per immediate directory to a multi-level tree that folds the common path prefix into one heading. Previously every group repeated the full directory path — so results rooted outside cwd printed the absolute prefix (e.g. `/Users/me/proj/`) on every heading and nested directories were never collapsed. Now a single-child directory chain folds into one heading (`# packages/pkg/src/`, including an absolute root for out-of-cwd results), subdirectories nest one `#` deeper (`## nested/` → `### child.ts`), and each directory's own files are listed before its subdirectories. TUI hyperlink reconstruction tracks the nested directory stack across the whole output so file and code-frame links keep resolving to the correct absolute paths.
|
||||
- Changed the plan-mode approval surface from an inline transcript block plus a separate bottom selector into a single fullscreen overlay (like `/copy`) and overhauled its navigation. The overlay now renders the plan per-section through `ScrollView` (line-level ↑/↓ scroll, Shift+↑/↓ to scroll faster, PgUp/PgDn, g/G) with no stray per-line `…`, and — when the terminal is wide enough and the plan has ≥2 headings — shows a compact VS Code-style section sidebar (the redundant plan-title heading and any "Contents" label are omitted). Focus moves between regions with Tab/Shift+Tab (and flows at the edges: Down past the last section or the bottom of the body drops into the approval options; Up steps back), while the sidebar glows to track the scrolled section. The sidebar can fast-jump between sections, delete a section (with `u` undo), and annotate sections with feedback (`a`); deletions and annotations are collected into refinement feedback that is submitted back to the model when the operator picks "Refine plan". Mouse works too: clicking an approval option activates it, clicking a sidebar section jumps to it, and the wheel scrolls the plan. ←/→ always drive the model-tier slider, Enter confirms, the external-editor key opens the plan, and Esc cancels. The overlay borrows the terminal's alternate screen buffer for its lifetime (`fullscreen` overlay), so the transcript stays put on the normal screen instead of bleeding through scrollback behind the modal.
|
||||
- Changed the interactive controllers (command, MCP, selector, extension-UI, event), debug panels, and the status/error/warning helpers to render chat output through `ctx.present(...)` instead of appending to `chatContainer` and calling `ui.requestRender()` directly; transcript rebuilds dispose live blocks via `ctx.resetTranscript()` so animated blocks' timers stop on reset.
|
||||
- Changed tool-execution block rendering so the container (`ToolExecutionComponent`) is a transparent passthrough — it no longer inserts a top/bottom blank line, adds left/right padding, or paints a state-colored background behind tool output. Tools with substantial body now self-frame with a muted outline and the tool title in the frame's top bar (`edit`/`apply_patch`, `write`, `ask`, `todo`, `github`, `goal`, `inspect_image`, `search_tool_bm25`, `task`), matching the already-framed `bash`/`read`/`eval`/`debug`/`web_search`/`lsp` blocks, while streaming/in-progress and trivial results collapse to a clean status line. The search-family list tools (`find`, `search`, `ast_grep`) and `job` render frameless/minimal; `find`/`search`/`ast_grep` show a magnifier on success instead of a checkmark, and `job` drops its `Job:` label prefix (the per-job rows are self-describing). The `search_tool_bm25`, `github`, and `inspect_image` frames draw with no background fill, and `inspect_image`'s label was shortened to `Inspect`.
|
||||
- Changed the plan-mode active prompt (`prompts/system/plan-mode-active.md`) to make plans decision-complete and cut filler. Added an Objective framing ("another engineer can execute end-to-end without making a single design decision"), a shared "Resolving Unknowns" section (explore discoverable facts before asking; reserve `ask` for non-derivable preferences/tradeoffs with 2–4 options + a recommended default), and a single shared "The Plan" structure (Context / Approach grouped by behavior not file-by-file / ≤5 Critical files / Verification / Assumptions) that replaces the per-branch structure guidance previously duplicated across the iterative and parallel workflows. Added explicit prohibitions on sections that decide nothing (Non-Goals, Out of Scope, Alternatives Considered, Risks/Mitigations boilerplate, Future Work), on enumerating every file/line, and on inventing schema/validation/precedence policy the request never established.
|
||||
- Changed completion notifications (`completion.notify`) to fire whenever the agent yields its turn, including in the foreground. The `agent_end` notification was previously gated behind background mode (`isBackgrounded`), so an ordinary foreground turn never emitted one; the gate is gone and the desktop toast now fires on every normal turn completion (still skipped for aborted/error turns and when `completion.notify` is `off`).
|
||||
- Changed the in-progress `task` tool block to keep the shared `context` brief (`# Goal` / `# Constraints` background) visible after the first progress snapshot arrives, instead of dropping it the moment the streaming call view was replaced by the result frame, and to stop animating a spinner/clock next to the `Task` frame header while running — the per-agent body lines already carry their own running spinner, so the header now shows a static state icon (matching the completed/failed header icons). The context is rendered through a shared `buildContextSection` helper that also undoes per-field double-encoding, so the brief reads cleanly in the result frame even though `renderResult` receives the raw (un-repaired) tool args.
|
||||
- Changed the messaging shown when you press Esc to interrupt a streaming turn from the ambiguous `Operation aborted` / `Tool execution was aborted: Request was aborted` to `Interrupted by user`, so a deliberate user interrupt no longer reads like an internal failure. Every Esc/flush interrupt path (`onEscape` while streaming, the queued-message restore-and-abort path, and the empty-submit queue flush) threads the reason through `AgentSession.abort({ reason })` → `Agent.abort(reason)` so it rides the `AbortController` onto the aborted assistant message's `errorMessage`; the turn label renders it verbatim on both the live and replay paths, and the synthetic placeholder results paired with in-flight tool calls now read `Tool execution was aborted: Interrupted by user`. Aborts that carry no reason still fall back to the retry-aware `Operation aborted` generic. Transcript label resolution is centralized in `resolveAbortLabel` (`session/messages.ts`).
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the `/background` (and `/bg`) slash command and the background-mode subsystem it was the sole entry point for — `InteractiveMode.isBackgrounded`, `createBackgroundUiContext`, `handleBackgroundEvent`, and every `isBackgrounded` guard across the input/event/extension-UI controllers and UI helpers. The command suspended the whole process group via `SIGTSTP` (a leftover testing shortcut) instead of detaching the running agent, which is not the expected workflow — use terminal panes or a multiplexer instead.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed inline `find` and `search` result blocks to align with grouped `read` output and render their success headers with the normal tool-title color instead of accent blue.
|
||||
|
||||
- Fixed the working-status shimmer to opt into the loader's 30fps animated-message repaint path while keeping both the status spinner and pending bash/eval tool spinners on their normal 80 ms glyph cadence.
|
||||
- Fixed consecutive `read` tool calls failing to collapse into a single grouped block when a reasoning model emits one read per completion (`[thinking, read]`). The read group was reset on every assistant `message_start`, so each read rendered as its own one-entry `Read …` line; now a read run accretes across completions and is broken only by a rendered non-empty text/thinking block, a non-read tool, or a user/IRC message — matching the transcript-rebuild path. `ReadToolGroupComponent` now reports its live/finalized state so the growing `Read (N)` header repaints correctly on native-scrollback (risk) terminals.
|
||||
- Fixed the `task` tool shared-context brief rendering raw Markdown headings (`# Goal`, `# Constraints`) inside framed call/result blocks instead of using the normal Markdown renderer.
|
||||
- Fixed the animated pending border on `bash`/`eval` blocks leaving a frozen dark "bar" segment behind after a backgrounded command finalized through the async update path. Once a command is auto-backgrounded (`details.async.state === "running"`) the block stays "partial" in the TUI until the async job-manager delivers the final result, but it also gets committed to native scrollback — so a mid-sweep shimmer frame baked a stray darkened border segment into the committed copy. The border now stops animating (and the 60fps redraw loop stops) the moment a block enters the backgrounded state, so the committed frame is a clean static border.
|
||||
- Fixed cold `omp` launch to clear native terminal history on the first paint, avoiding a once-per-launch duplicate welcome/transcript copy before the normal session replay.
|
||||
- Fixed plan approval resolution so `resolve` with `action: "apply"` can still find the plan file when `extra.title` is missing or stale by falling back to the current plan path and most-recent local plan artifacts
|
||||
- Fixed the search-family tool magnifier glyph (`find`, `search`, `ast_grep`, `search_tool_bm25`) to use the `accent` title color instead of `success` green, so the icon matches the tool title in the status header instead of standing out
|
||||
- Fixed TTSR stream interrupts to pass the matched rule name through the abort reason, so aborted in-flight tool placeholders say why they were stopped instead of `Request was aborted`.
|
||||
- Fixed URL reads for binary/special payloads to reuse local readers: remote archives list their root entries, SQLite databases show their table overview, notebooks render as editable cells, and unrenderable binary returns a metadata notice instead of decoded byte garbage.
|
||||
- Fixed pasted image-file paths that cannot be loaded to fall back to normal text paste with status feedback instead of disappearing.
|
||||
- Fixed tool-output file paths not being clickable OSC 8 `file://` hyperlinks in several renderers. `read` titles for plain text and image files (the common case) emitted no link at all because the renderer only linked when a `resolvedPath` was recorded — which the ordinary file/image read paths never set, keeping the absolute path only in `meta.source`; the renderer now falls back to that source path. `write` headers were never wrapped in a hyperlink and now link to the absolute path written (file, archive entry, SQLite, and conflict resolutions). `edit`/`apply_patch` headers wrapped the model-supplied (often cwd-relative) argument path, producing a root-anchored `file:///rel/path` URI; they now link the absolute `details.path` instead. Finally, `search`, `ast_grep`, and `ast_edit` produced doubled link targets (`/proj/src/src/file.ts`) for searches scoped to a subdirectory, because the renderer resolved the cwd-relative display paths against the scope directory rather than cwd — the scoped-search base is now the session cwd (with the scoped file's absolute path still seeding single-file body lines).
|
||||
- Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts
|
||||
- Fixed the bash tool corrupting commands that embed multi-byte UTF-8 (e.g. `✓`/`×` inside a `grep -E` pattern) ahead of a trailing `| head`/`| tail`. The `bash.stripTrailingHeadTail` rewrite cut at char-offset positions reported by `brush-parser` while slicing the command by byte offset, so the trailing-pipe strip landed mid-pattern and dropped the closing quote — turning `… |✓|×|XCTAssert" | tail -80` into `… |✓|×-80` and making execution fail with `pi-natives:command: unterminated double quote`. Fixed in `pi_shell::fixup` (`@oh-my-pi/pi-natives`).
|
||||
- Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts
|
||||
- Fixed duplicate file entries in grouped outputs for `find`, `search`, `ast_grep`, `ast_edit`, and `lsp` diagnostics when the same path appeared multiple times
|
||||
- Fixed search, grep, and edit output rendering so repeated directory group blank-line boundaries no longer break nested path/link reconstruction
|
||||
- Fixed `omp dry-balance --bench` flooding the terminal with staircased, duplicated spinner/status lines (and an indented summary) when the tty has ONLCR/OPOST disabled (raw mode). The interactive progress region separated rows with a bare LF and repositioned with a column-preserving `\x1b[<n>A` cursor-up, both of which only land at column 0 when the terminal translates LF→CRLF; with that translation off, every 80 ms redraw cascaded down and to the right into scrollback. The live region now carriage-returns before every cleared row, terminates each row with CRLF, and caps each row to the terminal width so a wrapped line cannot desync the cursor-up from the logical line count.
|
||||
- Fixed inconsistent vertical spacing between transcript blocks: some blocks (tool results from `search`/`find` and other renderer-backed tools) rendered with a doubled gap (a leading `Spacer` plus the content box's own `paddingY`), while others (the grouped `read` card, file-mention lists, IRC cards) rendered with no gap at all. Vertical spacing is now owned entirely by the chat renderer: `TranscriptContainer` strips each block's plain-blank top/bottom edges and inserts exactly one blank line between consecutive blocks, so every block is separated by a single consistent gap regardless of which component produced it. Individual components (assistant/user/tool/read-group/bash/eval/skill/custom/hook/compaction/branch/todo-reminder/plan-review messages) no longer emit their own leading `Spacer`/`paddingY` for separation, and multi-row groups (IRC cards, file-mention lists, completed-job batches, and the bordered command/`/changelog`/`/context`/version/OAuth/debug panels) are wrapped as single `TranscriptBlock` children so the renderer spaces them as one unit. Background-colored box padding is preserved as block-internal design.
|
||||
- Fixed `resolve` with `action: "discard"` surfacing a hard `isError` "No pending action to resolve" failure to the model when the agent asked to cancel a staged action (e.g. an `ast_edit` preview) but nothing was pending. A discard is a request to reach the "no staged change" end-state, which already holds in that case, so it is now honored as a successful cancellation (`"Nothing to discard; no pending action remains."` with `details.action: "discard"`) instead of an error. `action: "apply"` with no pending action still errors.
|
||||
- Fixed the collapsed tool-output expand hint rendering double brackets (e.g. `((Ctrl+O for more))`) — the `EXPAND_HINT` text already carried its own parentheses and then `formatExpandHint` wrapped it again with the theme's bracket glyphs. The hint now resolves the key actually bound to `app.tools.expand` at render time and reads `⟨<key>: Expand⟩` (e.g. `⟨Ctrl+O: Expand⟩`), so a single bracket pair surrounds it and a user remap of the expand keybinding is reflected instead of a hard-coded `Ctrl+O`.
|
||||
- Fixed the `edit`/`apply_patch` tool dropping its outlined frame while streaming/in-progress (only the final result was framed); the in-progress diff preview now renders inside the same muted frame as the completed result.
|
||||
- Fixed the `todo` and `job` tools rendering a success icon and success styling on a failed/error result; error results now show the error icon and a red frame border.
|
||||
- Fixed `debug` tool refusing every `dlv` launch on Go modules. The launch handler ran `validateLaunchProgram` before adapter selection and rejected any directory program with `launch program resolves to a directory`, while dlv's default `mode=debug` requires a Go package path (a directory or `.go` source file). Adapter resolution now precedes validation, directory programs prefer adapters that advertise `acceptsDirectoryProgram` before falling back to native extensionless debuggers, the rejection only fires when the resolved adapter does not advertise that flag (set on `dlv` in `dap/defaults.json`), and dlv's `mode` is derived from the program shape — directories and `.go` files launch as `mode=debug`, other files as `mode=exec` — so `omp` can debug both Go packages and pre-built binaries ([#2020](https://github.com/can1357/oh-my-pi/issues/2020)).
|
||||
|
||||
## [15.10.0] - 2026-06-06
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Replaced the `providers.parallelFetch` boolean setting with the `providers.fetch` enum (`auto` / `native` / `trafilatura` / `lynx` / `parallel` / `jina`) that selects the URL reader-backend priority for the `read`/`fetch` tool, mirroring `providers.image`/`providers.webSearch`. Existing configs are migrated automatically: the legacy key is dropped and the new `auto` default applies.
|
||||
|
||||
### Added
|
||||
|
||||
- Added a GitHub Actions read handler to the `read`/web-fetch GitHub scraper. Fetching `github.com/{owner}/{repo}/actions/runs/{id}` renders the run metadata plus a per-job breakdown (steps listed for any job that did not succeed), and `…/actions/runs/{id}/job/{id}` (also the API-style `…/jobs/{id}`) renders a single job's metadata, step table, and full plain-text logs. Logs are fetched via the `actions/jobs/{id}/logs` redirect using `GITHUB_TOKEN`/`GH_TOKEN` when present, with the per-line ISO timestamp prefix and leading BOM stripped; the section degrades to an explicit notice when logs are unavailable (no token, private repo, or expired/unfinalized run).
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed eval `agent()` subagents so they are never subject to the `task.maxRuntimeMs` wall-clock cap. The parent cell's idle watchdog is already suspended for the entire bridge call (`withBridgeTimeoutPause`), so a long-running fan-out/recovery workflow must not be killed by a per-subagent runtime limit. `runEvalAgent` now passes `maxRuntimeMs: 0` to `runSubprocess`, which honors an explicit `ExecutorOptions.maxRuntimeMs` override over the inherited setting.
|
||||
- Changed interactive timing behavior so `PI_TIMING=x pi` preloads the module timer before the CLI graph loads and includes the `(modules)` report. `PI_TIMING=full` now also exits after printing, matching `PI_TIMING=x`, so full module reports are usable for cold-start measurement without launching the TUI. Added the root `dev:timing` script for the same profiled startup path.
|
||||
- Changed coding-agent startup imports so normal TUI launch imports `InteractiveMode` directly, keeps print/RPC/ACP runners on their branch-only paths, and moves marketplace auto-update work behind a lightweight deferred starter.
|
||||
- Changed cold-launch setup gating so the full setup wizard (every scene plus the overlay and their TUI/OAuth/web-search/theme dependencies) is no longer statically imported by `main.ts`. The current setup version now lives in a tiny dependency-free `modes/setup-version` module, and the wizard barrel is lazy-loaded only when the stored setup version is stale or the wizard is forced — the common up-to-date launch skips loading it entirely.
|
||||
- Changed cold-launch startup imports so the hot-path CLI files no longer pull the full `@oh-my-pi/pi-ai` barrel: `commands/launch.ts` and `cli/args.ts` import `THINKING_EFFORTS`/`Effort` from the tiny `@oh-my-pi/pi-ai/effort` module, and `config/model-registry.ts` now imports its ~20 symbols from narrow subpaths (`api-registry`, `model-cache`, `model-manager`, `model-thinking`, `models`, `provider-models`, `types`, `utils/event-stream`) instead of the barrel — so launching no longer eagerly loads every provider, auth, OAuth, and usage module re-exported by the barrel.
|
||||
- Changed the `read`/`fetch` HTML reader-backend priority to `native > trafilatura > lynx > parallel > jina` (was `parallel > jina > trafilatura > lynx > native`). The in-process native `htmlToMarkdown` runs first — instant, no network, full-fidelity — so the common case no longer depends on a remote service, and a stalled remote backend can no longer mask it. Selecting a specific backend via `providers.fetch` tries it first, then the rest fall back. The low-quality gate (`>100` chars and not `isLowQualityOutput`) now applies uniformly to every backend; when none clears it, the highest-priority substantial-but-low-quality output is still surfaced so the `llms.txt` / document-extraction fallbacks keep running.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed eval `agent()` failures surfacing as an opaque `RuntimeError: bridge call '__agent__' failed` with no reason. When a subagent aborted, `runEvalAgent` built its failure message with `result.error ?? result.stderr ?? result.abortReason ?? …`, but `result.stderr` is the empty string on a clean abort (and `result.error` is gated on a non-empty `stderr`), so the nullish chain stopped at `""` and never reached `abortReason`. The empty string propagated through the loopback bridge and the Python prelude's `RuntimeError(msg or "bridge call … failed")`, discarding the real reason. The chain now uses `||` so an empty `stderr` falls through to `abortReason`.
|
||||
- Fixed subagent aborts being mislabeled as the generic "Cancelled by caller" when the abort originated inside the subagent's own turn (`stopReason: "aborted"` with no caller signal and no runtime-limit timer). `runSubprocess` now prefers the aborted assistant message's `errorMessage` (e.g. "Request was aborted" or a specific stream error) for that case, while a real caller signal or wall-clock abort still reports its precise reason.
|
||||
- Fixed a long streaming tool preview that alone overflows the viewport dropping its scrolled-off head on ED3-risk terminals (ghostty/kitty/iTerm2/…). When expanded with `Ctrl+O`, a streaming `write` (content streaming in) and a streaming `eval` (stdout streaming below its fixed code cell) render top-anchored and grow append-only, but the tool block never reported itself append-only to the transcript, so the renderer's commit-as-you-go boundary stopped at the block start and the earlier rows that scrolled above the viewport were committed nowhere — they vanished, leaving the preview looking like a viewport-tall circular buffer. `ToolExecutionComponent` now implements `isTranscriptBlockAppendOnly()` (gated on `isTranscriptBlockFinalized()`, so it also covers partial-result streams like `eval`), delegating to a renderer-declared `isStreamingPreviewAppendOnly` predicate so the expanded stream commits its head exactly like a streamed assistant reply. Collapsed previews (bounded sliding tail windows) and finalized/result previews (which can collapse to a capped view) stay deferred.
|
||||
- Fixed `read`/`fetch` silently dropping whole list sections on pages with malformed list markup — stray `<img>`, text, or `<video>` nodes as direct children of `<ul>` (e.g. the Alacritty changelog). The Jina reader (previously tried before the local renderers) mis-extracts such lists, returning empty `Added`/`Changed` sections; the native in-process renderer now runs first and preserves the full content.
|
||||
- Fixed `/usage` aggregate amount fallback using raw `limits.length` as account count — now counts unique `accountId` values from limit scopes, so N limits from a single account no longer display as "N accts".
|
||||
- Fixed `/usage` account labeling falling back to "account N" for providers that use `projectId` as their primary identity (e.g. Google Antigravity, Gemini CLI) — `projectId` from report metadata is now considered before the generic fallback.
|
||||
- Fixed the `todo` tool's TUI renderer crashing with `TypeError: args?.ops?.map is not a function` when a streaming tool-call delta surfaced a non-array `ops` field (mid-stream `parseStreamingJson` shapes like `{ ops: "[{" }`, or `[null]` entries before fields arrive). The renderer now treats non-array `ops`, non-object entries, and non-array `items` as missing structure instead of crashing, which also stops the spam-warn cascade that followed each malformed delta. Paired with the Anthropic-side reasoning-replay fix in `packages/ai` ([#2005](https://github.com/can1357/oh-my-pi/issues/2005)).
|
||||
- Fixed Python eval `agent()` collapsing subagent runtime-limit aborts (and other empty-stderr aborts) into a generic `RuntimeError: bridge call '__agent__' failed`. `runEvalAgent` coalesced the failure message with `??`, which stopped at the empty `stderr` and never reached `abortReason`, shipping an empty error through the loopback bridge. The bridge now prefers `abortReason` for aborts and trims empty `stderr`/`error` out of the fallback chain, so Python surfaces the actionable reason (e.g. `Subagent runtime limit exceeded (task.maxRuntimeMs=900000)`) ([#2006](https://github.com/can1357/oh-my-pi/issues/2006)).
|
||||
|
||||
## [15.9.69] - 2026-06-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added anonymous fallback for Perplexity web search, allowing `web_search` and explicit Perplexity provider usage when no Perplexity credentials are configured
|
||||
- Added `gallery` CLI command to render built-in tool renderer output across streaming, in-progress, success, and failure states
|
||||
- Added `omp gallery` filtering and rendering options (`--tool`, `--state`, `--width`, `--expanded`, and `--plain`) for focused renderer previews and plain-text output
|
||||
- Added `omp gallery` fidelity for tools whose renderers are attached on the tool instance (`lsp`, `task`): the gallery now drives them through the same custom-tool render branch production uses, so regressions in that path surface in the gallery rather than only in a live session.
|
||||
- Added `omp gallery --screenshot`, which renders the gallery through a real virtual terminal (VHS) and writes PNG screenshot(s) instead of ANSI, so agents (and anything that can only read raw bytes) can actually see the rendered output. The capture forces truecolor and matches the active theme/symbol preset; tall galleries split across multiple images (whole renderers are never cut). Tune with `--out`, `--font`, and `--font-size`; requires `vhs` on `PATH` and fails with install guidance when absent.
|
||||
- Added `app.display.reset`, bound to `Ctrl+L` by default, to force an immediate terminal display reset/redraw without resizing the window.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed authenticated Perplexity ask requests to default to the `experimental` model preference, matching the anonymous fallback.
|
||||
- Changed Perplexity explicit provider availability checks so the setup wizard can mark `perplexity` as available for manual selection without credentials, while auto provider discovery still requires auth
|
||||
- Changed the default persistent model selector shortcut from `Ctrl+L` to `Alt+M`, leaving `Ctrl+L` for display reset. Existing user remaps in `keybindings.yml` are preserved.
|
||||
- Changed the edit tool result header to carry the diff change stats (`+N / -M / K hunks`) inline next to the file path, and removed the redundant lone language-icon metadata row and the blank line between the header and the diff body, so a single-hunk edit renders as `✔ Edit: <icon> path:LINE ⟨+3 / 1 hunk⟩` immediately followed by the diff.
|
||||
- Changed the `web_search` tool result rendering: the answer text now shows in full instead of being truncated to a "… N more lines" preview (the `omp q` CLI still caps its compact output), each source renders as a single `title (domain) · age` line with the URL linked on the title (dropping the snippet and bare-URL rows), and the metadata block collapses to one `Provider: <model> @ <provider> (<auth>)` line plus `Usage:` (removing the redundant Sources/Citations/Request/Queries rows).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Perplexity `perplexity_ask` response parsing so OAuth, cookie, and anonymous searches correctly extract answer text and sources from JSON payloads
|
||||
- Fixed framed read results rendering with an extra blank row above and below the output block.
|
||||
- Fixed collapsed search result previews that could show only "… N more matches" when the first grouped section exceeded the preview budget. Collapsed search output now compacts to match rows, fills the budget with visible hits before the summary, and keeps truncation details out of the bottom user-visible notice.
|
||||
- Fixed the expanded search result view dumping every match when all hits live in one file. A single file's matches collapse into one blank-line group, and the expanded tree list ignored the line budget, so a hot file whose matches span its whole length rendered every row (e.g. lines 374–2858). Expanded search output is now bounded by a larger-than-collapsed budget (`EXPANDED_LINES × 2`), keeps surrounding context rows, and appends a `… N more matches` summary when truncated.
|
||||
- Fixed boolean environment flag overrides that were ORed with settings, so `PI_INTENT_TRACING=0`, `PI_AUTO_QA=0`, and per-backend eval flags now take precedence when present while falling back to config when unset.
|
||||
- Fixed custom-rendered tools that set `mergeCallAndResult` (e.g. `lsp`) rendering a redundant tool-name line above the framed result once a result arrived. `ToolExecutionComponent`'s custom-tool branch now emits the fallback label only when the tool has no `renderCall` and the call is not suppressed by an existing result, matching the built-in renderer branch.
|
||||
- Fixed the `write` tool result rendering with a green success checkmark even when the write failed. `writeToolRenderer.renderResult` now branches on `result.isError`, rendering the error status icon plus the failure message instead of the success header and content preview.
|
||||
- Fixed Perplexity OAuth/cookie web search returning a refusal answer ("I don't currently have access to the web-search tools in this turn") despite returning real sources. `callPerplexityOAuth` was prepending the API-style `web-search` system prompt to the query (`query_str = systemPrompt + "\n\n" + query`), but the consumer `www.perplexity.ai/rest/sse/perplexity_ask` endpoint has no system-message slot and reads the prepended instruction as a meta-prompt, making the model decline. The OAuth/cookie path now sends the bare query; the API-key path still passes the system prompt as a proper `system` message.
|
||||
- Fixed Perplexity OAuth web search always returning the free `turbo` model instead of the account's Pro model (e.g. `pplx_pro_upgraded`/Sonar). The `www.perplexity.ai/rest/sse/perplexity_ask` endpoint authenticates via the `__Secure-next-auth.session-token` cookie and ignores the `Authorization: Bearer` header entirely — so sending the OAuth session token as a bearer was treated as an anonymous request, which silently downgrades to `turbo` regardless of `model_preference`. The stored Perplexity OAuth token is itself the next-auth session JWT (the macOS app injects the same value as that cookie), so `callPerplexityAsk` now sends it as the `__Secure-next-auth.session-token` cookie, unlocking Pro model selection.
|
||||
|
||||
## [15.9.67] - 2026-06-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added `timeout-pause` and `timeout-resume` eval bridge status events emitted around `agent()`/`llm()` operations
|
||||
- Added a `/copy` picker: `/copy` now opens a fullscreen, outlined tree of recent assistant messages with their code blocks nested beneath (like `/tree`). Navigate with ↑↓, and Enter copies the highlighted node — a whole message, an individual code block, "All N blocks", or a bash/eval command interleaved with the assistant turn that issued it. A live preview pane shows the selected target, wrapping prose and syntax-highlighting code/commands.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed eval timeout accounting so delegated bridge calls now suspend the cell watchdog and start a fresh timeout window when runtime control returns
|
||||
- Changed `IdleTimeout` to support reference-counted pauses so overlapping delegated bridge calls keep timeout paused until all calls complete
|
||||
- Changed the default `app.message.followUp` binding from `Ctrl+Enter` alone to `[Ctrl+Q, Ctrl+Enter]` so the follow-up shortcut works in Windows Terminal, which does not deliver a distinct `Ctrl+Enter` event to console apps. `Ctrl+Q` mirrors the GitHub Copilot CLI default for the same action; existing remaps in `~/.omp/agent/keybindings.yml` are untouched, and if another user-remapped action already claims `Ctrl+Q`, that user binding wins while follow-up keeps `Ctrl+Enter`. `Ctrl+Q` is also reserved by `ExtensionRunner` so an extension cannot register that chord and be silently overwritten by the built-in follow-up handler ([#1903](https://github.com/can1357/oh-my-pi/issues/1903)).
|
||||
- Changed all scrollable TUI pickers and viewports to render through the shared `ScrollView` right-edge scrollbar for a uniform look, replacing their ad-hoc `(N/M)` / `[a-b/total]` text indicators (search hints and the tree filter-mode label are preserved). Covers the session/resume picker, model selector, OAuth provider selector, history search, session tree selector, agent dashboard list, extension list, user-message selector, the raw SSE debug viewer, the autoresearch dashboard overlay, and the session observer overlay.
|
||||
- Changed the `/model` and `/switch` selectors to dim and skip models whose context windows are smaller than the current chat context.
|
||||
- Changed `/copy` command targets to appear inline with recent assistant messages instead of as a separate "Last bash command" row at the end of the picker.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the idle `Working...` loader freezing on ED3-risk terminals with unobservable native scrollback by keeping foreground live-region rendering enabled from `agent_start` until `agent_end`, before the first assistant or tool event arrives.
|
||||
- Fixed framed tool output blocks rendering one column inset inside tool boxes; modern bordered blocks now span the same width as legacy background-filled tool boxes.
|
||||
- Fixed potential `TimeoutError` aborts for short `timeout` eval cells during long bridged `agent()`/`llm()` work where no progress events are emitted until completion
|
||||
- Fixed retry recovery to allow automatic retries without switching models when `retry.modelFallback` is disabled.
|
||||
- Fixed `ttsr.enabled: false` being ignored at runtime. TTSR rules were still being registered with `TtsrManager.addRule` and matched against stream deltas even when the global toggle was off, so disabling TTSR did not suppress rule injection or stream abort. The manager now gates `addRule`, `hasRules`, and `#matchBuffer` on the enabled flag, so disabling fully short-circuits the TTSR path. Condition rules fall through to the rulebook bucket instead of being silently swallowed. ([#1767](https://github.com/can1357/oh-my-pi/issues/1767))
|
||||
- Fixed the Python eval kernel hanging on Windows during `import pandas` / `import numpy`, with SIGINT unable to recover the cell. `PythonKernel.start()` spawned the runner with `windowsHide: true`, which in Bun maps to the Win32 `CREATE_NO_WINDOW` flag and detaches the long-lived child from any inherited console — so native extensions like `numpy/_core/_multiarray_umath.pyd` (and its bundled OpenBLAS/SLEEF thread-pool init) could deadlock inside `LoadLibraryExW`, and `GenerateConsoleCtrlEvent`-based SIGINT delivery silently became a no-op. The kernel now hides its window only when the host itself has no console to share (service / piped launch); an interactive TUI launch lets the kernel inherit the parent's console, matching the behavior of `python.exe` invoked from `cmd.exe` ([#1960](https://github.com/can1357/oh-my-pi/issues/1960)).
|
||||
- Fixed `task` renderer crashing the TUI with `TypeError: completeData?.map is not a function` when a subagent's `extractedToolData.yield` slot held a non-array value. `renderAgentResult` (and the live-progress sibling) cast the slot to `Array<{ data }>` and called `?.map`, but optional chaining short-circuits only on `null`/`undefined`, so a plain object made `.map` `undefined` and threw — taking down every `review` task render. Both sites now go through `normalizeYieldData`, which wraps a single object as a 1-element array and drops primitives ([#1987](https://github.com/can1357/oh-my-pi/issues/1987))
|
||||
- Fixed `sdk-async-job-manager-singleton` tests flaking under the full parallel suite. The four `createAgentSession`-based cases ran on the default 5000ms per-test timeout, which two real session startups can exceed when `test:ts` saturates the machine across packages; on timeout the still-running test body and `afterEach` reset raced, surfacing a spurious "Unhandled error between tests" on the `AsyncJobManager.instance()` assertion. They now carry an explicit 60000ms timeout, matching the convention used by the other session-creating tests in this suite.
|
||||
- Fixed streaming `eval`, `bash`, `ssh`, and `task` call previews overflowing the live transcript viewport and cutting off their top while pending. A volatile tool block taller than the viewport could strand its scrolled-off head out of native scrollback on ED3-risk terminals (committed nowhere, repainted nowhere) until the result landed. The pending `eval` source preview now follows the streaming edge in a bounded 12-line tail window (newest lines pinned to the bottom, "… N earlier lines" on top) so you can watch the code being written without the box overflowing; `bash`/`ssh` commands and `task` context use a bounded head+tail window. `Ctrl+O` still lifts the cap for a full view.
|
||||
- Fixed the streaming `write` call preview ignoring `Ctrl+O` so the expand toggle was a no-op while a file was being written. Unlike the `eval`/`bash`/`ssh`/`task` streaming previews, `formatStreamingContent` never received the `expanded` flag, leaving the preview pinned to a bounded 12-line tail window even after pressing `Ctrl+O` — so on a large write you could not widen past the streaming edge until the tool result landed. The preview now lifts the cap to the full file (head through tail) when expanded, matching the documented streaming-preview behavior of the other tools.
|
||||
- Fixed turn-ending provider errors rendering twice — once as the transcript's inline `Error: …` line and again in the pinned banner above the editor (added in 15.9.5). The inline line is now suppressed while the same error is mirrored in the banner and restored to the transcript when the banner clears at the next turn, so the error stays in history without the duplicate render at the error moment.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the `/copy last|code|all|cmd` subcommands; every copy target is now reachable by picking it in the `/copy` tree.
|
||||
|
||||
## [15.9.5] - 2026-06-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added a persistent error banner pinned above the editor when an assistant turn ends on a provider error (e.g. Anthropic's "Output blocked by content filtering policy"). The transcript `Error: …` line scrolls away as the conversation grows, so terminal turns that ended on a stream error could pass unnoticed; the banner stays in the fixed region above the input and is cleared when the next turn starts.
|
||||
@@ -10,6 +182,10 @@
|
||||
- Added `OLLAMA_HOST` support for implicit local Ollama discovery when `OLLAMA_BASE_URL` is unset, so OMP picks up the same host setting used by Ollama.
|
||||
- Added `OLLAMA_CONTEXT_LENGTH` as a positive-integer context-window override for implicit local Ollama discovery, so users can correct OMP context budgeting without writing per-model overrides.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `tools.discoveryMode` to default to `auto`, which keeps discovery off for small tool sets and automatically switches to MCP-only tool discovery when more than 40 tools are registered.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed user-message rendering to materialize image links from embedded image blocks when rebuilding chat output, so image placeholders remain clickable after replayed or restored messages
|
||||
@@ -25,6 +201,7 @@
|
||||
- Blocked OSC 8 hyperlink wrapping for URI targets containing terminal control bytes to avoid rendering malformed control-sequence links
|
||||
|
||||
## [15.9.4] - 2026-06-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed chat transcript updates after submitting input so frozen scrollback is only thawed when native scrollback replay succeeds, preventing misplaced or duplicated rows when the viewport is not at the tail
|
||||
|
||||
@@ -1017,7 +1017,7 @@ Core pipeline in `renderUrl(url, timeout, raw, signal)`:
|
||||
- HTML-to-text conversion (`renderHtmlToText`)
|
||||
7. If HTML conversion is low-quality (`isLowQualityOutput`), try document-link extraction (`extractDocumentLinks`) + markit.
|
||||
|
||||
`renderHtmlToText(...)` fallback order is explicit in code: Jina reader endpoint (`https://r.jina.ai/<url>`), then `trafilatura` (via `ensureTool`), then `lynx`, then native `htmlToMarkdown`.
|
||||
`renderHtmlToText(...)` tries reader backends in priority order: native in-process `htmlToMarkdown`, then `trafilatura` (via `ensureTool`), then `lynx`, then Parallel extract, then the Jina reader endpoint (`https://r.jina.ai/<url>`). The `providers.fetch` setting picks the order — `auto` uses the default above; selecting a specific backend tries it first, then the rest as fallbacks. Every backend's output must clear the same quality gate (`>100` chars and not `isLowQualityOutput`); if none clears it, the highest-priority substantial-but-low-quality output is surfaced so step 7's targeted fallbacks still run. native operates on already-fetched HTML (instant, no network), so it both wins the common case and survives a stalled remote backend.
|
||||
|
||||
### Scraper registry role (`web/scrapers/index.ts`)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.9.4",
|
||||
"version": "15.10.1",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
# Dev launcher for the omp CLI, installed by `bun run install:dev`.
|
||||
#
|
||||
# Problem it solves: Bun reads `bunfig.toml` from the *current working
|
||||
# directory* at startup and evaluates its `preload` entries before running the
|
||||
# script. A bun-shebang bin (what `bun link` creates for `src/cli.ts`)
|
||||
# therefore inherits whatever `preload` the directory you happen to be in
|
||||
# declares. Running `omp`/`pi` inside an unrelated Bun project can execute — and
|
||||
# crash on — that project's preload, e.g.
|
||||
# error: Cannot find module '@v12sh/utils/frontmatter' from '.../loader.ts'
|
||||
#
|
||||
# Bun only reads the *exact* cwd (it does not walk parents) and ignores
|
||||
# `--config`/`BUN_BE_BUN` for this, so the fix is to launch Bun from an empty,
|
||||
# bunfig-free directory and restore the real cwd inside the process via the
|
||||
# preload shim alongside this file.
|
||||
set -e
|
||||
|
||||
# Resolve this script's real location even when invoked through a symlink
|
||||
# (`$HOME/.bun/bin/omp` -> this file).
|
||||
self=$0
|
||||
while [ -L "$self" ]; do
|
||||
link=$(readlink "$self")
|
||||
case $link in
|
||||
/*) self=$link ;;
|
||||
*) self=$(dirname "$self")/$link ;;
|
||||
esac
|
||||
done
|
||||
scripts_dir=$(CDPATH= cd -- "$(dirname -- "$self")" && pwd -P)
|
||||
cli=$scripts_dir/../src/cli.ts
|
||||
preload=$scripts_dir/dev-launch-preload.ts
|
||||
timing_preload=$scripts_dir/../../utils/src/module-timer.ts
|
||||
|
||||
launch_dir=${OMP_DEV_LAUNCH_DIR:-${HOME}/.omp/.dev-cwd}
|
||||
mkdir -p "$launch_dir"
|
||||
|
||||
OMP_LAUNCH_CWD=$PWD
|
||||
export OMP_LAUNCH_CWD
|
||||
cd "$launch_dir"
|
||||
if [ -n "${PI_TIMING:-}" ]; then
|
||||
exec bun --preload "$preload" --preload "$timing_preload" "$cli" "$@"
|
||||
fi
|
||||
exec bun --preload "$preload" "$cli" "$@"
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Bun `--preload` shim for the omp dev launcher (`scripts/dev-launch`).
|
||||
*
|
||||
* The launcher starts Bun from an empty, bunfig-free directory so a foreign
|
||||
* project's `bunfig.toml` `preload` cannot run inside the omp CLI: Bun reads
|
||||
* `bunfig.toml` from the *current working directory* on startup and evaluates
|
||||
* its `preload` entries before the entrypoint, so a bun-shebang bin inherits
|
||||
* whatever `preload` the directory you launched from declares (and crashes if
|
||||
* that preload can't resolve). This shim is loaded before the entrypoint's
|
||||
* imports run, so it restores the user's real working directory in time for
|
||||
* import-time snapshots (e.g. `getProjectDir()` in `@oh-my-pi/pi-utils/dirs`).
|
||||
*/
|
||||
const launchCwd = process.env.OMP_LAUNCH_CWD;
|
||||
if (launchCwd) {
|
||||
delete process.env.OMP_LAUNCH_CWD;
|
||||
try {
|
||||
process.chdir(launchCwd);
|
||||
} catch {}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveRoleSelection } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
@@ -82,7 +83,10 @@ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Prom
|
||||
messages: [{ role: "user", content: input, timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
apiKey: deps.registry.resolver(model.provider, {
|
||||
sessionId: deps.sessionId,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
maxTokens,
|
||||
disableReasoning: true,
|
||||
metadata,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { matchesKey, replaceTabs, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { matchesKey, replaceTabs, ScrollView, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import { formatElapsed, formatNum, isBetter } from "./helpers";
|
||||
import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state";
|
||||
@@ -76,14 +76,14 @@ export function createDashboardController(): DashboardController {
|
||||
const viewportRows = Math.max(4, terminalRows - 4);
|
||||
const maxScroll = Math.max(0, body.length - viewportRows);
|
||||
if (scrollOffset > maxScroll) scrollOffset = maxScroll;
|
||||
const visible = body.slice(scrollOffset, scrollOffset + viewportRows);
|
||||
const footer = renderOverlayFooter(width, scrollOffset, viewportRows, body.length, theme);
|
||||
return [
|
||||
header,
|
||||
...visible,
|
||||
...Array.from({ length: Math.max(0, viewportRows - visible.length) }, () => ""),
|
||||
footer,
|
||||
];
|
||||
const sv = new ScrollView(body.slice(scrollOffset, scrollOffset + viewportRows), {
|
||||
height: viewportRows,
|
||||
scrollbar: "auto",
|
||||
totalRows: body.length,
|
||||
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
|
||||
});
|
||||
sv.setScrollOffset(scrollOffset);
|
||||
return [header, ...sv.render(width), renderOverlayFooter(width, theme)];
|
||||
},
|
||||
handleInput(data: string): void {
|
||||
const totalRows =
|
||||
@@ -406,18 +406,8 @@ function renderOverlayRunningLine(
|
||||
);
|
||||
}
|
||||
|
||||
function renderOverlayFooter(
|
||||
width: number,
|
||||
scrollOffset: number,
|
||||
viewportRows: number,
|
||||
totalRows: number,
|
||||
theme: Theme,
|
||||
): string {
|
||||
const position =
|
||||
totalRows > viewportRows
|
||||
? ` ${scrollOffset + 1}-${Math.min(totalRows, scrollOffset + viewportRows)}/${totalRows}`
|
||||
: "";
|
||||
const hint = theme.fg("dim", ` up/down j/k pageup pagedown g G esc${position} `);
|
||||
function renderOverlayFooter(width: number, theme: Theme): string {
|
||||
const hint = theme.fg("dim", " up/down j/k pageup pagedown g G esc ");
|
||||
const fill = Math.max(0, width - visibleWidth(hint));
|
||||
return theme.fg("borderMuted", "-".repeat(fill)) + hint;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const commands: CommandEntry[] = [
|
||||
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
|
||||
{ name: "dry-balance", load: () => import("./commands/dry-balance").then(m => m.default) },
|
||||
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
|
||||
{ name: "gallery", load: () => import("./commands/gallery").then(m => m.default) },
|
||||
{ name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
|
||||
{ name: "install", load: () => import("./commands/install").then(m => m.default) },
|
||||
{ name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* CLI argument parsing and help display
|
||||
*/
|
||||
import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
|
||||
import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai/effort";
|
||||
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { parseEffort } from "../thinking";
|
||||
@@ -284,7 +284,7 @@ export function getExtraHelpText(): string {
|
||||
${chalk.dim("# Search & Tools")}
|
||||
EXA_API_KEY - Exa web search
|
||||
BRAVE_API_KEY - Brave web search
|
||||
PERPLEXITY_API_KEY - Perplexity web search (API)
|
||||
PERPLEXITY_API_KEY - Perplexity web search API key (optional; anonymous fallback)
|
||||
PERPLEXITY_COOKIES - Perplexity web search (session cookie)
|
||||
TAVILY_API_KEY - Tavily web search
|
||||
ANTHROPIC_SEARCH_API_KEY - Anthropic web search (override; isolates search from main ANTHROPIC_API_KEY)
|
||||
|
||||
@@ -380,6 +380,18 @@ function isMessagesRequest(message: ParsedHttpMessage): boolean {
|
||||
return pathNameFromRequestTarget(message.path ?? "") === "/v1/messages";
|
||||
}
|
||||
|
||||
// Claude Code fires a background warmup/classification call on its small fast
|
||||
// model (a haiku variant, ANTHROPIC_SMALL_FAST_MODEL) before sending the user's
|
||||
// real message. Skip it so the capture lands on the actual prompt.
|
||||
function isBackgroundModelRequest(message: ParsedHttpMessage): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeBody(message.headers, message.body)) as { model?: unknown };
|
||||
return typeof parsed.model === "string" && parsed.model.toLowerCase().includes("haiku");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBody(headers: readonly HeaderEntry[], body: Buffer): string {
|
||||
const encoding = headerValue(headers, "content-encoding")?.toLowerCase().trim();
|
||||
try {
|
||||
@@ -636,7 +648,7 @@ export class ClaudeMessagesProxy {
|
||||
upstreamTls.write(data);
|
||||
const messages = requestParser.push(data);
|
||||
for (const message of messages) {
|
||||
if (!isMessagesRequest(message)) {
|
||||
if (!isMessagesRequest(message) || isBackgroundModelRequest(message)) {
|
||||
responseQueue.push(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
Api,
|
||||
ApiKeyResolver,
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
AssistantMessageEventStream,
|
||||
AuthCredentialSnapshotEntry,
|
||||
Context,
|
||||
Model,
|
||||
OAuthAccess,
|
||||
@@ -59,6 +61,12 @@ export interface DryBalanceAuthStorage {
|
||||
options?: DryBalanceAuthOptions,
|
||||
): Promise<OAuthAccess | undefined>;
|
||||
getOAuthAccesses?(provider: string, options?: DryBalanceAuthOptions): Promise<OAuthAccessResolution[]>;
|
||||
/**
|
||||
* Force-refresh a single credential by id (step (b) of the auth-retry
|
||||
* policy). The bench re-mints the failing account's token in place on a
|
||||
* 401 rather than rotating accounts — it is measuring each account.
|
||||
*/
|
||||
forceRefreshCredentialById?(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
|
||||
}
|
||||
|
||||
export interface DryBalanceModelRegistry {
|
||||
@@ -152,6 +160,8 @@ export interface DryBalanceDependencies {
|
||||
now?: () => number;
|
||||
stdoutIsTTY?: boolean;
|
||||
stderrIsTTY?: boolean;
|
||||
stdoutColumns?: number;
|
||||
stderrColumns?: number;
|
||||
}
|
||||
|
||||
type DryBalanceAttemptResult =
|
||||
@@ -181,6 +191,7 @@ type DryBalanceBenchTarget =
|
||||
ok: true;
|
||||
account: string;
|
||||
accessToken: string;
|
||||
credentialId?: number;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
@@ -310,10 +321,11 @@ function renderBenchStatusLine(
|
||||
}
|
||||
}
|
||||
|
||||
function createBenchProgressSink(
|
||||
export function createBenchProgressSink(
|
||||
total: number,
|
||||
write: (text: string) => void,
|
||||
interactive: boolean,
|
||||
columns: number,
|
||||
): DryBalanceBenchProgressSink {
|
||||
const statuses: DryBalanceBenchProgressStatus[] = Array.from({ length: total }, () => ({ state: "waiting" }));
|
||||
if (!interactive) {
|
||||
@@ -333,13 +345,21 @@ function createBenchProgressSink(
|
||||
let frame = 0;
|
||||
let lineCount = 0;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const width = Number.isFinite(columns) && columns > 0 ? Math.trunc(columns) : 80;
|
||||
const render = (): void => {
|
||||
const lines = [
|
||||
chalk.bold("bench requests"),
|
||||
...statuses.map((status, index) => renderBenchStatusLine(status, index, total, frame)),
|
||||
];
|
||||
if (lineCount > 0) write(`\x1b[${lineCount}A`);
|
||||
write(`${lines.map(line => `\x1b[2K${line}`).join("\n")}\n`);
|
||||
// Anchor every redraw at column 0 and terminate each row with CRLF: a
|
||||
// bare `\n` only returns to column 0 when the tty performs ONLCR
|
||||
// translation, which is off whenever the terminal is in raw mode — there
|
||||
// the old column-preserving cursor-up staircased each frame into
|
||||
// scrollback. Cap each line to the terminal width so a wrapped row never
|
||||
// desyncs the `\x1b[<n>A` cursor-up from the logical line count.
|
||||
const move = lineCount > 0 ? `\x1b[${lineCount}A` : "";
|
||||
const body = lines.map(line => `\x1b[2K${truncateToWidth(line, width)}`).join("\r\n");
|
||||
write(`${move}\r${body}\r\n`);
|
||||
lineCount = lines.length;
|
||||
};
|
||||
render();
|
||||
@@ -370,13 +390,23 @@ function createBenchProgressSink(
|
||||
async function runBenchRequest(
|
||||
model: Model<Api>,
|
||||
sessionId: string,
|
||||
account: string,
|
||||
accessToken: string,
|
||||
target: Extract<DryBalanceBenchTarget, { ok: true }>,
|
||||
authStorage: DryBalanceAuthStorage,
|
||||
streamFn: DryBalanceStreamSimple,
|
||||
now: () => number,
|
||||
): Promise<DryBalanceBenchResult> {
|
||||
const { account, accessToken, credentialId } = target;
|
||||
const startedAt = now();
|
||||
let firstTokenAt: number | undefined;
|
||||
// Re-mint the cached token on a 401: a peer/broker may have rotated it out
|
||||
// from under our snapshot (Anthropic rotates refresh tokens on every use).
|
||||
// The bench measures one account, so the switch step intentionally declines.
|
||||
const apiKey: ApiKeyResolver = async ({ lastChance, error }) => {
|
||||
if (error === undefined) return accessToken;
|
||||
if (lastChance || credentialId === undefined || !authStorage.forceRefreshCredentialById) return undefined;
|
||||
const refreshed = await authStorage.forceRefreshCredentialById(credentialId);
|
||||
return refreshed.credential.type === "oauth" ? refreshed.credential.access : undefined;
|
||||
};
|
||||
try {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
@@ -389,7 +419,7 @@ async function runBenchRequest(
|
||||
],
|
||||
};
|
||||
const stream = streamFn(model, context, {
|
||||
apiKey: accessToken,
|
||||
apiKey,
|
||||
sessionId,
|
||||
maxTokens: resolveBenchMaxTokens(model),
|
||||
temperature: 0.2,
|
||||
@@ -454,7 +484,7 @@ async function resolveBenchTargets(
|
||||
seen.add(key);
|
||||
const account = extractAccount(entry);
|
||||
if (entry.ok) {
|
||||
targets.push({ ok: true, account, accessToken: entry.accessToken });
|
||||
targets.push({ ok: true, account, accessToken: entry.accessToken, credentialId: entry.credentialId });
|
||||
} else {
|
||||
targets.push({ ok: false, account, error: entry.error });
|
||||
}
|
||||
@@ -465,6 +495,7 @@ async function resolveBenchTargets(
|
||||
async function runBenchTargets(
|
||||
model: Model<Api>,
|
||||
targets: DryBalanceBenchTarget[],
|
||||
authStorage: DryBalanceAuthStorage,
|
||||
randomSessionId: () => string,
|
||||
progress: DryBalanceBenchProgressSink | undefined,
|
||||
streamFn: DryBalanceStreamSimple,
|
||||
@@ -482,14 +513,7 @@ async function runBenchTargets(
|
||||
return result;
|
||||
}
|
||||
progress?.markRunning(index, target.account);
|
||||
const result = await runBenchRequest(
|
||||
model,
|
||||
randomSessionId(),
|
||||
target.account,
|
||||
target.accessToken,
|
||||
streamFn,
|
||||
now,
|
||||
);
|
||||
const result = await runBenchRequest(model, randomSessionId(), target, authStorage, streamFn, now);
|
||||
progress?.complete(index, result);
|
||||
return result;
|
||||
}),
|
||||
@@ -792,8 +816,19 @@ export async function runDryBalanceCommand(
|
||||
const progressInteractive = command.flags.json
|
||||
? (deps.stderrIsTTY ?? process.stderr.isTTY === true)
|
||||
: (deps.stdoutIsTTY ?? process.stdout.isTTY === true);
|
||||
progress = createBenchProgressSink(targets.length, progressWrite, progressInteractive);
|
||||
benchResults = await runBenchTargets(model, targets, randomSessionId, progress, streamFn, now);
|
||||
const progressColumns = command.flags.json
|
||||
? (deps.stderrColumns ?? process.stderr.columns ?? 80)
|
||||
: (deps.stdoutColumns ?? process.stdout.columns ?? 80);
|
||||
progress = createBenchProgressSink(targets.length, progressWrite, progressInteractive, progressColumns);
|
||||
benchResults = await runBenchTargets(
|
||||
model,
|
||||
targets,
|
||||
runtime.modelRegistry.authStorage,
|
||||
randomSessionId,
|
||||
progress,
|
||||
streamFn,
|
||||
now,
|
||||
);
|
||||
results = targets.map(target =>
|
||||
target.ok ? { ok: true, account: target.account } : { ok: false, reason: target.error },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* `omp gallery` — render every built-in tool's renderer across its lifecycle.
|
||||
*
|
||||
* For each tool with a registered renderer, the gallery drives a real
|
||||
* {@link ToolExecutionComponent} through four states — streaming arguments,
|
||||
* arguments complete (in progress), success, and failure — and prints the
|
||||
* rendered output to stdout. It exists for visual QA of tool renderers without
|
||||
* having to provoke each state through a live agent session.
|
||||
*/
|
||||
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import type { TUI } from "@oh-my-pi/pi-tui";
|
||||
import { getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { Settings } from "../config/settings";
|
||||
import { ToolExecutionComponent } from "../modes/components/tool-execution";
|
||||
import { initTheme, theme } from "../modes/theme/theme";
|
||||
import { toolRenderers } from "../tools/renderers";
|
||||
import { type GalleryFixture, type GalleryResult, galleryFixtures } from "./gallery-fixtures";
|
||||
import { captureGalleryScreenshots } from "./gallery-screenshot";
|
||||
|
||||
/** Lifecycle states the gallery renders, in display order. */
|
||||
export const GALLERY_STATES = ["streaming", "progress", "success", "error"] as const;
|
||||
export type GalleryState = (typeof GALLERY_STATES)[number];
|
||||
|
||||
const STATE_LABELS: Record<GalleryState, string> = {
|
||||
streaming: "streaming args",
|
||||
progress: "in progress",
|
||||
success: "done",
|
||||
error: "failed",
|
||||
};
|
||||
|
||||
export interface GalleryCommandArgs {
|
||||
/** Render width in columns (defaults to terminal width, clamped). */
|
||||
width?: number;
|
||||
/** Restrict to a single tool name. */
|
||||
tool?: string;
|
||||
/** Restrict to specific lifecycle states. */
|
||||
states?: GalleryState[];
|
||||
/** Render the expanded variant of each renderer. */
|
||||
expanded?: boolean;
|
||||
/** Strip ANSI styling from the output (useful when redirecting to a file). */
|
||||
plain?: boolean;
|
||||
/** Capture the rendered gallery as PNG screenshot(s) via VHS instead of printing ANSI. */
|
||||
screenshot?: boolean;
|
||||
/** Screenshot output path (single image) or base path (suffixed when split across images). */
|
||||
out?: string;
|
||||
/** Font family for screenshots (must be installed; Nerd Font recommended for icon glyphs). */
|
||||
font?: string;
|
||||
/** Font size in points for screenshots. */
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/** One tool's rendered lifecycle, as ANSI lines: a leading blank, the section rule, then each state. */
|
||||
export interface GallerySection {
|
||||
heading: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const GENERIC_ERROR: GalleryResult = {
|
||||
content: [{ type: "text", text: "Error: operation failed" }],
|
||||
isError: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the fake `AgentTool` the component needs for its label, edit mode, and —
|
||||
* for `customRendered` fixtures — the renderer functions that route it through
|
||||
* the same custom-tool branch production uses (see {@link GalleryFixture}).
|
||||
*/
|
||||
function fakeToolFor(name: string, fixture: GalleryFixture | undefined): AgentTool | undefined {
|
||||
if (!fixture?.label && !fixture?.editMode && !fixture?.customRendered) return undefined;
|
||||
const tool: Record<string, unknown> = { name, label: fixture.label ?? name, mode: fixture.editMode };
|
||||
if (fixture.customRendered) {
|
||||
const renderer = toolRenderers[name] as
|
||||
| { renderCall?: unknown; renderResult?: unknown; mergeCallAndResult?: unknown; inline?: unknown }
|
||||
| undefined;
|
||||
if (renderer) {
|
||||
tool.renderCall = renderer.renderCall;
|
||||
tool.renderResult = renderer.renderResult;
|
||||
tool.mergeCallAndResult = renderer.mergeCallAndResult;
|
||||
tool.inline = renderer.inline;
|
||||
}
|
||||
}
|
||||
return tool as unknown as AgentTool;
|
||||
}
|
||||
|
||||
/** The curated fixture for a tool, or a generic one for registry tools lacking sample data. */
|
||||
export function resolveFixture(name: string): GalleryFixture {
|
||||
return (
|
||||
galleryFixtures[name] ??
|
||||
({
|
||||
args: { note: `sample ${name} call` },
|
||||
result: { content: [{ type: "text", text: `${name} completed` }] },
|
||||
} satisfies GalleryFixture)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single tool/state pair to lines. Builds a fresh component, drives it
|
||||
* to the requested state, settles any async edit preview, then snapshots the
|
||||
* render and stops all animation timers.
|
||||
*/
|
||||
export async function renderGalleryState(
|
||||
name: string,
|
||||
fixture: GalleryFixture,
|
||||
state: GalleryState,
|
||||
width: number,
|
||||
expanded = false,
|
||||
): Promise<string[]> {
|
||||
const tool = fakeToolFor(name, fixture);
|
||||
const streamingArgs = state === "streaming" ? (fixture.streamingArgs ?? fixture.args) : fixture.args;
|
||||
// The component only calls `requestRender` during a static render;
|
||||
// `imageBudget` is consulted solely when images render, which the gallery
|
||||
// disables. A cast avoids constructing a real terminal.
|
||||
const ui = { requestRender() {} } as unknown as TUI;
|
||||
const component = new ToolExecutionComponent(name, streamingArgs, { showImages: false }, tool, ui, getProjectDir());
|
||||
component.setExpanded(expanded);
|
||||
|
||||
if (state !== "streaming") {
|
||||
component.setArgsComplete();
|
||||
}
|
||||
if (state === "success") {
|
||||
component.updateResult(fixture.result, false);
|
||||
} else if (state === "error") {
|
||||
component.updateResult(fixture.errorResult ?? GENERIC_ERROR, false);
|
||||
}
|
||||
|
||||
// Edit-like renderers compute their diff preview off the render path; wait
|
||||
// for it to settle so the snapshot is deterministic instead of racing a tick.
|
||||
await component.whenPreviewSettled();
|
||||
|
||||
const lines = component.render(width);
|
||||
component.stopAnimation();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function resolveWidth(requested: number | undefined): number {
|
||||
const fallback = process.stdout.columns ?? 100;
|
||||
const width = requested ?? fallback;
|
||||
return Math.max(40, Math.min(200, width));
|
||||
}
|
||||
|
||||
function sectionRule(label: string, width: number): string {
|
||||
const prefix = `── ${label} `;
|
||||
const fill = Math.max(0, width - prefix.length);
|
||||
return theme.fg("accent", theme.bold(`${prefix}${"─".repeat(fill)}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render each requested tool's lifecycle into ANSI section blocks. The block
|
||||
* layout (leading blank, section rule, then a blank + dim label + body per
|
||||
* state) is shared by the stdout and screenshot paths so both stay identical.
|
||||
*/
|
||||
async function renderGallerySections(
|
||||
names: string[],
|
||||
states: GalleryState[],
|
||||
width: number,
|
||||
expanded: boolean,
|
||||
): Promise<GallerySection[]> {
|
||||
const sections: GallerySection[] = [];
|
||||
for (const name of names) {
|
||||
const fixture = resolveFixture(name);
|
||||
const heading = fixture.label && fixture.label !== name ? `${name} — ${fixture.label}` : name;
|
||||
const lines: string[] = ["", sectionRule(heading, width)];
|
||||
for (const state of states) {
|
||||
lines.push("", theme.fg("dim", ` · ${STATE_LABELS[state]}`));
|
||||
try {
|
||||
for (const line of await renderGalleryState(name, fixture, state, width, expanded)) lines.push(line);
|
||||
} catch (err) {
|
||||
lines.push(theme.fg("error", ` render failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
sections.push({ heading, lines });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the gallery. Iterates the renderer registry (or a single tool),
|
||||
* printing each requested lifecycle state under a labeled section — or, with
|
||||
* `screenshot`, capturing the rendered output as PNG(s) via VHS.
|
||||
*/
|
||||
export async function runGalleryCommand(args: GalleryCommandArgs): Promise<void> {
|
||||
const settingsInstance = await Settings.init();
|
||||
// Screenshots must carry exact theme RGB regardless of how the invoking
|
||||
// terminal advertises its color support, so force truecolor before the theme
|
||||
// (and therefore every SGR escape it emits) is built.
|
||||
if (args.screenshot) process.env.COLORTERM = "truecolor";
|
||||
await initTheme(
|
||||
false,
|
||||
settingsInstance.get("symbolPreset"),
|
||||
settingsInstance.get("colorBlindMode"),
|
||||
settingsInstance.get("theme.dark"),
|
||||
settingsInstance.get("theme.light"),
|
||||
);
|
||||
|
||||
const width = resolveWidth(args.width);
|
||||
const expanded = args.expanded ?? false;
|
||||
const states = args.states && args.states.length > 0 ? args.states : [...GALLERY_STATES];
|
||||
|
||||
// Renderer-registry tools plus fixture-only tools (no dedicated renderer,
|
||||
// e.g. `report_tool_issue` / custom extension tools) so the gallery covers
|
||||
// the generic fallback + custom-tool branches too.
|
||||
const allNames = Array.from(new Set([...Object.keys(toolRenderers), ...Object.keys(galleryFixtures)])).sort();
|
||||
const names = args.tool ? allNames.filter(name => name === args.tool) : allNames;
|
||||
if (args.tool && names.length === 0) {
|
||||
process.stdout.write(`Unknown tool '${args.tool}'. Known tools: ${allNames.join(", ")}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = await renderGallerySections(names, states, width, expanded);
|
||||
|
||||
if (args.screenshot) {
|
||||
const paths = await captureGalleryScreenshots(sections, {
|
||||
width,
|
||||
font: args.font,
|
||||
fontSize: args.fontSize,
|
||||
out: args.out,
|
||||
});
|
||||
process.stdout.write(`${paths.join("\n")}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = sections.flatMap(section => section.lines);
|
||||
lines.push("");
|
||||
const text = lines.map(line => (args.plain ? Bun.stripANSI(line) : line)).join("\n");
|
||||
process.stdout.write(`${text}\n`);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Gallery fixtures for the agentic orchestration tools (task, goal, job).
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const agenticFixtures: Record<string, GalleryFixture> = {
|
||||
task: {
|
||||
label: "Task",
|
||||
customRendered: true,
|
||||
// Streaming: agent chosen, first task fully arrived, second still landing.
|
||||
streamingArgs: {
|
||||
agent: "task",
|
||||
tasks: [
|
||||
{
|
||||
id: "AuthLoader",
|
||||
description: "Load auth middleware",
|
||||
assignment: "Read packages/server/src/auth/*.ts and summarize the session-cookie flow.",
|
||||
},
|
||||
{ id: "RateLimiter", description: "Audit rate limiter" },
|
||||
],
|
||||
},
|
||||
args: {
|
||||
agent: "task",
|
||||
context: [
|
||||
"# Goal",
|
||||
"Harden the HTTP auth stack before the release cut.",
|
||||
"# Constraints",
|
||||
"Touch only files under packages/server/src/auth/. Do not run gates.",
|
||||
].join("\n"),
|
||||
tasks: [
|
||||
{
|
||||
id: "AuthLoader",
|
||||
description: "Load auth middleware",
|
||||
assignment:
|
||||
"Read packages/server/src/auth/session.ts and middleware.ts, then document the session-cookie validation flow and any TODOs.",
|
||||
},
|
||||
{
|
||||
id: "RateLimiter",
|
||||
description: "Audit rate limiter",
|
||||
assignment:
|
||||
"Inspect packages/server/src/auth/rate-limit.ts. Confirm the 429 path sets Retry-After and report gaps.",
|
||||
},
|
||||
{
|
||||
id: "TokenRotation",
|
||||
description: "Check token rotation",
|
||||
assignment:
|
||||
"Trace refresh-token rotation in packages/server/src/auth/tokens.ts and flag any reuse window.",
|
||||
},
|
||||
],
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "3 agents completed: AuthLoader, RateLimiter, TokenRotation.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
projectAgentsDir: null,
|
||||
totalDurationMs: 48_200,
|
||||
usage: { cost: { total: 0.34 } },
|
||||
results: [
|
||||
{
|
||||
index: 0,
|
||||
id: "AuthLoader",
|
||||
agent: "task",
|
||||
agentSource: "bundled",
|
||||
description: "Load auth middleware",
|
||||
task: "Read packages/server/src/auth/session.ts and middleware.ts",
|
||||
assignment:
|
||||
"Read packages/server/src/auth/session.ts and middleware.ts, then document the session-cookie validation flow and any TODOs.",
|
||||
exitCode: 0,
|
||||
output: [
|
||||
"Session validation runs in middleware.ts:42 via verifySessionCookie().",
|
||||
"Cookies are HMAC-signed (SHA-256) and checked against the session store.",
|
||||
"TODO at session.ts:88 — sliding-expiration refresh is stubbed.",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
durationMs: 41_900,
|
||||
tokens: 61_400,
|
||||
contextTokens: 23_100,
|
||||
contextWindow: 200_000,
|
||||
resolvedModel: "anthropic/claude-sonnet",
|
||||
usage: { cost: { total: 0.12 } },
|
||||
outputMeta: { lineCount: 3, charCount: 214 },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: "RateLimiter",
|
||||
agent: "task",
|
||||
agentSource: "bundled",
|
||||
description: "Audit rate limiter",
|
||||
task: "Inspect packages/server/src/auth/rate-limit.ts",
|
||||
assignment:
|
||||
"Inspect packages/server/src/auth/rate-limit.ts. Confirm the 429 path sets Retry-After and report gaps.",
|
||||
exitCode: 0,
|
||||
output: [
|
||||
"rate-limit.ts uses a fixed-window counter keyed by client IP.",
|
||||
"429 responses set Retry-After (rate-limit.ts:57).",
|
||||
"Gap: no per-account limit, so a botnet across IPs bypasses the cap.",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
durationMs: 38_500,
|
||||
tokens: 54_800,
|
||||
contextTokens: 19_700,
|
||||
contextWindow: 200_000,
|
||||
resolvedModel: "anthropic/claude-sonnet",
|
||||
usage: { cost: { total: 0.1 } },
|
||||
outputMeta: { lineCount: 3, charCount: 198 },
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
id: "TokenRotation",
|
||||
agent: "task",
|
||||
agentSource: "bundled",
|
||||
description: "Check token rotation",
|
||||
task: "Trace refresh-token rotation in packages/server/src/auth/tokens.ts",
|
||||
assignment:
|
||||
"Trace refresh-token rotation in packages/server/src/auth/tokens.ts and flag any reuse window.",
|
||||
exitCode: 0,
|
||||
output: [
|
||||
"Refresh tokens rotate on every use (tokens.ts:120) and the old jti is revoked.",
|
||||
"Reuse of a rotated token triggers full-family revocation — no reuse window found.",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
durationMs: 48_200,
|
||||
tokens: 49_200,
|
||||
contextTokens: 17_500,
|
||||
contextWindow: 200_000,
|
||||
resolvedModel: "anthropic/claude-sonnet",
|
||||
usage: { cost: { total: 0.12 } },
|
||||
outputMeta: { lineCount: 2, charCount: 160 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "1 of 3 agents failed: RateLimiter.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
projectAgentsDir: null,
|
||||
totalDurationMs: 39_400,
|
||||
usage: { cost: { total: 0.21 } },
|
||||
results: [
|
||||
{
|
||||
index: 0,
|
||||
id: "AuthLoader",
|
||||
agent: "task",
|
||||
agentSource: "bundled",
|
||||
description: "Load auth middleware",
|
||||
task: "Read packages/server/src/auth/session.ts and middleware.ts",
|
||||
assignment:
|
||||
"Read packages/server/src/auth/session.ts and middleware.ts, then document the session-cookie validation flow and any TODOs.",
|
||||
exitCode: 0,
|
||||
output: "Session validation runs in middleware.ts:42 via verifySessionCookie().",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
durationMs: 31_200,
|
||||
tokens: 58_100,
|
||||
contextTokens: 21_900,
|
||||
contextWindow: 200_000,
|
||||
resolvedModel: "anthropic/claude-sonnet",
|
||||
usage: { cost: { total: 0.11 } },
|
||||
outputMeta: { lineCount: 1, charCount: 70 },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: "RateLimiter",
|
||||
agent: "task",
|
||||
agentSource: "bundled",
|
||||
description: "Audit rate limiter",
|
||||
task: "Inspect packages/server/src/auth/rate-limit.ts",
|
||||
assignment:
|
||||
"Inspect packages/server/src/auth/rate-limit.ts. Confirm the 429 path sets Retry-After and report gaps.",
|
||||
exitCode: 1,
|
||||
output: "",
|
||||
stderr: "ENOENT: packages/server/src/auth/rate-limit.ts",
|
||||
truncated: false,
|
||||
durationMs: 9_800,
|
||||
tokens: 12_300,
|
||||
contextTokens: 6_400,
|
||||
contextWindow: 200_000,
|
||||
resolvedModel: "anthropic/claude-sonnet",
|
||||
usage: { cost: { total: 0.1 } },
|
||||
error: "Subagent exited 1: target file packages/server/src/auth/rate-limit.ts does not exist.",
|
||||
outputMeta: { lineCount: 0, charCount: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
goal: {
|
||||
label: "Goal",
|
||||
// Streaming: op is "create"; objective text still being typed.
|
||||
streamingArgs: { op: "create", objective: "Ship the auth hardening" },
|
||||
args: {
|
||||
op: "create",
|
||||
objective: "Ship the auth hardening pass: per-account rate limits and sliding session expiry.",
|
||||
token_budget: 500_000,
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Goal set. Working toward: Ship the auth hardening pass.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
op: "create",
|
||||
remainingTokens: 451_800,
|
||||
completionBudgetReport: null,
|
||||
goal: {
|
||||
id: "goal_8f2a",
|
||||
objective: "Ship the auth hardening pass: per-account rate limits and sliding session expiry.",
|
||||
status: "active",
|
||||
tokenBudget: 500_000,
|
||||
tokensUsed: 48_200,
|
||||
timeUsedSeconds: 312,
|
||||
createdAt: 1_749_200_000_000,
|
||||
updatedAt: 1_749_200_312_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Goal tool failed: objective is required when op=create." }],
|
||||
details: { op: "create" },
|
||||
},
|
||||
},
|
||||
|
||||
job: {
|
||||
label: "Job",
|
||||
// Streaming: polling a single job id; the second id is still arriving.
|
||||
streamingArgs: { poll: ["job_a1"] },
|
||||
args: { poll: ["job_a1", "job_b2", "job_c3"] },
|
||||
result: {
|
||||
content: [{ type: "text", text: "3 jobs settled." }],
|
||||
details: {
|
||||
jobs: [
|
||||
{
|
||||
id: "job_a1",
|
||||
type: "bash",
|
||||
status: "completed",
|
||||
label: "bun test packages/server/test/auth.test.ts",
|
||||
durationMs: 18_400,
|
||||
resultText: "42 pass, 0 fail (18.4s)",
|
||||
},
|
||||
{
|
||||
id: "job_b2",
|
||||
type: "task",
|
||||
status: "completed",
|
||||
label: "Migrate rate limiter to a sliding window",
|
||||
durationMs: 96_700,
|
||||
resultText: "Rewrote rate-limit.ts to a token-bucket; added per-account keys.",
|
||||
},
|
||||
{
|
||||
id: "job_c3",
|
||||
type: "bash",
|
||||
status: "failed",
|
||||
label: "bunx biome check packages/server/src/auth",
|
||||
durationMs: 4_100,
|
||||
errorText: "biome: 2 errors in tokens.ts — noUnusedVariables, useConst",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Job cancelled by user." }],
|
||||
details: {
|
||||
jobs: [
|
||||
{
|
||||
id: "job_d4",
|
||||
type: "task",
|
||||
status: "cancelled",
|
||||
label: "Refactor the session store to Redis",
|
||||
durationMs: 52_300,
|
||||
errorText: "Aborted: superseded by goal re-scope.",
|
||||
},
|
||||
],
|
||||
cancelled: [{ id: "job_d4", status: "cancelled" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
/** Gallery fixtures for the code-intelligence tools (lsp, debug). */
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const codeintelFixtures: Record<string, GalleryFixture> = {
|
||||
lsp: {
|
||||
label: "LSP",
|
||||
customRendered: true,
|
||||
streamingArgs: {
|
||||
action: "references",
|
||||
file: "src/server/auth.ts",
|
||||
},
|
||||
args: {
|
||||
action: "references",
|
||||
file: "src/server/auth.ts",
|
||||
line: 42,
|
||||
symbol: "validateToken",
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"Found 6 reference(s):",
|
||||
" src/server/auth.ts:42:14",
|
||||
" 41: ",
|
||||
" 42: export function validateToken(token: string): Claims {",
|
||||
" 43: const claims = verifyJwt(token);",
|
||||
" src/server/auth.ts:118:21",
|
||||
' 117: if (!header) throw new HttpError(401, "missing token");',
|
||||
" 118: const claims = validateToken(stripBearer(header));",
|
||||
" 119: return claims.sub;",
|
||||
" src/server/middleware/session.ts:57:18",
|
||||
" 56: const token = req.cookies.session;",
|
||||
" 57: const claims = validateToken(token);",
|
||||
" 58: req.userId = claims.sub;",
|
||||
" src/server/router.ts:153:20",
|
||||
" 152: router.use(async (req, res, next) => {",
|
||||
" 153: req.claims = await validateToken(req.token);",
|
||||
" 154: next();",
|
||||
" test/auth.test.ts:24:9",
|
||||
' 23: it("rejects expired tokens", () => {',
|
||||
" 24: expect(() => validateToken(expired)).toThrow(/expired/);",
|
||||
" 25: });",
|
||||
" test/auth.test.ts:41:9",
|
||||
' 40: it("accepts valid tokens", () => {',
|
||||
" 41: const claims = validateToken(signed);",
|
||||
' 42: expect(claims.sub).toBe("u_123");',
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
serverName: "typescript-language-server",
|
||||
action: "references",
|
||||
success: true,
|
||||
request: {
|
||||
action: "references",
|
||||
file: "src/server/auth.ts",
|
||||
line: 42,
|
||||
symbol: "validateToken",
|
||||
},
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No language server found for this file",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {
|
||||
serverName: "typescript-language-server",
|
||||
action: "references",
|
||||
success: false,
|
||||
request: {
|
||||
action: "references",
|
||||
file: "src/server/auth.ts",
|
||||
line: 42,
|
||||
symbol: "validateToken",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
debug: {
|
||||
label: "Debug",
|
||||
streamingArgs: {
|
||||
action: "stack_trace",
|
||||
},
|
||||
args: {
|
||||
action: "stack_trace",
|
||||
levels: 20,
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"Stack trace:",
|
||||
"- #1000 validate_token @ app/server.py:42:14",
|
||||
"- #1001 authenticate @ app/server.py:88:9",
|
||||
"- #1002 handle_request @ app/router.py:153:20",
|
||||
"- #1003 dispatch @ app/router.py:97:5",
|
||||
"- #1004 <module> @ app/server.py:212:1",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "stack_trace",
|
||||
success: true,
|
||||
snapshot: {
|
||||
id: "dbg-1",
|
||||
adapter: "debugpy",
|
||||
cwd: "/Users/dev/project",
|
||||
program: "./app/server.py",
|
||||
status: "stopped",
|
||||
launchedAt: "2026-06-06T14:21:08.412Z",
|
||||
lastUsedAt: "2026-06-06T14:22:55.901Z",
|
||||
threadId: 1,
|
||||
frameId: 1000,
|
||||
stopReason: "breakpoint",
|
||||
stopDescription: "breakpoint 2",
|
||||
frameName: "validate_token",
|
||||
instructionPointerReference: "0x00000001000034a8",
|
||||
source: { name: "server.py", path: "app/server.py" },
|
||||
line: 42,
|
||||
column: 14,
|
||||
breakpointFiles: 1,
|
||||
breakpointCount: 2,
|
||||
functionBreakpointCount: 0,
|
||||
outputBytes: 248,
|
||||
outputTruncated: false,
|
||||
needsConfigurationDone: false,
|
||||
},
|
||||
stackFrames: [
|
||||
{
|
||||
id: 1000,
|
||||
name: "validate_token",
|
||||
source: { name: "server.py", path: "app/server.py" },
|
||||
line: 42,
|
||||
column: 14,
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
name: "authenticate",
|
||||
source: { name: "server.py", path: "app/server.py" },
|
||||
line: 88,
|
||||
column: 9,
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
name: "handle_request",
|
||||
source: { name: "router.py", path: "app/router.py" },
|
||||
line: 153,
|
||||
column: 20,
|
||||
},
|
||||
{
|
||||
id: 1003,
|
||||
name: "dispatch",
|
||||
source: { name: "router.py", path: "app/router.py" },
|
||||
line: 97,
|
||||
column: 5,
|
||||
},
|
||||
{
|
||||
id: 1004,
|
||||
name: "<module>",
|
||||
source: { name: "server.py", path: "app/server.py" },
|
||||
line: 212,
|
||||
column: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No active debug session. Launch or attach first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {
|
||||
action: "stack_trace",
|
||||
success: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
/** Gallery fixtures for the edit tools (edit, apply_patch, ast_edit). */
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const editFixtures: Record<string, GalleryFixture> = {
|
||||
edit: {
|
||||
label: "Edit",
|
||||
editMode: "replace",
|
||||
// `previewDiff` is surfaced verbatim by the renderer's call preview, and the
|
||||
// harness diff strategy skips `{ file_path, previewDiff }` (no `path`/`edits`),
|
||||
// so the canned diff survives the streaming and progress states.
|
||||
streamingArgs: {
|
||||
file_path: "packages/coding-agent/src/tools/read.ts",
|
||||
previewDiff: [
|
||||
"@@ -88,3 +88,4 @@",
|
||||
" const offset = args.offset ?? 1;",
|
||||
"- const limit = args.limit ?? 2000;",
|
||||
"+ const limit = args.limit ?? 4000;",
|
||||
].join("\n"),
|
||||
},
|
||||
args: {
|
||||
file_path: "packages/coding-agent/src/tools/read.ts",
|
||||
previewDiff: [
|
||||
"@@ -88,5 +88,6 @@",
|
||||
" const offset = args.offset ?? 1;",
|
||||
"- const limit = args.limit ?? 2000;",
|
||||
"+ const limit = args.limit ?? 4000;",
|
||||
" const raw = await Bun.file(path).text();",
|
||||
"- return raw.slice(offset, offset + limit);",
|
||||
'+ return raw.split("\\n").slice(offset - 1, offset - 1 + limit).join("\\n");',
|
||||
].join("\n"),
|
||||
},
|
||||
result: {
|
||||
content: [{ type: "text", text: "Edited packages/coding-agent/src/tools/read.ts (1 hunk, +3 -2)" }],
|
||||
details: {
|
||||
path: "packages/coding-agent/src/tools/read.ts",
|
||||
firstChangedLine: 89,
|
||||
diff: [
|
||||
"@@ -88,5 +88,6 @@",
|
||||
" const offset = args.offset ?? 1;",
|
||||
"- const limit = args.limit ?? 2000;",
|
||||
"+ const limit = args.limit ?? 4000;",
|
||||
" const raw = await Bun.file(path).text();",
|
||||
"- return raw.slice(offset, offset + limit);",
|
||||
'+ return raw.split("\\n").slice(offset - 1, offset - 1 + limit).join("\\n");',
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Edit failed: the search text was not found in packages/coding-agent/src/tools/read.ts",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {
|
||||
path: "packages/coding-agent/src/tools/read.ts",
|
||||
diff: "",
|
||||
errorText:
|
||||
"No match for the search text. Expected `const limit = args.limit ?? 2000;` near line 89, but the file has `const limit = args.limit ?? 1000;`. Re-read the file and retry with the current contents.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
apply_patch: {
|
||||
label: "Apply Patch",
|
||||
editMode: "apply_patch",
|
||||
streamingArgs: {
|
||||
file_path: "packages/coding-agent/src/edit/renderer.ts",
|
||||
previewDiff: [
|
||||
"@@ -464,2 +464,2 @@",
|
||||
"- fileCount = countEditFiles(editArgs.edits);",
|
||||
"+ fileCount = countDistinctFiles(editArgs.edits);",
|
||||
].join("\n"),
|
||||
},
|
||||
args: {
|
||||
file_path: "packages/coding-agent/src/edit/renderer.ts",
|
||||
previewDiff: [
|
||||
"@@ -177,4 +177,4 @@",
|
||||
" /** Count distinct file paths in an edits array. */",
|
||||
"-function countEditFiles(edits: EditRenderEntry[]): number {",
|
||||
"+function countDistinctFiles(edits: EditRenderEntry[]): number {",
|
||||
" return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;",
|
||||
" }",
|
||||
"@@ -467,2 +467,2 @@",
|
||||
"- fileCount = countEditFiles(editArgs.edits);",
|
||||
"+ fileCount = countDistinctFiles(editArgs.edits);",
|
||||
].join("\n"),
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{ type: "text", text: "Applied patch to packages/coding-agent/src/edit/renderer.ts (2 hunks, +2 -2)" },
|
||||
],
|
||||
details: {
|
||||
op: "update",
|
||||
path: "packages/coding-agent/src/edit/renderer.ts",
|
||||
firstChangedLine: 178,
|
||||
diff: [
|
||||
"@@ -177,4 +177,4 @@",
|
||||
" /** Count distinct file paths in an edits array. */",
|
||||
"-function countEditFiles(edits: EditRenderEntry[]): number {",
|
||||
"+function countDistinctFiles(edits: EditRenderEntry[]): number {",
|
||||
" return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;",
|
||||
" }",
|
||||
"@@ -467,2 +467,2 @@",
|
||||
"- fileCount = countEditFiles(editArgs.edits);",
|
||||
"+ fileCount = countDistinctFiles(editArgs.edits);",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Apply patch failed: context does not match at line 177 of packages/coding-agent/src/edit/renderer.ts",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {
|
||||
op: "update",
|
||||
path: "packages/coding-agent/src/edit/renderer.ts",
|
||||
diff: "",
|
||||
errorText:
|
||||
"Hunk @@ -177,4 +177,4 @@ failed to apply: the context line `function countEditFiles(edits: EditRenderEntry[]): number {` does not match the file. The file may have changed since it was read.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ast_edit: {
|
||||
label: "AST Edit",
|
||||
streamingArgs: {
|
||||
ops: [{ pat: "countEditFiles($$$ARGS)" }],
|
||||
paths: ["packages/coding-agent/src/**/*.ts"],
|
||||
},
|
||||
args: {
|
||||
ops: [{ pat: "countEditFiles($$$ARGS)", out: "countDistinctFiles($$$ARGS)" }],
|
||||
paths: ["packages/coding-agent/src/**/*.ts"],
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"# edit/renderer.ts (2 replacements)",
|
||||
"-468: fileCount = countEditFiles(editArgs.edits);",
|
||||
"+468: fileCount = countDistinctFiles(editArgs.edits);",
|
||||
"-488: const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;",
|
||||
"+488: const totalFiles = args?.edits ? countDistinctFiles(args.edits) : 0;",
|
||||
"",
|
||||
"# tools/tool-result.ts (1 replacement)",
|
||||
"-42: return countEditFiles(files);",
|
||||
"+42: return countDistinctFiles(files);",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
totalReplacements: 3,
|
||||
filesTouched: 2,
|
||||
filesSearched: 214,
|
||||
applied: false,
|
||||
limitReached: false,
|
||||
scopePath: "packages/coding-agent/src",
|
||||
searchPath: "/Users/dev/Projects/pi/packages/coding-agent/src",
|
||||
files: ["edit/renderer.ts", "tools/tool-result.ts"],
|
||||
fileReplacements: [
|
||||
{ path: "edit/renderer.ts", count: 2 },
|
||||
{ path: "tools/tool-result.ts", count: 1 },
|
||||
],
|
||||
displayContent: [
|
||||
"# edit/",
|
||||
"## renderer.ts (2 replacements)",
|
||||
"-468│ fileCount = countEditFiles(editArgs.edits);",
|
||||
"+468│ fileCount = countDistinctFiles(editArgs.edits);",
|
||||
"-488│ const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;",
|
||||
"+488│ const totalFiles = args?.edits ? countDistinctFiles(args.edits) : 0;",
|
||||
"",
|
||||
"# tools/",
|
||||
"## tool-result.ts (1 replacement)",
|
||||
"-42│ return countEditFiles(files);",
|
||||
"+42│ return countDistinctFiles(files);",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Pattern parse error in ops[0].pat: unbalanced parenthesis in `countEditFiles($$$ARGS`",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: sample source-code strings (read fixtures) intentionally contain literal ${...}.
|
||||
// Gallery fixtures for the filesystem tools (read, write, find).
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
const readSnippet = [
|
||||
"export const findToolRenderer = {",
|
||||
"\tinline: true,",
|
||||
"\trenderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {",
|
||||
"\t\tconst meta: string[] = [];",
|
||||
"\t\tif (args.limit !== undefined) meta.push(`limit:${args.limit}`);",
|
||||
"",
|
||||
"\t\tconst text = renderStatusLine(",
|
||||
'\t\t\t{ icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },',
|
||||
"\t\t\tuiTheme,",
|
||||
"\t\t);",
|
||||
"\t\treturn new Text(text, 0, 0);",
|
||||
"\t},",
|
||||
].join("\n");
|
||||
|
||||
const writtenContent = [
|
||||
'import { describe, expect, it } from "bun:test";',
|
||||
'import { parseSel } from "../src/tools/read";',
|
||||
"",
|
||||
'describe("parseSel", () => {',
|
||||
'\tit("parses a single line range", () => {',
|
||||
'\t\texpect(parseSel("42-58")).toEqual({',
|
||||
'\t\t\tkind: "lines",',
|
||||
"\t\t\tranges: [{ startLine: 42, endLine: 58 }],",
|
||||
"\t\t});",
|
||||
"\t});",
|
||||
"",
|
||||
'\tit("treats raw as a verbatim selector", () => {',
|
||||
'\t\texpect(parseSel("raw")).toEqual({ kind: "raw" });',
|
||||
"\t});",
|
||||
"});",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
export const fsFixtures: Record<string, GalleryFixture> = {
|
||||
read: {
|
||||
label: "Read",
|
||||
// Streaming: path still being typed, selector not yet appended.
|
||||
streamingArgs: { path: "packages/coding-agent/src/tools/find" },
|
||||
args: { path: "packages/coding-agent/src/tools/find.ts:437-448" },
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"[packages/coding-agent/src/tools/find.ts#E48E]",
|
||||
"437:export const findToolRenderer = {",
|
||||
"438:\tinline: true,",
|
||||
"439:\trenderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {",
|
||||
"440:\t\tconst meta: string[] = [];",
|
||||
"441:\t\tif (args.limit !== undefined) meta.push(`limit:${args.limit}`);",
|
||||
"442:",
|
||||
"443:\t\tconst text = renderStatusLine(",
|
||||
'444:\t\t\t{ icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },',
|
||||
"445:\t\t\tuiTheme,",
|
||||
"446:\t\t);",
|
||||
"447:\t\treturn new Text(text, 0, 0);",
|
||||
"448:\t},",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
kind: "file",
|
||||
resolvedPath: "/Users/dev/Projects/pi/packages/coding-agent/src/tools/find.ts",
|
||||
contentType: "text/typescript",
|
||||
displayContent: { text: readSnippet, startLine: 437 },
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: ENOENT: no such file or directory, open 'packages/coding-agent/src/tools/find.ts'",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
write: {
|
||||
label: "Write",
|
||||
// Streaming: path known, content still arriving (only the imports so far).
|
||||
streamingArgs: {
|
||||
path: "packages/coding-agent/test/parse-sel.test.ts",
|
||||
content: 'import { describe, expect, it } from "bun:test";\nimport { parseSel } from "../src/tools/read";\n',
|
||||
},
|
||||
args: {
|
||||
path: "packages/coding-agent/test/parse-sel.test.ts",
|
||||
content: writtenContent,
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Created packages/coding-agent/test/parse-sel.test.ts (17 lines, 412 bytes).",
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: EACCES: permission denied, open 'packages/coding-agent/test/parse-sel.test.ts'",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
find: {
|
||||
label: "Find",
|
||||
// Streaming: glob half-typed, no limit yet.
|
||||
streamingArgs: { paths: ["packages/coding-agent/src/tools/*-render"] },
|
||||
args: { paths: ["packages/coding-agent/src/**/*.test.ts"], limit: 50 },
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"packages/coding-agent/src/tools/read.test.ts",
|
||||
"packages/coding-agent/src/tools/write.test.ts",
|
||||
"packages/coding-agent/src/tools/find.test.ts",
|
||||
"packages/coding-agent/src/cli/gallery-cli.test.ts",
|
||||
"packages/coding-agent/src/edit/edit.test.ts",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
scopePath: "packages/coding-agent/src",
|
||||
cwd: "/Users/dev/Projects/pi",
|
||||
fileCount: 5,
|
||||
truncated: false,
|
||||
files: [
|
||||
"packages/coding-agent/src/cli/gallery-cli.test.ts",
|
||||
"packages/coding-agent/src/edit/edit.test.ts",
|
||||
"packages/coding-agent/src/tools/find.test.ts",
|
||||
"packages/coding-agent/src/tools/read.test.ts",
|
||||
"packages/coding-agent/src/tools/write.test.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Find failed: invalid glob pattern '[unclosed'." }],
|
||||
details: { error: "invalid glob pattern '[unclosed'" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Aggregated sample data for the `omp gallery` command.
|
||||
*
|
||||
* Each fixture drives one tool's renderer through the four lifecycle states the
|
||||
* gallery showcases: arguments streaming in, arguments complete but awaiting a
|
||||
* result, a successful result, and a failed result. The data is intentionally
|
||||
* hand-written (rather than schema-derived) so the gallery reflects what a real
|
||||
* tool call looks like — the whole point is visual QA of the renderers.
|
||||
*
|
||||
* Fixtures are grouped by subsystem into sibling modules and merged here.
|
||||
* Adding a tool to one of those groups is enough for the gallery to render it.
|
||||
* Tools present in the renderer registry but missing here fall back to a
|
||||
* generic fixture (see `gallery-cli.ts`), so the gallery never crashes on a
|
||||
* newly added tool — it just looks plain until a fixture is supplied.
|
||||
*/
|
||||
import { agenticFixtures } from "./agentic";
|
||||
import { codeintelFixtures } from "./codeintel";
|
||||
import { editFixtures } from "./edit";
|
||||
import { fsFixtures } from "./fs";
|
||||
import { interactionFixtures } from "./interaction";
|
||||
import { memoryFixtures } from "./memory";
|
||||
import { miscFixtures } from "./misc";
|
||||
import { searchFixtures } from "./search";
|
||||
import { shellFixtures } from "./shell";
|
||||
import { webFixtures } from "./web";
|
||||
|
||||
export * from "./types";
|
||||
|
||||
export const galleryFixtures = {
|
||||
...interactionFixtures,
|
||||
...shellFixtures,
|
||||
...fsFixtures,
|
||||
...searchFixtures,
|
||||
...editFixtures,
|
||||
...agenticFixtures,
|
||||
...memoryFixtures,
|
||||
...webFixtures,
|
||||
...codeintelFixtures,
|
||||
...miscFixtures,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Gallery fixtures for the todo / ask / resolve interaction tools. */
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const interactionFixtures: Record<string, GalleryFixture> = {
|
||||
todo: {
|
||||
label: "Todo",
|
||||
streamingArgs: {
|
||||
ops: [{ op: "init", list: [{ phase: "Foundation", items: ["Scaffold crate"] }] }],
|
||||
},
|
||||
args: {
|
||||
ops: [
|
||||
{
|
||||
op: "init",
|
||||
list: [
|
||||
{ phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
|
||||
{ phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
result: {
|
||||
content: [{ type: "text", text: "Initialized 4 tasks across 2 phases" }],
|
||||
details: {
|
||||
storage: "session",
|
||||
phases: [
|
||||
{
|
||||
name: "Foundation",
|
||||
tasks: [
|
||||
{ content: "Scaffold crate", status: "done" },
|
||||
{ content: "Wire workspace", status: "in_progress" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Auth",
|
||||
tasks: [
|
||||
{ content: "Port credential store", status: "pending" },
|
||||
{ content: "Wire OAuth providers", status: "pending" },
|
||||
],
|
||||
},
|
||||
],
|
||||
completedTasks: [{ phase: "Foundation", content: "Scaffold crate" }],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [{ type: "text", text: "Unknown phase 'Auth' — initialize the list first" }],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
// Gallery fixtures for the long-term memory tools (retain, recall, reflect).
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const memoryFixtures: Record<string, GalleryFixture> = {
|
||||
retain: {
|
||||
label: "Retain",
|
||||
// Streaming: first item complete, second still arriving without a context.
|
||||
streamingArgs: {
|
||||
items: [{ content: "User prefers Bun over Node for all new scripts in this repo." }],
|
||||
},
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
content: "User prefers Bun over Node for all new scripts in this repo.",
|
||||
context: "Established while wiring up the gallery command tooling.",
|
||||
},
|
||||
{
|
||||
content: "The TUI renderers live in packages/coding-agent/src/tools/*-render.ts.",
|
||||
context: "Discovered during the gallery-fixtures task.",
|
||||
},
|
||||
],
|
||||
},
|
||||
result: {
|
||||
content: [{ type: "text", text: "2 memories stored." }],
|
||||
details: { count: 2 },
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Retain failed: memory store is not initialized." }],
|
||||
},
|
||||
},
|
||||
|
||||
recall: {
|
||||
label: "Recall",
|
||||
// Streaming: query partially typed.
|
||||
streamingArgs: { query: "bun vs node" },
|
||||
args: { query: "Which runtime does the user prefer for scripts?" },
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"Found 2 relevant memories:",
|
||||
"",
|
||||
"1. [0.92] User prefers Bun over Node for all new scripts in this repo.",
|
||||
" (Established while wiring up the gallery command tooling.)",
|
||||
"2. [0.78] The TUI renderers live in packages/coding-agent/src/tools/*-render.ts.",
|
||||
" (Discovered during the gallery-fixtures task.)",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Recall failed: vector index unavailable." }],
|
||||
},
|
||||
},
|
||||
|
||||
reflect: {
|
||||
label: "Reflect",
|
||||
streamingArgs: { query: "what have we learned about the user's" },
|
||||
args: { query: "What have we learned about the user's tooling preferences?" },
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"The user consistently favors Bun as the runtime for scripts in this",
|
||||
"repository, avoiding Node where possible. They also track the location",
|
||||
"of TUI renderers under packages/coding-agent/src/tools, suggesting an",
|
||||
"interest in keeping rendering logic discoverable and well-organized.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Reflect failed: no memories matched the query." }],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
/** Gallery fixtures for the ask / resolve / ssh / github / inspect_image tools. */
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const miscFixtures: Record<string, GalleryFixture> = {
|
||||
ask: {
|
||||
label: "Ask",
|
||||
streamingArgs: {
|
||||
questions: [
|
||||
{
|
||||
id: "db",
|
||||
question: "Which database should the new service use?",
|
||||
options: [{ label: "Postgres" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
args: {
|
||||
questions: [
|
||||
{
|
||||
id: "db",
|
||||
question: "Which database should the new service use?",
|
||||
options: [
|
||||
{ label: "Postgres", description: "Relational, strong consistency, JSONB support" },
|
||||
{ label: "SQLite", description: "Embedded, zero-ops, great for single-node" },
|
||||
{ label: "MongoDB", description: "Document store, flexible schema" },
|
||||
],
|
||||
recommended: 0,
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
question: "Which auth flows should ship in v1?",
|
||||
options: [
|
||||
{ label: "Email + password" },
|
||||
{ label: "OAuth (Google, GitHub)" },
|
||||
{ label: "Magic links" },
|
||||
{ label: "SAML SSO", description: "Enterprise; can be deferred" },
|
||||
],
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "db: Postgres\nfeatures: Email + password, OAuth (Google, GitHub)",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
results: [
|
||||
{
|
||||
id: "db",
|
||||
question: "Which database should the new service use?",
|
||||
options: ["Postgres", "SQLite", "MongoDB"],
|
||||
multi: false,
|
||||
selectedOptions: ["Postgres"],
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
question: "Which auth flows should ship in v1?",
|
||||
options: ["Email + password", "OAuth (Google, GitHub)", "Magic links", "SAML SSO"],
|
||||
multi: true,
|
||||
selectedOptions: ["Email + password", "OAuth (Google, GitHub)"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [{ type: "text", text: "Prompt cancelled by user before any answer was given" }],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
|
||||
resolve: {
|
||||
label: "Resolve",
|
||||
streamingArgs: {
|
||||
action: "apply",
|
||||
},
|
||||
args: {
|
||||
action: "apply",
|
||||
reason: "Rename is mechanical and the staged diff matches the intended refactor.",
|
||||
extra: { title: "rename-usecredentials-hook" },
|
||||
},
|
||||
result: {
|
||||
content: [{ type: "text", text: "Applied pending ast_edit: 7 replacements across 3 files" }],
|
||||
details: {
|
||||
action: "apply",
|
||||
reason: "Rename is mechanical and the staged diff matches the intended refactor.",
|
||||
extra: { title: "rename-usecredentials-hook" },
|
||||
sourceToolName: "ast_edit",
|
||||
label: "ast_edit: 7 replacements across 3 files",
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [{ type: "text", text: "No pending action to resolve" }],
|
||||
isError: true,
|
||||
details: {
|
||||
action: "apply",
|
||||
reason: "Rename is mechanical and the staged diff matches the intended refactor.",
|
||||
sourceToolName: "ast_edit",
|
||||
label: "ast_edit: 7 replacements across 3 files",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ssh: {
|
||||
label: "SSH",
|
||||
streamingArgs: {
|
||||
host: "deploy@web-01",
|
||||
command: "systemctl status",
|
||||
},
|
||||
args: {
|
||||
host: "deploy@web-01",
|
||||
command: "systemctl status omp-api --no-pager | head -n 12",
|
||||
cwd: "/srv/omp",
|
||||
timeout: 60,
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"● omp-api.service - Oh My Pi API",
|
||||
" Loaded: loaded (/etc/systemd/system/omp-api.service; enabled)",
|
||||
" Active: active (running) since Sat 2026-06-06 09:14:02 UTC; 3h 21min ago",
|
||||
" Main PID: 4812 (bun)",
|
||||
" Tasks: 17 (limit: 4915)",
|
||||
" Memory: 142.6M",
|
||||
" CPU: 38.214s",
|
||||
" CGroup: /system.slice/omp-api.service",
|
||||
" └─4812 /usr/local/bin/bun run dist/server.js",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "ssh: connect to host web-01 port 22: Connection timed out",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
|
||||
github: {
|
||||
label: "GitHub",
|
||||
streamingArgs: {
|
||||
op: "search_prs",
|
||||
query: "is:open author:@me",
|
||||
},
|
||||
args: {
|
||||
op: "search_prs",
|
||||
query: "is:open review-requested:@me sort:updated",
|
||||
repo: "oh-my-pi/pi",
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"#1842 feat(tui): virtualized scrollback for tool output openyou · 2h ago +312 -47",
|
||||
"#1839 fix(agent): retry stream on transient 529 dvir · 5h ago +18 -4",
|
||||
"#1830 refactor(edit): unify hashline + ast_edit previews mira · 1d ago +540 -210",
|
||||
"#1817 docs: document gallery fixtures contract leo · 2d ago +96 -0",
|
||||
"",
|
||||
"4 open pull requests requesting your review",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "gh: Could not resolve to a Repository with the name 'oh-my-pi/pi'. (HTTP 404)",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
|
||||
inspect_image: {
|
||||
label: "Inspect Image",
|
||||
streamingArgs: {
|
||||
path: "docs/assets/dashboard-mock.png",
|
||||
},
|
||||
args: {
|
||||
path: "docs/assets/dashboard-mock.png",
|
||||
question: "What chart types are shown and roughly what layout does the dashboard use?",
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"The dashboard uses a two-column layout on a dark background.",
|
||||
"Top row: four KPI cards (Revenue, Active Users, Churn, MRR) with sparklines.",
|
||||
"Left column: a stacked area chart of weekly sessions over ~3 months.",
|
||||
"Right column: a horizontal bar chart ranking the top 6 referrers.",
|
||||
"Bottom: a paginated table of recent transactions with status pills.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
model: "claude-opus-4",
|
||||
imagePath: "docs/assets/dashboard-mock.png",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [{ type: "text", text: "Image not found: docs/assets/dashboard-mock.png" }],
|
||||
isError: true,
|
||||
details: {
|
||||
model: "claude-opus-4",
|
||||
imagePath: "docs/assets/dashboard-mock.png",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Built-in tool with no dedicated renderer — exercises the generic fallback
|
||||
// (`#formatToolExecution`) path so its padded, state-tinted block is QA'd.
|
||||
report_tool_issue: {
|
||||
label: "Report Tool Issue",
|
||||
streamingArgs: { tool: "lsp" },
|
||||
args: {
|
||||
tool: "lsp",
|
||||
report: "Rename returned no edit for an exported symbol that has 12 references",
|
||||
},
|
||||
result: { content: [{ type: "text", text: "Noted, thanks!" }] },
|
||||
errorResult: {
|
||||
content: [{ type: "text", text: "Could not record the report: issue tracker unreachable" }],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Stand-in for a custom/extension tool that ships no renderer — same generic
|
||||
// fallback path most MCP/extension tools take.
|
||||
custom: {
|
||||
label: "Custom Tool",
|
||||
streamingArgs: { query: "weather" },
|
||||
args: { query: "weather in Tokyo", units: "metric" },
|
||||
result: { content: [{ type: "text", text: "Tokyo: 22°C, partly cloudy, humidity 64%." }] },
|
||||
errorResult: {
|
||||
content: [{ type: "text", text: "Upstream provider returned 503 Service Unavailable" }],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
/** Gallery fixtures for the search tools (search, search_tool_bm25, ast_grep). */
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const searchFixtures: Record<string, GalleryFixture> = {
|
||||
search: {
|
||||
label: "Search",
|
||||
streamingArgs: {
|
||||
pattern: "useState",
|
||||
},
|
||||
args: {
|
||||
pattern: "useState",
|
||||
paths: ["packages/tui/src"],
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"# packages/tui/src/components/",
|
||||
"## SearchBox.tsx",
|
||||
'18: const [query, setQuery] = useState("");',
|
||||
"19: const [results, setResults] = useState<Match[]>([]);",
|
||||
"## StatusBar.tsx",
|
||||
"27: const [expanded, setExpanded] = useState(false);",
|
||||
"",
|
||||
"# packages/tui/src/hooks/",
|
||||
"## useDebounced.ts",
|
||||
"9: const [value, setValue] = useState(initial);",
|
||||
"10: const [pending, setPending] = useState(false);",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
scopePath: "packages/tui/src",
|
||||
searchPath: "/Users/dev/Projects/pi/packages/tui/src",
|
||||
matchCount: 5,
|
||||
fileCount: 3,
|
||||
files: [
|
||||
"packages/tui/src/components/SearchBox.tsx",
|
||||
"packages/tui/src/components/StatusBar.tsx",
|
||||
"packages/tui/src/hooks/useDebounced.ts",
|
||||
],
|
||||
fileMatches: [
|
||||
{ path: "packages/tui/src/components/SearchBox.tsx", count: 2 },
|
||||
{ path: "packages/tui/src/components/StatusBar.tsx", count: 1 },
|
||||
{ path: "packages/tui/src/hooks/useDebounced.ts", count: 2 },
|
||||
],
|
||||
truncated: false,
|
||||
displayContent: [
|
||||
"# packages/tui/src/components/",
|
||||
"## SearchBox.tsx",
|
||||
'*18│ const [query, setQuery] = useState("");',
|
||||
"*19│ const [results, setResults] = useState<Match[]>([]);",
|
||||
"## StatusBar.tsx",
|
||||
"*27│ const [expanded, setExpanded] = useState(false);",
|
||||
"",
|
||||
"# packages/tui/src/hooks/",
|
||||
"## useDebounced.ts",
|
||||
" *9│ const [value, setValue] = useState(initial);",
|
||||
"*10│ const [pending, setPending] = useState(false);",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Invalid regex pattern: unclosed group near index 8",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {
|
||||
error: "Invalid regex pattern: unclosed group near index 8",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
search_tool_bm25: {
|
||||
label: "SearchTools",
|
||||
streamingArgs: {
|
||||
query: "read pdf and ext",
|
||||
},
|
||||
args: {
|
||||
query: "read pdf and extract tables",
|
||||
limit: 5,
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
query: "read pdf and extract tables",
|
||||
activated_tools: ["docling_extract_tables", "docling_convert", "pdf_read_text"],
|
||||
match_count: 4,
|
||||
total_tools: 142,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
query: "read pdf and extract tables",
|
||||
limit: 5,
|
||||
total_tools: 142,
|
||||
activated_tools: ["docling_extract_tables", "docling_convert", "pdf_read_text"],
|
||||
active_selected_tools: ["read", "search", "edit", "bash"],
|
||||
tools: [
|
||||
{
|
||||
name: "docling_extract_tables",
|
||||
label: "Extract Tables",
|
||||
description: "Extract tabular data from PDF documents into CSV or JSON rows.",
|
||||
server_name: "docling",
|
||||
mcp_tool_name: "extract_tables",
|
||||
schema_keys: ["path", "pages", "format"],
|
||||
score: 9.412037,
|
||||
},
|
||||
{
|
||||
name: "docling_convert",
|
||||
label: "Convert Document",
|
||||
description: "Convert PDF, DOCX, or PPTX into structured Markdown with layout preserved.",
|
||||
server_name: "docling",
|
||||
mcp_tool_name: "convert",
|
||||
schema_keys: ["path", "target", "ocr"],
|
||||
score: 6.83102,
|
||||
},
|
||||
{
|
||||
name: "pdf_read_text",
|
||||
label: "Read PDF Text",
|
||||
description: "Read raw text from a PDF, optionally scoped to a page range.",
|
||||
server_name: "pdf-tools",
|
||||
mcp_tool_name: "read_text",
|
||||
schema_keys: ["path", "page_start", "page_end"],
|
||||
score: 5.207884,
|
||||
},
|
||||
{
|
||||
name: "tabula_scan",
|
||||
label: "Scan Tables",
|
||||
description: "Detect table bounding boxes on scanned PDF pages before extraction.",
|
||||
server_name: "pdf-tools",
|
||||
mcp_tool_name: "scan",
|
||||
schema_keys: ["path", "dpi"],
|
||||
score: 3.119556,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tool discovery is disabled. Enable tools.discoveryMode or mcp.discoveryMode to use search_tool_bm25.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
|
||||
ast_grep: {
|
||||
label: "AST Grep",
|
||||
streamingArgs: {
|
||||
pat: "useState(",
|
||||
},
|
||||
args: {
|
||||
pat: "useState($A)",
|
||||
paths: ["packages/tui/src/components"],
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"# packages/tui/src/components/",
|
||||
"## SearchBox.tsx",
|
||||
'18: const [query, setQuery] = useState("");',
|
||||
' meta: $A=""',
|
||||
"## StatusBar.tsx",
|
||||
"27: const [expanded, setExpanded] = useState(false);",
|
||||
" meta: $A=false",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
matchCount: 2,
|
||||
fileCount: 2,
|
||||
filesSearched: 14,
|
||||
limitReached: false,
|
||||
scopePath: "packages/tui/src/components",
|
||||
searchPath: "/Users/dev/Projects/pi/packages/tui/src/components",
|
||||
files: ["packages/tui/src/components/SearchBox.tsx", "packages/tui/src/components/StatusBar.tsx"],
|
||||
fileMatches: [
|
||||
{ path: "packages/tui/src/components/SearchBox.tsx", count: 1 },
|
||||
{ path: "packages/tui/src/components/StatusBar.tsx", count: 1 },
|
||||
],
|
||||
displayContent: [
|
||||
"# packages/tui/src/components/",
|
||||
"## SearchBox.tsx",
|
||||
'*18│ const [query, setQuery] = useState("");',
|
||||
' meta: $A=""',
|
||||
"## StatusBar.tsx",
|
||||
"*27│ const [expanded, setExpanded] = useState(false);",
|
||||
" meta: $A=false",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Pattern parse error: incomplete node `useState(` — expected a closing `)`",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Gallery fixtures for the shell tools (bash, eval). */
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const shellFixtures: Record<string, GalleryFixture> = {
|
||||
bash: {
|
||||
label: "Bash",
|
||||
streamingArgs: {
|
||||
command: "git status --short && git log --on",
|
||||
},
|
||||
args: {
|
||||
command: "git status --short && git log --oneline -5",
|
||||
cwd: "packages/coding-agent",
|
||||
timeout: 30,
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
" M src/cli/gallery-cli.ts",
|
||||
" M src/tools/bash.ts",
|
||||
"?? src/cli/gallery-fixtures/shell.ts",
|
||||
"a1b2c3d Wire gallery command into CLI dispatch",
|
||||
"9f8e7d6 Add ToolExecutionComponent lifecycle states",
|
||||
"4c5b6a7 Extract createShellRenderer from bashToolRenderer",
|
||||
"2d3e4f5 Strip LLM-facing notices before TUI render",
|
||||
"7a8b9c0 Cap preview lines in pending command block",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
exitCode: 0,
|
||||
wallTimeMs: 184,
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"src/tools/bash.ts:1142:34 - error TS2339: Property 'requestedTimeoutSeconds' does not exist on type 'BashToolDetails'.",
|
||||
"",
|
||||
"1142 const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;",
|
||||
" ~~~~~~~~~~~~~~~~~~~~~~~~",
|
||||
"Found 1 error in src/tools/bash.ts:1142",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {
|
||||
exitCode: 2,
|
||||
wallTimeMs: 5120,
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
eval: {
|
||||
label: "Eval",
|
||||
streamingArgs: {
|
||||
cells: [
|
||||
{
|
||||
language: "py",
|
||||
code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
|
||||
title: "load config",
|
||||
},
|
||||
],
|
||||
},
|
||||
args: {
|
||||
cells: [
|
||||
{
|
||||
language: "py",
|
||||
title: "load config",
|
||||
code: [
|
||||
"import json",
|
||||
"from pathlib import Path",
|
||||
"",
|
||||
'data = json.loads(Path("package.json").read_text())',
|
||||
'deps = data.get("dependencies", {})',
|
||||
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
|
||||
'print(f"{len(deps)} dependencies")',
|
||||
"display(sorted(deps)[:3])",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: ["@oh-my-pi/coding-agent v0.42.0", "37 dependencies"].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
language: "python",
|
||||
languages: ["python"],
|
||||
jsonOutputs: [["@ai-sdk/anthropic", "@oh-my-pi/pi-ai", "@oh-my-pi/pi-tui"]],
|
||||
cells: [
|
||||
{
|
||||
index: 0,
|
||||
title: "load config",
|
||||
language: "python",
|
||||
code: [
|
||||
"import json",
|
||||
"from pathlib import Path",
|
||||
"",
|
||||
'data = json.loads(Path("package.json").read_text())',
|
||||
'deps = data.get("dependencies", {})',
|
||||
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
|
||||
'print(f"{len(deps)} dependencies")',
|
||||
"display(sorted(deps)[:3])",
|
||||
].join("\n"),
|
||||
output: ["@oh-my-pi/coding-agent v0.42.0", "37 dependencies"].join("\n"),
|
||||
status: "complete",
|
||||
durationMs: 64,
|
||||
exitCode: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"Traceback (most recent call last):",
|
||||
' File "<cell 0>", line 4, in <module>',
|
||||
' data = json.loads(Path("package.json").read_text())',
|
||||
" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
|
||||
"json.decoder.JSONDecodeError: Expecting ',' delimiter: line 12 column 3 (char 318)",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: {
|
||||
language: "python",
|
||||
languages: ["python"],
|
||||
isError: true,
|
||||
cells: [
|
||||
{
|
||||
index: 0,
|
||||
title: "load config",
|
||||
language: "python",
|
||||
code: [
|
||||
"import json",
|
||||
"from pathlib import Path",
|
||||
"",
|
||||
'data = json.loads(Path("package.json").read_text())',
|
||||
'deps = data.get("dependencies", {})',
|
||||
'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
|
||||
].join("\n"),
|
||||
output: [
|
||||
"Traceback (most recent call last):",
|
||||
' File "<cell 0>", line 4, in <module>',
|
||||
' data = json.loads(Path("package.json").read_text())',
|
||||
"json.decoder.JSONDecodeError: Expecting ',' delimiter: line 12 column 3 (char 318)",
|
||||
].join("\n"),
|
||||
status: "error",
|
||||
durationMs: 41,
|
||||
exitCode: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Types for `omp gallery` sample data. See {@link ./index} for the aggregated
|
||||
* fixture registry and the contract each fixture must satisfy.
|
||||
*/
|
||||
import type { EditMode } from "../../edit";
|
||||
|
||||
/** A tool result snapshot, matching the shape `ToolExecutionComponent` consumes. */
|
||||
export interface GalleryResult {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface GalleryFixture {
|
||||
/** Display label for the tool header (defaults to the tool name). */
|
||||
label?: string;
|
||||
/** Edit mode for edit-like tools so the streaming preview dispatches correctly. */
|
||||
editMode?: EditMode;
|
||||
/**
|
||||
* Set for tools whose real `AgentTool` attaches `renderCall`/`renderResult`
|
||||
* directly on the instance (e.g. `lsp`, `task`). The harness then attaches
|
||||
* the registry renderer onto the fake tool so the component routes through
|
||||
* the custom-tool branch — the same path production takes — instead of the
|
||||
* built-in registry branch. The two branches can diverge, so exercising the
|
||||
* real one keeps the gallery honest for these tools.
|
||||
*/
|
||||
customRendered?: boolean;
|
||||
/**
|
||||
* Arguments shown during the streaming state — a partial view of {@link args}
|
||||
* as if the tool-call JSON were still arriving. May include `__partialJson`
|
||||
* for renderers (bash, edit) that surface fields before the object closes.
|
||||
* Defaults to {@link args} when omitted.
|
||||
*/
|
||||
streamingArgs?: unknown;
|
||||
/** Complete arguments shown for the in-progress, success, and error states. */
|
||||
args: unknown;
|
||||
/** Successful result. */
|
||||
result: GalleryResult;
|
||||
/** Failed result. Falls back to a generic error when omitted. */
|
||||
errorResult?: GalleryResult;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Gallery fixtures for the web tools (web_search, browser).
|
||||
import type { GalleryFixture } from "./types";
|
||||
|
||||
export const webFixtures: Record<string, GalleryFixture> = {
|
||||
web_search: {
|
||||
label: "Web Search",
|
||||
// Streaming: query still being typed, no recency/limit yet.
|
||||
streamingArgs: { query: "bun vs node performance" },
|
||||
args: {
|
||||
query: "Bun vs Node.js performance benchmarks 2026",
|
||||
recency: "month",
|
||||
limit: 4,
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"Bun continues to outperform Node.js on raw HTTP throughput and cold-start",
|
||||
"time thanks to its JavaScriptCore engine and native-Zig runtime, while",
|
||||
"Node.js retains an edge in ecosystem maturity and long-term stability.",
|
||||
"For script-heavy workflows Bun's faster startup is the decisive factor.",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
response: {
|
||||
provider: "perplexity",
|
||||
model: "sonar-pro",
|
||||
authMode: "api_key",
|
||||
requestId: "req_a1b2c3d4e5f6",
|
||||
answer: [
|
||||
"Bun continues to outperform Node.js on raw HTTP throughput and cold-start",
|
||||
"time thanks to its JavaScriptCore engine and native-Zig runtime, while",
|
||||
"Node.js retains an edge in ecosystem maturity and long-term stability.",
|
||||
"For script-heavy workflows Bun's faster startup is the decisive factor.",
|
||||
].join("\n"),
|
||||
searchQueries: ["bun vs node.js performance benchmarks 2026", "bun http throughput vs node"],
|
||||
sources: [
|
||||
{
|
||||
title: "Bun 1.2 Benchmarks: HTTP, SQLite, and Startup Time",
|
||||
url: "https://bun.sh/blog/bun-v1.2-benchmarks",
|
||||
snippet:
|
||||
"Bun serves roughly 2.5x the requests per second of Node.js on a simple HTTP server and starts in under 10ms.",
|
||||
ageSeconds: 86400 * 12,
|
||||
author: "The Bun Team",
|
||||
},
|
||||
{
|
||||
title: "Node.js vs Bun: A 2026 Performance Deep Dive",
|
||||
url: "https://blog.platformatic.dev/nodejs-vs-bun-2026",
|
||||
snippet:
|
||||
"Across CPU-bound workloads the gap narrows, but Bun's faster module resolution keeps cold starts ahead.",
|
||||
ageSeconds: 86400 * 3,
|
||||
author: "Matteo Collina",
|
||||
},
|
||||
{
|
||||
title: "Real-world API latency: Bun, Deno, and Node compared",
|
||||
url: "https://www.theregister.com/2026/05/18/js_runtime_latency/",
|
||||
snippet:
|
||||
"Under sustained load p99 latencies converge, suggesting runtime choice matters less for steady-state services.",
|
||||
ageSeconds: 86400 * 19,
|
||||
},
|
||||
{
|
||||
title: "Why we migrated our CLI tooling from Node to Bun",
|
||||
url: "https://engineering.example.com/posts/bun-cli-migration",
|
||||
snippet:
|
||||
"Startup dropped from 180ms to 22ms, shaving seconds off every developer command invocation.",
|
||||
ageSeconds: 86400 * 27,
|
||||
author: "Dana Whitfield",
|
||||
},
|
||||
],
|
||||
citations: [
|
||||
{
|
||||
url: "https://bun.sh/blog/bun-v1.2-benchmarks",
|
||||
title: "Bun 1.2 Benchmarks",
|
||||
citedText: "Bun serves roughly 2.5x the requests per second of Node.js",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
inputTokens: 312,
|
||||
outputTokens: 248,
|
||||
totalTokens: 560,
|
||||
searchRequests: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Web search failed: provider returned HTTP 429 (rate limited)." }],
|
||||
details: {
|
||||
response: {
|
||||
provider: "perplexity",
|
||||
sources: [],
|
||||
},
|
||||
error: "Provider returned HTTP 429 (rate limited). Retry after 30s.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
browser: {
|
||||
label: "Browser",
|
||||
// Streaming: code body still arriving for a `run` action.
|
||||
streamingArgs: {
|
||||
action: "run",
|
||||
name: "docs",
|
||||
code: "const obs = await tab.observe();\n",
|
||||
},
|
||||
args: {
|
||||
action: "run",
|
||||
name: "docs",
|
||||
code: [
|
||||
"const obs = await tab.observe();",
|
||||
"const heading = obs.elements.find(e => e.role === 'heading');",
|
||||
"display({ url: obs.url, title: obs.title, headings: obs.elements.filter(e => e.role === 'heading').length });",
|
||||
"return heading?.name ?? 'no heading found';",
|
||||
].join("\n"),
|
||||
},
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
'{ url: "https://bun.sh/docs", title: "Bun Documentation", headings: 14 }',
|
||||
'"Get started with Bun"',
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "run",
|
||||
name: "docs",
|
||||
url: "https://bun.sh/docs",
|
||||
browser: "headless",
|
||||
viewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
|
||||
result: '"Get started with Bun"',
|
||||
},
|
||||
},
|
||||
errorResult: {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"TimeoutError: waiting for selector `aria/Sign in` failed: timeout 30000ms exceeded",
|
||||
" at Tab.waitFor (browser/tab.ts:212:13)",
|
||||
" at run (eval:3:7)",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "run",
|
||||
name: "docs",
|
||||
url: "https://bun.sh/docs",
|
||||
browser: "headless",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Render `omp gallery` output to PNG screenshots via VHS.
|
||||
*
|
||||
* ANSI escapes are invisible to anything that can only read raw bytes (e.g.
|
||||
* agents), so `--screenshot` drives the rendered gallery through a real virtual
|
||||
* terminal (VHS + ttyd + ffmpeg) and writes the captured frame to disk. The
|
||||
* gallery is pre-rendered to truecolor ANSI in this process — where the user's
|
||||
* theme and symbol preset are correct — then `cat`'d inside VHS so the captured
|
||||
* pixels match exactly what the live TUI would draw.
|
||||
*
|
||||
* VHS is a hard dependency of this path: if it is not installed we fail loudly
|
||||
* rather than degrade to a lossy fallback.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $which } from "@oh-my-pi/pi-utils";
|
||||
import { theme } from "../modes/theme/theme";
|
||||
import type { GallerySection } from "./gallery-cli";
|
||||
|
||||
/** Nerd Font family so the gallery's icon glyphs (PUA) render instead of tofu. */
|
||||
export const DEFAULT_SCREENSHOT_FONT = "JetBrainsMono Nerd Font";
|
||||
export const DEFAULT_SCREENSHOT_FONT_SIZE = 18;
|
||||
|
||||
/** Inner padding (px) VHS leaves around the terminal grid. */
|
||||
const PADDING = 14;
|
||||
const LINE_HEIGHT = 1.0;
|
||||
/**
|
||||
* Upper-bound cell metrics relative to font size. Real monospace cells are
|
||||
* smaller, so over-provisioning the canvas guarantees the gallery never
|
||||
* soft-wraps (too few columns) or scrolls off the top (too few rows). The slack
|
||||
* shows up only as a modest background margin, which is harmless for review.
|
||||
*/
|
||||
const CELL_WIDTH_RATIO = 0.65;
|
||||
const CELL_HEIGHT_RATIO = 1.5;
|
||||
/** Keep each image well under headless-Chromium's tall-canvas limits. */
|
||||
const MAX_IMAGE_HEIGHT_PX = 8000;
|
||||
|
||||
export interface GalleryScreenshotOptions {
|
||||
/** Gallery render width in columns (matches the ANSI line width). */
|
||||
width: number;
|
||||
/** VHS `FontFamily`. */
|
||||
font?: string;
|
||||
/** VHS `FontSize`. */
|
||||
fontSize?: number;
|
||||
/**
|
||||
* Output destination. When omitted, PNGs land in a fresh temp directory.
|
||||
* With multiple images the path is suffixed (`name-01.png`, `name-02.png`).
|
||||
*/
|
||||
out?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the gallery sections as one or more PNGs and return their absolute
|
||||
* paths. Tall galleries are split across images so no single capture exceeds
|
||||
* the terminal-canvas height limit.
|
||||
*/
|
||||
export async function captureGalleryScreenshots(
|
||||
sections: GallerySection[],
|
||||
options: GalleryScreenshotOptions,
|
||||
): Promise<string[]> {
|
||||
const vhs = $which("vhs");
|
||||
if (!vhs) {
|
||||
throw new Error(
|
||||
"`omp gallery --screenshot` requires VHS, which is not installed. " +
|
||||
"Install it (e.g. `brew install vhs`, or see https://github.com/charmbracelet/vhs) and retry.",
|
||||
);
|
||||
}
|
||||
|
||||
const font = options.font ?? DEFAULT_SCREENSHOT_FONT;
|
||||
const fontSize = options.fontSize ?? DEFAULT_SCREENSHOT_FONT_SIZE;
|
||||
const cellHeight = fontSize * Math.max(LINE_HEIGHT, 1) * CELL_HEIGHT_RATIO;
|
||||
const cellWidth = fontSize * CELL_WIDTH_RATIO;
|
||||
const rowBudget = Math.max(40, Math.floor((MAX_IMAGE_HEIGHT_PX - 2 * PADDING) / cellHeight) - 2);
|
||||
const chunks = chunkGallerySections(sections, rowBudget);
|
||||
const themeJson = buildVhsTheme();
|
||||
|
||||
const baseDir = options.out
|
||||
? path.dirname(path.resolve(options.out))
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), "omp-gallery-"));
|
||||
await fs.promises.mkdir(baseDir, { recursive: true });
|
||||
|
||||
const outPaths: string[] = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (chunks.length > 1) {
|
||||
process.stderr.write(`Rendering gallery screenshot ${i + 1}/${chunks.length}…\n`);
|
||||
}
|
||||
const outPng = resolveScreenshotOutputPath(options.out, baseDir, i, chunks.length);
|
||||
const lines = chunks[i].flatMap(section => section.lines);
|
||||
await renderChunk({ vhs, lines, outPng, font, fontSize, cellWidth, cellHeight, width: options.width, themeJson });
|
||||
outPaths.push(outPng);
|
||||
}
|
||||
return outPaths;
|
||||
}
|
||||
|
||||
interface RenderChunkArgs {
|
||||
vhs: string;
|
||||
lines: string[];
|
||||
outPng: string;
|
||||
font: string;
|
||||
fontSize: number;
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
width: number;
|
||||
themeJson: string;
|
||||
}
|
||||
|
||||
async function renderChunk(args: RenderChunkArgs): Promise<void> {
|
||||
const rows = args.lines.length;
|
||||
const widthPx = Math.ceil(args.width * args.cellWidth) + 2 * PADDING;
|
||||
const heightPx = Math.ceil((rows + 2) * args.cellHeight) + 2 * PADDING;
|
||||
|
||||
const dir = path.dirname(args.outPng);
|
||||
const stem = path.basename(args.outPng, path.extname(args.outPng));
|
||||
const ansiPath = path.join(dir, `.${stem}.ansi`);
|
||||
const tapePath = path.join(dir, `.${stem}.tape`);
|
||||
const gifPath = path.join(dir, `.${stem}.gif`);
|
||||
|
||||
// CRLF so each gallery line is its own terminal row regardless of how the
|
||||
// captured shell handles bare LF.
|
||||
await Bun.write(ansiPath, `${args.lines.join("\r\n")}\r\n`);
|
||||
await Bun.write(
|
||||
tapePath,
|
||||
buildTape({
|
||||
gifPath,
|
||||
outPng: args.outPng,
|
||||
ansiPath,
|
||||
widthPx,
|
||||
heightPx,
|
||||
font: args.font,
|
||||
fontSize: args.fontSize,
|
||||
themeJson: args.themeJson,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await Bun.$`${args.vhs} ${tapePath}`.quiet().nothrow();
|
||||
if (result.exitCode !== 0 || !(await Bun.file(args.outPng).exists())) {
|
||||
const detail = result.stderr.toString().trim() || result.stdout.toString().trim();
|
||||
throw new Error(`VHS failed to render the gallery screenshot${detail ? `: ${detail.slice(-600)}` : ""}`);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.promises.rm(ansiPath, { force: true }),
|
||||
fs.promises.rm(tapePath, { force: true }),
|
||||
fs.promises.rm(gifPath, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
interface TapeArgs {
|
||||
gifPath: string;
|
||||
outPng: string;
|
||||
ansiPath: string;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
font: string;
|
||||
fontSize: number;
|
||||
themeJson: string;
|
||||
}
|
||||
|
||||
function buildTape(args: TapeArgs): string {
|
||||
// `Output` (a throwaway GIF) is mandatory for VHS to record; the screenshot
|
||||
// is captured from the final visible frame. Setup is hidden so the typed
|
||||
// `cat` command and shell prompt never appear in the capture, and a trailing
|
||||
// `sleep` keeps the shell from drawing a fresh prompt under the output.
|
||||
const shellCommand = `clear; cat ${shellSingleQuote(args.ansiPath)}; sleep 120`;
|
||||
return `${[
|
||||
`Output ${JSON.stringify(args.gifPath)}`,
|
||||
`Set Width ${args.widthPx}`,
|
||||
`Set Height ${args.heightPx}`,
|
||||
`Set FontFamily ${JSON.stringify(args.font)}`,
|
||||
`Set FontSize ${args.fontSize}`,
|
||||
`Set Padding ${PADDING}`,
|
||||
`Set LineHeight ${LINE_HEIGHT}`,
|
||||
`Set Theme ${args.themeJson}`,
|
||||
"Hide",
|
||||
`Type ${JSON.stringify(shellCommand)}`,
|
||||
"Enter",
|
||||
"Sleep 1.2s",
|
||||
"Show",
|
||||
"Sleep 400ms",
|
||||
`Screenshot ${JSON.stringify(args.outPng)}`,
|
||||
].join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the VHS terminal theme. Only background/foreground/cursor matter: the
|
||||
* gallery emits truecolor (`38;2`/`48;2`) escapes, so the 16-color palette is
|
||||
* never consulted — it is filler to satisfy VHS's theme schema.
|
||||
*/
|
||||
function buildVhsTheme(): string {
|
||||
const background = parseAnsiRgb(theme.getBgAnsi("statusLineBg")) ?? (theme.isLight ? "#ffffff" : "#1a1a1a");
|
||||
const foreground = theme.isLight ? "#1a1a1a" : "#d4d4d4";
|
||||
const selection = theme.isLight ? "#c8d6ff" : "#404862";
|
||||
return JSON.stringify({
|
||||
name: "omp-gallery",
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
selection,
|
||||
black: "#000000",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#6272ff",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#bfbfbf",
|
||||
brightBlack: "#4d4d4d",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#8aa0ff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract `#rrggbb` from a truecolor SGR escape (`…38;2;r;g;b…` / `…48;2;…`). */
|
||||
function parseAnsiRgb(ansi: string): string | undefined {
|
||||
const match = /[34]8;2;(\d+);(\d+);(\d+)/.exec(ansi);
|
||||
if (!match) return undefined;
|
||||
const hex = (value: string) => Number(value).toString(16).padStart(2, "0");
|
||||
return `#${hex(match[1])}${hex(match[2])}${hex(match[3])}`;
|
||||
}
|
||||
|
||||
/** POSIX single-quote a path for embedding in the VHS shell command. */
|
||||
function shellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a chunk's PNG path. A single image keeps the bare name (or the exact
|
||||
* `out`); multiple images gain a zero-padded `-NN` suffix so they sort and never
|
||||
* collide.
|
||||
*/
|
||||
export function resolveScreenshotOutputPath(
|
||||
out: string | undefined,
|
||||
baseDir: string,
|
||||
index: number,
|
||||
total: number,
|
||||
): string {
|
||||
if (total === 1) {
|
||||
return out ? path.resolve(out) : path.join(baseDir, "gallery.png");
|
||||
}
|
||||
const suffix = String(index + 1).padStart(2, "0");
|
||||
if (out) {
|
||||
const resolved = path.resolve(out);
|
||||
const ext = path.extname(resolved) || ".png";
|
||||
const stem = path.basename(resolved, ext);
|
||||
return path.join(path.dirname(resolved), `${stem}-${suffix}${ext}`);
|
||||
}
|
||||
return path.join(baseDir, `gallery-${suffix}.png`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group whole tool sections into chunks that stay under `rowBudget` rows. A
|
||||
* single section larger than the budget gets its own (taller) image rather than
|
||||
* being split mid-renderer.
|
||||
*/
|
||||
export function chunkGallerySections(sections: GallerySection[], rowBudget: number): GallerySection[][] {
|
||||
const chunks: GallerySection[][] = [];
|
||||
let current: GallerySection[] = [];
|
||||
let currentRows = 0;
|
||||
for (const section of sections) {
|
||||
const rows = section.lines.length;
|
||||
if (current.length > 0 && currentRows + rows > rowBudget) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
currentRows = 0;
|
||||
}
|
||||
current.push(section);
|
||||
currentRows += rows;
|
||||
}
|
||||
if (current.length > 0) chunks.push(current);
|
||||
return chunks.length > 0 ? chunks : [[]];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Render every built-in tool's renderer across its lifecycle states.
|
||||
*/
|
||||
import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
||||
import { GALLERY_STATES, type GalleryState, runGalleryCommand } from "../cli/gallery-cli";
|
||||
|
||||
export default class Gallery extends Command {
|
||||
static description = "Preview tool renderers across streaming, in-progress, success, and failure states";
|
||||
|
||||
static flags = {
|
||||
tool: Flags.string({ char: "t", description: "Render a single tool by name" }),
|
||||
state: Flags.string({
|
||||
char: "s",
|
||||
description: "Render only the given lifecycle state(s)",
|
||||
options: [...GALLERY_STATES],
|
||||
multiple: true,
|
||||
}),
|
||||
width: Flags.integer({ char: "w", description: "Render width in columns" }),
|
||||
expanded: Flags.boolean({
|
||||
char: "e",
|
||||
description: "Render the expanded variant of each renderer",
|
||||
default: false,
|
||||
}),
|
||||
plain: Flags.boolean({ description: "Strip ANSI styling from the output", default: false }),
|
||||
screenshot: Flags.boolean({
|
||||
description:
|
||||
"Capture the rendered output as PNG screenshot(s) via VHS instead of printing ANSI (requires vhs)",
|
||||
default: false,
|
||||
}),
|
||||
out: Flags.string({
|
||||
char: "o",
|
||||
description: "Screenshot output path (with --screenshot); suffixed per image when split across multiple",
|
||||
}),
|
||||
font: Flags.string({ description: "Screenshot font family (default: JetBrainsMono Nerd Font)" }),
|
||||
"font-size": Flags.integer({ description: "Screenshot font size in points (default: 18)" }),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Gallery);
|
||||
await runGalleryCommand({
|
||||
tool: flags.tool,
|
||||
states: flags.state as GalleryState[] | undefined,
|
||||
width: flags.width,
|
||||
expanded: flags.expanded,
|
||||
plain: flags.plain,
|
||||
screenshot: flags.screenshot,
|
||||
out: flags.out,
|
||||
font: flags.font,
|
||||
fontSize: flags["font-size"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Root command for the coding agent CLI.
|
||||
*/
|
||||
|
||||
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
|
||||
import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai/effort";
|
||||
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
||||
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
||||
import { parseArgs } from "../cli/args";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import { completeSimple } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
|
||||
@@ -14,7 +14,7 @@ const ConventionalAnalysisTool = createConventionalAnalysisTool(
|
||||
|
||||
export interface ConventionalAnalysisInput {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
contextFiles?: Array<{ path: string; content: string }>;
|
||||
userContext?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
||||
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import * as z from "zod/v4";
|
||||
@@ -19,7 +19,7 @@ const SummaryTool = {
|
||||
|
||||
export interface SummaryInput {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
commitType: string;
|
||||
scope: string | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
||||
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import * as z from "zod/v4";
|
||||
@@ -25,7 +25,7 @@ export const changelogTool = {
|
||||
|
||||
export interface ChangelogPromptInput {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
changelogPath: string;
|
||||
isPackageChangelog: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as path from "node:path";
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { CHANGELOG_CATEGORIES } from "../../commit/types";
|
||||
import * as git from "../../utils/git";
|
||||
@@ -15,7 +15,7 @@ const DEFAULT_MAX_DIFF_CHARS = 120_000;
|
||||
export interface ChangelogFlowInput {
|
||||
cwd: string;
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
stagedFiles: string[];
|
||||
dryRun: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
import { parseFileDiffs } from "../../commit/git/diff";
|
||||
import type { ConventionalAnalysis } from "../../commit/types";
|
||||
@@ -21,10 +21,10 @@ export interface MapReduceSettings {
|
||||
|
||||
export interface MapReduceInput {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
smolModel: Model<Api>;
|
||||
smolApiKey: string;
|
||||
smolApiKey: ApiKey;
|
||||
smolThinkingLevel?: ThinkingLevel;
|
||||
diff: string;
|
||||
stat: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, AssistantMessage, Message, Model } from "@oh-my-pi/pi-ai";
|
||||
import { completeSimple } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import fileObserverSystemPrompt from "../../commit/prompts/file-observer-system.md" with { type: "text" };
|
||||
@@ -18,7 +18,7 @@ const RETRY_BACKOFF_MS = 1000;
|
||||
|
||||
export interface MapPhaseInput {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
files: FileDiff[];
|
||||
config?: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import { completeSimple } from "@oh-my-pi/pi-ai";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import reduceSystemPrompt from "../../commit/prompts/reduce-system.md" with { type: "text" };
|
||||
@@ -12,7 +12,7 @@ const ReduceTool = createConventionalAnalysisTool("Synthesize file observations
|
||||
|
||||
export interface ReducePhaseInput {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
observations: FileObservation[];
|
||||
stat: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { ApiKeyResolverRegistry } from "../config/api-key-resolver";
|
||||
import { MODEL_ROLE_IDS } from "../config/model-registry";
|
||||
import {
|
||||
type ModelLookupRegistry,
|
||||
@@ -12,13 +13,19 @@ import MODEL_PRIO from "../priority.json" with { type: "json" };
|
||||
|
||||
export interface ResolvedCommitModel {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
/**
|
||||
* Resolver for the model's bearer: re-resolves on 401 / usage-limit so the
|
||||
* whole commit pipeline (analysis, map/reduce, changelog) inherits the
|
||||
* central force-refresh + account-rotation policy.
|
||||
*/
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
type CommitModelRegistry = ModelLookupRegistry & {
|
||||
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
|
||||
};
|
||||
type CommitModelRegistry = ModelLookupRegistry &
|
||||
ApiKeyResolverRegistry & {
|
||||
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
export async function resolvePrimaryModel(
|
||||
override: string | undefined,
|
||||
@@ -38,20 +45,32 @@ export async function resolvePrimaryModel(
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key available for model ${model.provider}/${model.id}`);
|
||||
}
|
||||
return { model, apiKey, thinkingLevel: resolved?.thinkingLevel };
|
||||
return {
|
||||
model,
|
||||
apiKey: modelRegistry.resolver(model.provider, { baseUrl: model.baseUrl }),
|
||||
thinkingLevel: resolved?.thinkingLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSmolModel(
|
||||
settings: Settings,
|
||||
modelRegistry: CommitModelRegistry,
|
||||
fallbackModel: Model<Api>,
|
||||
fallbackApiKey: string,
|
||||
fallbackApiKey: ApiKey,
|
||||
): Promise<ResolvedCommitModel> {
|
||||
const available = modelRegistry.getAvailable();
|
||||
const resolvedSmol = resolveRoleSelection(["smol"], settings, available, modelRegistry);
|
||||
if (resolvedSmol?.model) {
|
||||
const apiKey = await modelRegistry.getApiKey(resolvedSmol.model);
|
||||
if (apiKey) return { model: resolvedSmol.model, apiKey, thinkingLevel: resolvedSmol.thinkingLevel };
|
||||
if (apiKey) {
|
||||
return {
|
||||
model: resolvedSmol.model,
|
||||
apiKey: modelRegistry.resolver(resolvedSmol.model.provider, {
|
||||
baseUrl: resolvedSmol.model.baseUrl,
|
||||
}),
|
||||
thinkingLevel: resolvedSmol.thinkingLevel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
|
||||
@@ -59,7 +78,12 @@ export async function resolveSmolModel(
|
||||
const candidate = parseModelPattern(pattern, available, matchPreferences, { modelRegistry }).model;
|
||||
if (!candidate) continue;
|
||||
const apiKey = await modelRegistry.getApiKey(candidate);
|
||||
if (apiKey) return { model: candidate, apiKey };
|
||||
if (apiKey) {
|
||||
return {
|
||||
model: candidate,
|
||||
apiKey: modelRegistry.resolver(candidate.provider, { baseUrl: candidate.baseUrl }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { model: fallbackModel, apiKey: fallbackApiKey };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as path from "node:path";
|
||||
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import { getProjectDir, logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { ModelRegistry } from "../config/model-registry";
|
||||
import { Settings } from "../config/settings";
|
||||
@@ -145,10 +145,10 @@ async function generateAnalysis(input: {
|
||||
contextFiles: Array<{ path: string; content: string }>;
|
||||
userContext?: string;
|
||||
primaryModel: Model<Api>;
|
||||
primaryApiKey: string;
|
||||
primaryApiKey: ApiKey;
|
||||
primaryThinkingLevel?: ThinkingLevel;
|
||||
smolModel: Model<Api>;
|
||||
smolApiKey: string;
|
||||
smolApiKey: ApiKey;
|
||||
smolThinkingLevel?: ThinkingLevel;
|
||||
commitSettings: {
|
||||
mapReduceEnabled: boolean;
|
||||
@@ -206,7 +206,7 @@ async function generateSummaryWithRetry(input: {
|
||||
analysis: ConventionalAnalysis;
|
||||
stat: string;
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
userContext?: string;
|
||||
}): Promise<{ summary: string }> {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ApiKeyResolver, AuthStorage } from "@oh-my-pi/pi-ai";
|
||||
|
||||
export interface ApiKeyResolverOptions {
|
||||
/** Session id for credential stickiness; read at resolve time by the caller. */
|
||||
sessionId?: string;
|
||||
/** Provider base URL hint forwarded to the auth-storage cascade. */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal slice of `ModelRegistry` the resolver needs. Typed structurally so
|
||||
* narrower registry shells (e.g. the commit pipeline's `CommitModelRegistry`)
|
||||
* can build resolvers without depending on the full class.
|
||||
*/
|
||||
export interface ApiKeyResolverRegistry {
|
||||
getApiKeyForProvider(
|
||||
provider: string,
|
||||
sessionId?: string,
|
||||
options?: { baseUrl?: string; forceRefresh?: boolean; signal?: AbortSignal },
|
||||
): Promise<string | undefined>;
|
||||
authStorage: Pick<AuthStorage, "rotateSessionCredential">;
|
||||
/**
|
||||
* Build an {@link ApiKeyResolver} implementing the central a/b/c auth-retry
|
||||
* policy: initial → resolve; step (b) → force-refresh same account; step (c)
|
||||
* → rotate to a sibling credential, then re-resolve.
|
||||
*
|
||||
* The resolver is stateless (safe to reuse across requests). Callers that
|
||||
* need the initial key for a guard can call `resolveApiKeyOnce(resolver)`.
|
||||
*/
|
||||
resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of {@link ApiKeyResolverRegistry.resolver}.
|
||||
* Also usable standalone for structural registries that don't carry the method.
|
||||
*/
|
||||
export function createApiKeyResolver(
|
||||
registry: Pick<ApiKeyResolverRegistry, "getApiKeyForProvider" | "authStorage">,
|
||||
provider: string,
|
||||
options: ApiKeyResolverOptions = {},
|
||||
): ApiKeyResolver {
|
||||
const { sessionId, baseUrl } = options;
|
||||
return async ({ lastChance, error, signal }) => {
|
||||
if (error === undefined) {
|
||||
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl });
|
||||
}
|
||||
if (lastChance) {
|
||||
// Account constraint (401 / usage / account-rate-limit): rotate to a
|
||||
// sibling credential. We do NOT honor any retry-after here — if a
|
||||
// sibling exists we switch immediately; the precise no-sibling backoff
|
||||
// is owned by `markUsageLimitReached` (default + server usage-report
|
||||
// reset) and the outer whole-turn retry layer.
|
||||
await registry.authStorage.rotateSessionCredential(provider, sessionId, { error, signal });
|
||||
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl });
|
||||
}
|
||||
return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, forceRefresh: true, signal });
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ interface AppKeybindings {
|
||||
"app.clear": true;
|
||||
"app.exit": true;
|
||||
"app.suspend": true;
|
||||
"app.display.reset": true;
|
||||
"app.thinking.cycle": true;
|
||||
"app.thinking.toggle": true;
|
||||
"app.model.cycleForward": true;
|
||||
@@ -86,6 +87,10 @@ export const KEYBINDINGS = {
|
||||
defaultKeys: "ctrl+z",
|
||||
description: "Suspend application",
|
||||
},
|
||||
"app.display.reset": {
|
||||
defaultKeys: "ctrl+l",
|
||||
description: "Reset terminal display",
|
||||
},
|
||||
"app.thinking.cycle": {
|
||||
defaultKeys: "shift+tab",
|
||||
description: "Cycle thinking level",
|
||||
@@ -103,7 +108,7 @@ export const KEYBINDINGS = {
|
||||
description: "Cycle to previous model",
|
||||
},
|
||||
"app.model.select": {
|
||||
defaultKeys: "ctrl+l",
|
||||
defaultKeys: "alt+m",
|
||||
description: "Select model",
|
||||
},
|
||||
"app.model.selectTemporary": {
|
||||
@@ -119,7 +124,10 @@ export const KEYBINDINGS = {
|
||||
description: "Open external editor",
|
||||
},
|
||||
"app.message.followUp": {
|
||||
defaultKeys: "ctrl+enter",
|
||||
// Ctrl+Enter is preserved for terminals that deliver it (Kitty/iTerm2/WezTerm/Ghostty),
|
||||
// but Windows Terminal does not emit a distinct event for Ctrl+Enter — Ctrl+Q is listed
|
||||
// first so the default binding works there without remapping (#1903).
|
||||
defaultKeys: ["ctrl+q", "ctrl+enter"],
|
||||
description: "Send follow-up message",
|
||||
},
|
||||
"app.message.dequeue": {
|
||||
@@ -213,6 +221,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
|
||||
clear: "app.clear",
|
||||
exit: "app.exit",
|
||||
suspend: "app.suspend",
|
||||
displayReset: "app.display.reset",
|
||||
cycleThinkingLevel: "app.thinking.cycle",
|
||||
cycleModelForward: "app.model.cycleForward",
|
||||
cycleModelBackward: "app.model.cycleBackward",
|
||||
@@ -439,16 +448,50 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
|
||||
loadKeybindingsConfig(readPath, writeBackPath);
|
||||
}
|
||||
|
||||
const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
|
||||
const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
|
||||
function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
|
||||
if (keys === undefined) return false;
|
||||
const keyList = Array.isArray(keys) ? keys : [keys];
|
||||
for (const key of keyList) {
|
||||
if (key.toLowerCase() === target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function userBindingClaimsKey(config: KeybindingsConfig, target: KeyId, except: Keybinding): boolean {
|
||||
for (const [keybinding, keys] of Object.entries(config)) {
|
||||
if (!(keybinding in KEYBINDINGS)) continue;
|
||||
if (keybinding === except) continue;
|
||||
if (keyListIncludes(keys, target)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function removeKey(keys: KeyId[], target: KeyId): KeyId[] {
|
||||
return keys.filter(key => key !== target);
|
||||
}
|
||||
|
||||
function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
|
||||
if (keys.length === 1) {
|
||||
const key = keys[0];
|
||||
if (key !== undefined) return key;
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages all keybindings (app + TUI).
|
||||
* Extends the TUI KeybindingsManager with app-specific functionality.
|
||||
*/
|
||||
export class KeybindingsManager extends TuiKeybindingsManager {
|
||||
#configPath: string | undefined;
|
||||
#userBindings: KeybindingsConfig;
|
||||
|
||||
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
|
||||
super(KEYBINDINGS, userBindings);
|
||||
this.#configPath = configPath;
|
||||
this.#userBindings = userBindings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -480,6 +523,29 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
||||
this.setUserBindings(config);
|
||||
}
|
||||
|
||||
setUserBindings(userBindings: KeybindingsConfig): void {
|
||||
this.#userBindings = userBindings;
|
||||
super.setUserBindings(userBindings);
|
||||
}
|
||||
|
||||
getKeys(keybinding: Keybinding): KeyId[] {
|
||||
const keys = super.getKeys(keybinding);
|
||||
if (keybinding === FOLLOW_UP_KEYBINDING) {
|
||||
if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
|
||||
if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) {
|
||||
return keys;
|
||||
}
|
||||
return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
getResolvedBindings(): KeybindingsConfig {
|
||||
const resolved = super.getResolvedBindings();
|
||||
resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING));
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective resolved bindings (defaults + user overrides).
|
||||
*/
|
||||
|
||||
@@ -58,7 +58,7 @@ const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
|
||||
};
|
||||
const kModelResolutionCache = Symbol("model-equivalence.resolutionCache");
|
||||
interface CompiledEquivalenceConfigWithCache extends CompiledEquivalenceConfig {
|
||||
[kModelResolutionCache]?: WeakMap<Model<Api>, ResolvedCanonicalModel>;
|
||||
[kModelResolutionCache]?: Map<string, ResolvedCanonicalModel>;
|
||||
}
|
||||
const FAMILY_EXTRACTION_PATTERNS = [
|
||||
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
|
||||
@@ -128,10 +128,18 @@ function normalizeCanonicalIdKey(canonicalId: string): string {
|
||||
return canonicalId.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getCanonicalSuffixAliasKey(candidate: string): string {
|
||||
return PENALTY_HAS_UPPERCASE.test(candidate) ? normalizeCanonicalIdKey(candidate) : candidate;
|
||||
}
|
||||
|
||||
export function formatCanonicalVariantSelector(model: Model<Api>): string {
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
function getModelResolutionCacheKey(model: Model<Api>): string {
|
||||
return `${model.provider}\0${model.id}`;
|
||||
}
|
||||
|
||||
function buildOverrideMap(overrides: Record<string, string> | undefined): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
if (!overrides) {
|
||||
@@ -159,13 +167,24 @@ function buildExclusionSet(exclusions: readonly string[] | undefined): Set<strin
|
||||
return result;
|
||||
}
|
||||
|
||||
const compiledEquivalenceCache = new WeakMap<ModelEquivalenceConfig, CompiledEquivalenceConfig>();
|
||||
function compileEquivalenceConfig(config: ModelEquivalenceConfig | undefined): CompiledEquivalenceConfig {
|
||||
if (config) {
|
||||
const cached = compiledEquivalenceCache.get(config);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
const overrides = buildOverrideMap(config?.overrides);
|
||||
const exclude = buildExclusionSet(config?.exclude);
|
||||
if (overrides.size === 0 && exclude.size === 0) {
|
||||
return EMPTY_COMPILED_EQUIVALENCE;
|
||||
}
|
||||
return { overrides, exclude };
|
||||
const compiled: CompiledEquivalenceConfig = { overrides, exclude };
|
||||
if (config) {
|
||||
compiledEquivalenceCache.set(config, compiled);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function addCanonicalCandidate(candidates: Set<string>, candidate: string): void {
|
||||
@@ -277,7 +296,7 @@ function expandCompactSeriesMinorVersions(candidate: string): string[] {
|
||||
// safely return the same instance. Cap keeps memory bounded under adversarial
|
||||
// model-id churn.
|
||||
const QUALIFIED_NAMESPACE_SUFFIX_CACHE = new Map<string, string[]>();
|
||||
const QUALIFIED_NAMESPACE_SUFFIX_CACHE_CAP = 256;
|
||||
const QUALIFIED_NAMESPACE_SUFFIX_CACHE_CAP = 4096;
|
||||
function getQualifiedNamespaceSuffixes(candidate: string): string[] {
|
||||
const cached = QUALIFIED_NAMESPACE_SUFFIX_CACHE.get(candidate);
|
||||
if (cached !== undefined) {
|
||||
@@ -670,7 +689,7 @@ function expandHeavyCanonicalCandidates(normalized: string, queue: string[]): vo
|
||||
// is unused — kept for signature stability). The returned array is consumed via
|
||||
// `.filter` at every callsite, so sharing the cached instance is safe.
|
||||
const HEURISTIC_CANDIDATES_CACHE = new Map<string, string[]>();
|
||||
const HEURISTIC_CANDIDATES_CACHE_CAP = 256;
|
||||
const HEURISTIC_CANDIDATES_CACHE_CAP = 4096;
|
||||
function getHeuristicCanonicalCandidates(modelId: string, _officialIds?: ReadonlySet<string>): string[] {
|
||||
const cached = HEURISTIC_CANDIDATES_CACHE.get(modelId);
|
||||
if (cached !== undefined) {
|
||||
@@ -728,10 +747,10 @@ function getPreferredFallbackCanonicalCandidate(modelId: string, candidates: rea
|
||||
|
||||
function resolveCanonicalIdForModel(
|
||||
model: Model<Api>,
|
||||
selector: string,
|
||||
equivalence: CompiledEquivalenceConfig,
|
||||
referenceData: CanonicalReferenceData,
|
||||
): ResolvedCanonicalModel {
|
||||
const selector = formatCanonicalVariantSelector(model);
|
||||
const normalizedSelector = normalizeSelectorKey(selector);
|
||||
|
||||
if (equivalence.overrides.has(normalizedSelector)) {
|
||||
@@ -753,9 +772,12 @@ function resolveCanonicalIdForModel(
|
||||
}
|
||||
|
||||
const heuristicCandidates = getHeuristicCanonicalCandidates(model.id, referenceData.officialIds);
|
||||
const officialMatches = new Set(heuristicCandidates.filter(candidate => referenceData.officialIds.has(candidate)));
|
||||
const officialMatches = new Set<string>();
|
||||
for (const candidate of heuristicCandidates) {
|
||||
const aliased = referenceData.suffixAliases.get(normalizeCanonicalIdKey(candidate));
|
||||
if (referenceData.officialIds.has(candidate)) {
|
||||
officialMatches.add(candidate);
|
||||
}
|
||||
const aliased = referenceData.suffixAliases.get(getCanonicalSuffixAliasKey(candidate));
|
||||
if (aliased) {
|
||||
officialMatches.add(aliased);
|
||||
}
|
||||
@@ -814,17 +836,18 @@ export function buildCanonicalModelIndex(
|
||||
const compiledWithCache = compiledEquivalence as CompiledEquivalenceConfigWithCache;
|
||||
let modelCache = compiledWithCache[kModelResolutionCache];
|
||||
if (!modelCache) {
|
||||
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
|
||||
modelCache = new Map<string, ResolvedCanonicalModel>();
|
||||
compiledWithCache[kModelResolutionCache] = modelCache;
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
let canonical = modelCache.get(model);
|
||||
if (!canonical) {
|
||||
canonical = resolveCanonicalIdForModel(model, compiledEquivalence, referenceData);
|
||||
modelCache.set(model, canonical);
|
||||
}
|
||||
const selector = formatCanonicalVariantSelector(model);
|
||||
const cacheKey = getModelResolutionCacheKey(model);
|
||||
let canonical = modelCache.get(cacheKey);
|
||||
if (!canonical) {
|
||||
canonical = resolveCanonicalIdForModel(model, selector, compiledEquivalence, referenceData);
|
||||
modelCache.set(cacheKey, canonical);
|
||||
}
|
||||
const variant: CanonicalModelVariant = {
|
||||
canonicalId: canonical.id,
|
||||
selector,
|
||||
|
||||
@@ -4,34 +4,49 @@ const MODEL_ID_SEGMENT_PATTERN = /[a-z0-9.:-]+/g;
|
||||
const MODEL_FAMILY_PREFIX_PATTERN =
|
||||
/^(claude|gemini|gpt|grok|glm|qwen|deepseek|kimi|mimo|doubao|ernie|gpt-oss|gemma|minimax|step|command|jamba|llama|o[1345])/i;
|
||||
|
||||
function hasDigit(value: string): boolean {
|
||||
return /\d/.test(value);
|
||||
function normalizeModelIdWhitespace(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Ordering for model-like segments: longest first, ties broken lexicographically. */
|
||||
function compareSegmentPreference(left: string, right: string): number {
|
||||
if (left.length !== right.length) {
|
||||
return right.length - left.length;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
return left.length !== right.length ? right.length - left.length : left.localeCompare(right);
|
||||
}
|
||||
|
||||
export function getModelLikeIdSegments(modelId: string): string[] {
|
||||
const normalized = normalizeModelIdWhitespace(modelId).toLowerCase();
|
||||
if (!normalized) return [];
|
||||
const segments = (normalized.match(MODEL_ID_SEGMENT_PATTERN) ?? []).filter(
|
||||
segment => MODEL_FAMILY_PREFIX_PATTERN.test(segment) && hasDigit(segment),
|
||||
);
|
||||
const unique = [...new Set(segments)];
|
||||
unique.sort(compareSegmentPreference);
|
||||
return unique;
|
||||
const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
|
||||
if (!matches) return [];
|
||||
const segments = new Set<string>();
|
||||
for (const segment of matches) {
|
||||
if (MODEL_FAMILY_PREFIX_PATTERN.test(segment) && /\d/.test(segment)) segments.add(segment);
|
||||
}
|
||||
return [...segments].sort(compareSegmentPreference);
|
||||
}
|
||||
|
||||
export function getLongestModelLikeIdSegment(modelId: string): string | undefined {
|
||||
return getModelLikeIdSegments(modelId)[0];
|
||||
const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
|
||||
if (!matches) return undefined;
|
||||
let best: string | undefined;
|
||||
for (const segment of matches) {
|
||||
if (
|
||||
MODEL_FAMILY_PREFIX_PATTERN.test(segment) &&
|
||||
/\d/.test(segment) &&
|
||||
(best === undefined || compareSegmentPreference(segment, best) < 0)
|
||||
) {
|
||||
best = segment;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizeModelIdWhitespace(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
function hasBracketAffixMarker(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 91 || code === 93 || code === 0x3010 || code === 0x3011) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,18 +54,20 @@ function normalizeModelIdWhitespace(value: string): string {
|
||||
* upstream model id, e.g.
|
||||
* "[Kiro] claude-opus-4-8" -> "claude-opus-4-8"
|
||||
* "[gcli转] gemini-3.1-pro-preview [假流]" -> "gemini-3.1-pro-preview"
|
||||
*
|
||||
* Candidates are returned most-stripped first: both ends, then leading-only, then trailing-only.
|
||||
*/
|
||||
export function getBracketStrippedModelIdCandidates(modelId: string): string[] {
|
||||
if (!hasBracketAffixMarker(modelId)) return [];
|
||||
const normalized = normalizeModelIdWhitespace(modelId);
|
||||
if (!normalized) return [];
|
||||
|
||||
const candidates = new Set<string>();
|
||||
const withoutLeading = normalizeModelIdWhitespace(normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, ""));
|
||||
const strippedLeading = normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, "");
|
||||
const withoutLeading = normalizeModelIdWhitespace(strippedLeading);
|
||||
const withoutTrailing = normalizeModelIdWhitespace(normalized.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
|
||||
const withoutBoth = normalizeModelIdWhitespace(
|
||||
normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, "").replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""),
|
||||
);
|
||||
const withoutBoth = normalizeModelIdWhitespace(strippedLeading.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
|
||||
|
||||
const candidates = new Set<string>();
|
||||
for (const candidate of [withoutBoth, withoutLeading, withoutTrailing]) {
|
||||
if (candidate && candidate !== normalized) {
|
||||
candidates.add(candidate);
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import * as path from "node:path";
|
||||
import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry";
|
||||
import { readModelCache } from "@oh-my-pi/pi-ai/model-cache";
|
||||
import { createModelManager, type ModelManagerOptions, type ModelRefreshStrategy } from "@oh-my-pi/pi-ai/model-manager";
|
||||
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
|
||||
import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-ai/models";
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessageEventStream,
|
||||
type Context,
|
||||
createModelManager,
|
||||
enrichModelThinking,
|
||||
getBundledModels,
|
||||
getBundledProviders,
|
||||
googleAntigravityModelManagerOptions,
|
||||
googleGeminiCliModelManagerOptions,
|
||||
type Model,
|
||||
type ModelManagerOptions,
|
||||
type ModelRefreshStrategy,
|
||||
openaiCodexModelManagerOptions,
|
||||
PROVIDER_DESCRIPTORS,
|
||||
readModelCache,
|
||||
registerCustomApi,
|
||||
type SimpleStreamOptions,
|
||||
type ThinkingConfig,
|
||||
UNK_CONTEXT_WINDOW,
|
||||
UNK_MAX_TOKENS,
|
||||
unregisterCustomApis,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
} from "@oh-my-pi/pi-ai/provider-models";
|
||||
import type { Api, Context, Model, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
|
||||
import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
|
||||
// Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
|
||||
// any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
|
||||
@@ -103,12 +95,14 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
|
||||
...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
|
||||
];
|
||||
|
||||
import type { ApiKeyResolver } from "@oh-my-pi/pi-ai";
|
||||
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
|
||||
import { isRecord, logger } from "@oh-my-pi/pi-utils";
|
||||
import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
|
||||
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
|
||||
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
|
||||
import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
|
||||
import { type ConfigError, ConfigFile } from "./config-file";
|
||||
import {
|
||||
buildCanonicalModelIndex,
|
||||
@@ -2373,12 +2367,33 @@ export class ModelRegistry {
|
||||
|
||||
/**
|
||||
* Get API key for a provider (e.g., "openai").
|
||||
*
|
||||
* `options.forceRefresh` powers step (b) of the auth-retry policy — it
|
||||
* re-mints the session-sticky OAuth token even when the cached copy still
|
||||
* looks valid. `options.signal` is threaded into any broker-bound refresh.
|
||||
*/
|
||||
async getApiKeyForProvider(provider: string, sessionId?: string, baseUrl?: string): Promise<string | undefined> {
|
||||
async getApiKeyForProvider(
|
||||
provider: string,
|
||||
sessionId?: string,
|
||||
options?: { baseUrl?: string; forceRefresh?: boolean; signal?: AbortSignal },
|
||||
): Promise<string | undefined> {
|
||||
if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
|
||||
return kNoAuth;
|
||||
}
|
||||
return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
|
||||
return this.authStorage.getApiKey(provider, sessionId, {
|
||||
baseUrl: options?.baseUrl,
|
||||
forceRefresh: options?.forceRefresh,
|
||||
signal: options?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link ApiKeyResolver} for this provider, implementing the
|
||||
* central a/b/c auth-retry policy. Callers that need the initial key for
|
||||
* a guard can call `resolveApiKeyOnce(resolver)`.
|
||||
*/
|
||||
resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver {
|
||||
return createApiKeyResolver(this, provider, options);
|
||||
}
|
||||
|
||||
async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
|
||||
@@ -2609,6 +2624,14 @@ export class ModelRegistry {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cooldown suppressions recorded via {@link suppressSelector}.
|
||||
* Used to reset retry-fallback cooldown state without a full {@link refresh}.
|
||||
*/
|
||||
clearSuppressedSelectors(): void {
|
||||
this.#suppressedSelectors.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -660,6 +660,16 @@ export const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
"display.smoothStreaming": {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
ui: {
|
||||
tab: "appearance",
|
||||
label: "Smooth Streaming",
|
||||
description: "Reveal assistant text smoothly while streamed chunks arrive",
|
||||
},
|
||||
},
|
||||
|
||||
"display.showTokenUsage": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
@@ -902,6 +912,15 @@ export const SETTINGS_SCHEMA = {
|
||||
"Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows).",
|
||||
},
|
||||
},
|
||||
"retry.modelFallback": {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
ui: {
|
||||
tab: "model",
|
||||
label: "Retry Model Fallback",
|
||||
description: "Allow retry recovery to switch to configured fallback models",
|
||||
},
|
||||
},
|
||||
"retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
|
||||
"retry.fallbackRevertPolicy": {
|
||||
type: "enum",
|
||||
@@ -1855,7 +1874,7 @@ export const SETTINGS_SCHEMA = {
|
||||
tab: "editing",
|
||||
label: "Hash Lines",
|
||||
description:
|
||||
"Include snapshot-tag headers and line numbers in read output for hashline edit mode (¶PATH#tag plus LINE:content)",
|
||||
"Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2493,13 +2512,13 @@ export const SETTINGS_SCHEMA = {
|
||||
// Tool Discovery
|
||||
"tools.discoveryMode": {
|
||||
type: "enum",
|
||||
values: ["off", "mcp-only", "all"] as const,
|
||||
default: "off",
|
||||
values: ["auto", "off", "mcp-only", "all"] as const,
|
||||
default: "auto",
|
||||
ui: {
|
||||
tab: "tools",
|
||||
label: "Tool Discovery",
|
||||
description:
|
||||
"Hide tools behind a search tool to save tokens. 'mcp-only' hides MCP tools; 'all' hides all non-essential built-ins too.",
|
||||
"Hide tools behind a search tool to save tokens. 'auto' hides MCP tools once the tool set has more than 40 tools; 'mcp-only' always hides MCP tools; 'all' hides all non-essential built-ins too.",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3050,13 +3069,26 @@ export const SETTINGS_SCHEMA = {
|
||||
],
|
||||
},
|
||||
},
|
||||
"providers.parallelFetch": {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
"providers.fetch": {
|
||||
type: "enum",
|
||||
values: ["auto", "native", "trafilatura", "lynx", "parallel", "jina"] as const,
|
||||
default: "auto",
|
||||
ui: {
|
||||
tab: "providers",
|
||||
label: "Parallel Fetch",
|
||||
description: "Use Parallel extract API for URL fetching when credentials are available",
|
||||
label: "Fetch Provider",
|
||||
description: "Reader backend priority for the fetch/read URL tool",
|
||||
options: [
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
description: "Priority: native > trafilatura > lynx > parallel > jina",
|
||||
},
|
||||
{ value: "native", label: "Native", description: "In-process HTML→Markdown converter (always available)" },
|
||||
{ value: "trafilatura", label: "Trafilatura", description: "Auto-installs via uv/pip" },
|
||||
{ value: "lynx", label: "Lynx", description: "Requires lynx system package" },
|
||||
{ value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
|
||||
{ value: "jina", label: "Jina", description: "Uses r.jina.ai reader (JINA_API_KEY optional)" },
|
||||
],
|
||||
},
|
||||
},
|
||||
"provider.appendOnlyContext": {
|
||||
@@ -3307,6 +3339,7 @@ export interface RetrySettings {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
modelFallback: boolean;
|
||||
}
|
||||
|
||||
export interface MemoriesSettings {
|
||||
|
||||
@@ -240,11 +240,13 @@ export class Settings {
|
||||
return promise.then(
|
||||
instance => {
|
||||
globalInstance = instance;
|
||||
clearBoundSettingsMethods();
|
||||
globalInstancePromise = Promise.resolve(instance);
|
||||
return instance;
|
||||
},
|
||||
error => {
|
||||
globalInstance = null;
|
||||
clearBoundSettingsMethods();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
@@ -712,6 +714,17 @@ export class Settings {
|
||||
}
|
||||
}
|
||||
|
||||
// providers.parallelFetch (boolean) replaced by the providers.fetch reader
|
||||
// priority enum. The new default ("auto") supersedes both old values —
|
||||
// Parallel is now a deep fallback in the auto chain rather than the first
|
||||
// choice — so drop the legacy key (flat and nested) and let the enum
|
||||
// default apply.
|
||||
const providersObj = raw.providers as Record<string, unknown> | undefined;
|
||||
if (providersObj && "parallelFetch" in providersObj) {
|
||||
delete providersObj.parallelFetch;
|
||||
}
|
||||
delete raw["providers.parallelFetch"];
|
||||
|
||||
// Map legacy `memories.enabled` boolean to the explicit `memory.backend`
|
||||
// enum if the latter hasn't been set yet. Idempotent: subsequent
|
||||
// migrations are no-ops once memory.backend is materialised.
|
||||
@@ -967,6 +980,13 @@ export function onHindsightScopeChanged(cb: () => void): () => void {
|
||||
|
||||
let globalInstance: Settings | null = null;
|
||||
let globalInstancePromise: Promise<Settings> | null = null;
|
||||
let boundSettingsInstance: Settings | null = null;
|
||||
let boundSettingsMethods = new Map<PropertyKey, unknown>();
|
||||
|
||||
function clearBoundSettingsMethods(): void {
|
||||
boundSettingsInstance = null;
|
||||
boundSettingsMethods = new Map<PropertyKey, unknown>();
|
||||
}
|
||||
|
||||
export function isSettingsInitialized(): boolean {
|
||||
return globalInstance !== null;
|
||||
@@ -979,6 +999,7 @@ export function isSettingsInitialized(): boolean {
|
||||
export function resetSettingsForTest(): void {
|
||||
globalInstance = null;
|
||||
globalInstancePromise = null;
|
||||
clearBoundSettingsMethods();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -990,9 +1011,17 @@ export const settings = new Proxy({} as Settings, {
|
||||
if (!globalInstance) {
|
||||
throw new Error("Settings not initialized. Call Settings.init() first.");
|
||||
}
|
||||
const value = (globalInstance as unknown as Record<string | symbol, unknown>)[prop];
|
||||
if (boundSettingsInstance !== globalInstance) {
|
||||
clearBoundSettingsMethods();
|
||||
boundSettingsInstance = globalInstance;
|
||||
}
|
||||
const value = (globalInstance as unknown as Record<PropertyKey, unknown>)[prop];
|
||||
if (typeof value === "function") {
|
||||
return value.bind(globalInstance);
|
||||
const cached = boundSettingsMethods.get(prop);
|
||||
if (cached) return cached;
|
||||
const bound = value.bind(globalInstance);
|
||||
boundSettingsMethods.set(prop, bound);
|
||||
return bound;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { logger, ptree } from "@oh-my-pi/pi-utils";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { isEnoent, logger, ptree } from "@oh-my-pi/pi-utils";
|
||||
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
|
||||
import { ToolAbortError } from "../tools/tool-errors";
|
||||
import type {
|
||||
@@ -165,19 +166,7 @@ export class DapClient {
|
||||
detached: true,
|
||||
});
|
||||
|
||||
// Wait for the socket file to appear (dlv needs to start listening)
|
||||
await waitForCondition(
|
||||
() => {
|
||||
try {
|
||||
Bun.file(socketPath).size;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
10_000,
|
||||
proc,
|
||||
);
|
||||
await waitForCondition(() => isUnixSocketReady(socketPath), 10_000, proc);
|
||||
|
||||
const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
|
||||
const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
|
||||
@@ -553,15 +542,24 @@ export class DapClient {
|
||||
}
|
||||
}
|
||||
|
||||
async function isUnixSocketReady(socketPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(socketPath)).isSocket();
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll a condition until it returns true, or timeout/process exit. */
|
||||
async function waitForCondition(
|
||||
check: () => boolean,
|
||||
check: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
proc: { exitCode: number | null },
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (check()) return;
|
||||
if (await check()) return;
|
||||
if (proc.exitCode !== null) {
|
||||
throw new Error("Adapter process exited before socket was ready");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null {
|
||||
rootMarkers: normalizeStringArray(config.rootMarkers),
|
||||
launchDefaults: normalizeObject(config.launchDefaults),
|
||||
attachDefaults: normalizeObject(config.attachDefaults),
|
||||
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
|
||||
...(connectMode ? { connectMode } : {}),
|
||||
};
|
||||
}
|
||||
@@ -64,6 +65,7 @@ export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAda
|
||||
launchDefaults: config.launchDefaults ?? {},
|
||||
attachDefaults: config.attachDefaults ?? {},
|
||||
connectMode: config.connectMode ?? "stdio",
|
||||
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,12 +126,19 @@ function sortAdaptersForLaunch(program: string, cwd: string, adapters: DapResolv
|
||||
return rootAware.map(entry => entry.adapter);
|
||||
}
|
||||
|
||||
export function selectLaunchAdapter(program: string, cwd: string, adapterName?: string): DapResolvedAdapter | null {
|
||||
export function selectLaunchAdapter(
|
||||
program: string,
|
||||
cwd: string,
|
||||
adapterName?: string,
|
||||
programKind: LaunchProgramKind = "file",
|
||||
): DapResolvedAdapter | null {
|
||||
if (adapterName) {
|
||||
return resolveAdapter(adapterName, cwd);
|
||||
}
|
||||
const matches = getMatchingAdapters(program, cwd);
|
||||
const sorted = sortAdaptersForLaunch(program, cwd, matches);
|
||||
const candidates =
|
||||
programKind === "directory" ? matches.filter(adapter => adapter.acceptsDirectoryProgram) : matches;
|
||||
const sorted = sortAdaptersForLaunch(program, cwd, candidates.length > 0 ? candidates : matches);
|
||||
return sorted[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -148,3 +157,33 @@ export function selectAttachAdapter(cwd: string, adapterName?: string, port?: nu
|
||||
}
|
||||
return available[0] ?? null;
|
||||
}
|
||||
|
||||
/** How the launch `program` resolves on disk. `"missing"` is reserved for
|
||||
* programs the adapter creates on demand (rare); we treat them like files. */
|
||||
export type LaunchProgramKind = "file" | "directory" | "missing";
|
||||
|
||||
/** Compute adapter-specific launch arguments that depend on the resolved
|
||||
* program. Returned values are spread over `adapter.launchDefaults` so they
|
||||
* take precedence over the static defaults but can still be overridden by
|
||||
* the fields `DapSessionManager.launch` sets explicitly (program, cwd, args).
|
||||
*
|
||||
* Currently scoped to dlv, where `mode` selects how the program path is
|
||||
* interpreted: directories and `.go` source files debug as a Go package
|
||||
* (`mode=debug`), anything else is treated as a compiled binary (`mode=exec`).
|
||||
*/
|
||||
export function resolveLaunchOverrides(
|
||||
adapter: DapResolvedAdapter,
|
||||
program: string,
|
||||
programKind: LaunchProgramKind,
|
||||
): Record<string, unknown> {
|
||||
if (adapter.name === "dlv") {
|
||||
const extension = path.extname(program).toLowerCase();
|
||||
if (programKind === "directory" || extension === ".go") {
|
||||
return { mode: "debug" };
|
||||
}
|
||||
if (programKind === "file") {
|
||||
return { mode: "exec" };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"languages": ["go"],
|
||||
"fileTypes": [".go"],
|
||||
"rootMarkers": ["go.mod", "go.sum"],
|
||||
"acceptsDirectoryProgram": true,
|
||||
"launchDefaults": {
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
|
||||
@@ -259,6 +259,7 @@ export class DapSessionManager {
|
||||
session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true;
|
||||
const launchArguments: DapLaunchArguments = {
|
||||
...options.adapter.launchDefaults,
|
||||
...(options.extraLaunchArguments ?? {}),
|
||||
program: options.program,
|
||||
cwd: options.cwd,
|
||||
args: options.args,
|
||||
|
||||
@@ -488,6 +488,10 @@ export interface DapAdapterConfig {
|
||||
* On Linux, connects via a unix domain socket.
|
||||
* On macOS, the adapter dials into a local TCP listener (--client-addr). */
|
||||
connectMode?: "stdio" | "socket";
|
||||
/** When true, the adapter accepts a directory as the launch `program`
|
||||
* (e.g. dlv treats it as a Go package path). When false/undefined, the
|
||||
* debug tool rejects directory programs upfront. */
|
||||
acceptsDirectoryProgram?: boolean;
|
||||
}
|
||||
|
||||
export interface DapResolvedAdapter {
|
||||
@@ -501,6 +505,7 @@ export interface DapResolvedAdapter {
|
||||
launchDefaults: Record<string, unknown>;
|
||||
attachDefaults: Record<string, unknown>;
|
||||
connectMode: "stdio" | "socket";
|
||||
acceptsDirectoryProgram: boolean;
|
||||
}
|
||||
|
||||
export interface DapBreakpointRecord {
|
||||
@@ -589,6 +594,11 @@ export interface DapLaunchSessionOptions {
|
||||
program: string;
|
||||
args?: string[];
|
||||
cwd: string;
|
||||
/** Per-launch overrides merged over `adapter.launchDefaults`. Used to
|
||||
* inject adapter-specific values that depend on the resolved program
|
||||
* (e.g. dlv's `mode` switches between `debug` and `exec` based on
|
||||
* whether `program` is a Go package path or a compiled binary). */
|
||||
extraLaunchArguments?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DapAttachSessionOptions {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { getSessionsDir } from "@oh-my-pi/pi-utils";
|
||||
import { DynamicBorder } from "../modes/components/dynamic-border";
|
||||
import { TranscriptBlock } from "../modes/components/transcript-container";
|
||||
import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "../modes/types";
|
||||
import { formatBytes } from "../tools/render-utils";
|
||||
@@ -150,13 +151,13 @@ export class DebugSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
// Show message and wait for keypress
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", `${theme.status.info} CPU profiling started`), 1, 0));
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
const block = new TranscriptBlock();
|
||||
block.addChild(new Text(theme.fg("accent", `${theme.status.info} CPU profiling started`), 1, 0));
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(
|
||||
new Text(theme.fg("muted", "Reproduce the performance issue, then press Enter to stop profiling."), 1, 0),
|
||||
);
|
||||
this.ctx.ui.requestRender();
|
||||
this.ctx.present(block);
|
||||
|
||||
// Wait for Enter keypress
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
@@ -201,19 +202,16 @@ export class DebugSelectorComponent extends Container {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0),
|
||||
);
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
const block = new TranscriptBlock();
|
||||
block.addChild(new Text(theme.fg("success", `${theme.status.success} Performance report saved`), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleWorkReport(): Promise<void> {
|
||||
@@ -231,15 +229,13 @@ export class DebugSelectorComponent extends Container {
|
||||
|
||||
openPath(tmpPath);
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(theme.fg("dim", `Opened flamegraph (${workProfile.sampleCount} samples)`), 1, 0),
|
||||
);
|
||||
]);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to open profile: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleDumpReport(): Promise<void> {
|
||||
@@ -262,19 +258,16 @@ export class DebugSelectorComponent extends Container {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0),
|
||||
);
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
const block = new TranscriptBlock();
|
||||
block.addChild(new Text(theme.fg("success", `${theme.status.success} Report bundle saved`), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleMemoryReport(): Promise<void> {
|
||||
@@ -301,19 +294,16 @@ export class DebugSelectorComponent extends Container {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0),
|
||||
);
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
const block = new TranscriptBlock();
|
||||
block.addChild(new Text(theme.fg("success", `${theme.status.success} Memory report saved`), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", formatFileHyperlink(result.path)), 1, 0));
|
||||
block.addChild(new Text(theme.fg("dim", `Files: ${result.files.length}`), 1, 0));
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to create report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleViewLogs(): Promise<void> {
|
||||
@@ -365,15 +355,14 @@ export class DebugSelectorComponent extends Container {
|
||||
const info = await collectSystemInfo();
|
||||
const formatted = formatSystemInfo(info);
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(new Text(formatted, 1, 0));
|
||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
||||
const block = new TranscriptBlock();
|
||||
block.addChild(new DynamicBorder());
|
||||
block.addChild(new Text(formatted, 1, 0));
|
||||
block.addChild(new DynamicBorder());
|
||||
this.ctx.present(block);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to collect system info: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleViewTerminalState(): Promise<void> {
|
||||
@@ -384,11 +373,11 @@ export class DebugSelectorComponent extends Container {
|
||||
});
|
||||
const formatted = formatTerminalState(info);
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(new Text(formatted, 1, 0));
|
||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
||||
this.ctx.ui.requestRender();
|
||||
const block = new TranscriptBlock();
|
||||
block.addChild(new DynamicBorder());
|
||||
block.addChild(new Text(formatted, 1, 0));
|
||||
block.addChild(new DynamicBorder());
|
||||
this.ctx.present(block);
|
||||
}
|
||||
|
||||
async #handleViewProtocols(): Promise<void> {
|
||||
@@ -407,15 +396,14 @@ export class DebugSelectorComponent extends Container {
|
||||
TERMINAL.sendNotification(notification);
|
||||
}
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new ProtocolProbeComponent({
|
||||
image: buildSampleImage(),
|
||||
imageBudget: this.ctx.ui.imageBudget,
|
||||
notificationSuppressed: suppressed,
|
||||
}),
|
||||
);
|
||||
this.ctx.ui.requestRender();
|
||||
]);
|
||||
}
|
||||
|
||||
async #handleTranscriptExport(): Promise<void> {
|
||||
@@ -487,21 +475,19 @@ export class DebugSelectorComponent extends Container {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(
|
||||
theme.fg("success", `${theme.status.success} Cleared ${result.removed} artifact directories`),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
]);
|
||||
} catch (err) {
|
||||
loader.stop();
|
||||
this.ctx.statusContainer.clear();
|
||||
this.ctx.showError(`Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
#getResolvedSettings(): Record<string, unknown> {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type Component, matchesKey, padding, replaceTabs, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import {
|
||||
type Component,
|
||||
matchesKey,
|
||||
padding,
|
||||
replaceTabs,
|
||||
ScrollView,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
||||
import { theme } from "../modes/theme/theme";
|
||||
import { copyToClipboard } from "../utils/clipboard";
|
||||
@@ -146,14 +154,20 @@ export class RawSseViewerComponent implements Component {
|
||||
const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
|
||||
const bodyHeight = this.#bodyHeight();
|
||||
const rawLines = this.#renderRawLines(innerWidth);
|
||||
const body = rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight);
|
||||
while (body.length < bodyHeight) body.push("");
|
||||
const sv = new ScrollView(rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight), {
|
||||
height: bodyHeight,
|
||||
scrollbar: "auto",
|
||||
totalRows: rawLines.length,
|
||||
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
||||
});
|
||||
sv.setScrollOffset(this.#scrollOffset);
|
||||
const bodyRows = sv.render(innerWidth);
|
||||
|
||||
return [
|
||||
this.#frameTop(innerWidth),
|
||||
this.#frameLine(this.#summaryText(), innerWidth),
|
||||
this.#frameSeparator(innerWidth),
|
||||
...body.map(line => this.#frameLine(line, innerWidth)),
|
||||
...bodyRows.map(line => this.#frameLine(line, innerWidth)),
|
||||
this.#frameLine(this.#statusText(), innerWidth),
|
||||
this.#frameBottom(innerWidth),
|
||||
];
|
||||
|
||||
@@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize";
|
||||
/**
|
||||
* Upper bound on the file size we snapshot. A section tag is a content hash of
|
||||
* the *whole* file, so minting one means holding the full normalized text in
|
||||
* the store. Files above this cap emit no `¶path#tag` header — line-anchored
|
||||
* the store. Files above this cap emit no `[path#tag]` header — line-anchored
|
||||
* editing of multi-megabyte files is out of scope under the full-content model.
|
||||
*/
|
||||
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
@@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string {
|
||||
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
||||
const input = typeof record.input === "string" ? record.input : undefined;
|
||||
if (input) {
|
||||
const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
|
||||
const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input);
|
||||
if (hashlineMatch?.[1]) return hashlineMatch[1];
|
||||
|
||||
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Edit tool renderer and LSP batching helpers.
|
||||
*/
|
||||
|
||||
import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
|
||||
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
||||
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { FileDiagnosticsResult } from "../lsp";
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
formatDiffStats,
|
||||
formatExpandHint,
|
||||
formatStatusIcon,
|
||||
formatTitle,
|
||||
getDiffStats,
|
||||
getLspBatchRequest,
|
||||
type LspBatchRequest,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
shortenPath,
|
||||
truncateDiffByHunk,
|
||||
} from "../tools/render-utils";
|
||||
import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
||||
import { fileHyperlink, framedBlock, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
||||
import type { EditMode } from "../utils/edit-mode";
|
||||
import type { DiffError, DiffResult } from "./diff";
|
||||
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
||||
@@ -179,11 +178,6 @@ function countEditFiles(edits: EditRenderEntry[]): number {
|
||||
return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
function countLines(text: string): number {
|
||||
if (!text) return 0;
|
||||
return text.split("\n").length;
|
||||
}
|
||||
|
||||
function getOperationTitle(op: Operation | undefined): string {
|
||||
return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
|
||||
}
|
||||
@@ -191,10 +185,14 @@ function getOperationTitle(op: Operation | undefined): string {
|
||||
function formatEditPathDisplay(
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
options?: { rename?: string; firstChangedLine?: number },
|
||||
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
|
||||
): string {
|
||||
// `rawPath`/`rename` are shown (cwd-relative) but the OSC 8 link targets the
|
||||
// absolute path when known — a relative `rawPath` would yield a `file:///rel`
|
||||
// URI that resolves against filesystem root instead of cwd.
|
||||
const linkTarget = options?.linkPath || rawPath;
|
||||
let pathDisplay = rawPath
|
||||
? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath)))
|
||||
? fileHyperlink(linkTarget, uiTheme.fg("accent", shortenPath(rawPath)))
|
||||
: uiTheme.fg("toolOutput", "…");
|
||||
|
||||
if (options?.firstChangedLine) {
|
||||
@@ -202,7 +200,8 @@ function formatEditPathDisplay(
|
||||
}
|
||||
|
||||
if (options?.rename) {
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`;
|
||||
const renameTarget = options.renameLinkPath || options.rename;
|
||||
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", shortenPath(options.rename)))}`;
|
||||
}
|
||||
|
||||
return pathDisplay;
|
||||
@@ -211,7 +210,7 @@ function formatEditPathDisplay(
|
||||
function formatEditDescription(
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
options?: { rename?: string; firstChangedLine?: number },
|
||||
options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
|
||||
): { language: string; description: string } {
|
||||
const language = getLanguageFromPath(rawPath) ?? "text";
|
||||
const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
|
||||
@@ -233,19 +232,22 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
|
||||
return preview.trimEnd();
|
||||
}
|
||||
|
||||
function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
|
||||
function formatStreamingDiff(
|
||||
diff: string,
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
expanded: boolean,
|
||||
label = "streaming",
|
||||
): string {
|
||||
if (!diff) return "";
|
||||
// "Cursor" tail window: pin the last EDIT_STREAMING_PREVIEW_LINES rows to the
|
||||
// bottom of the diff so freshly streamed changes stay on screen, and accept
|
||||
// the trailing rows "from the back" once the diff outgrows the window. The
|
||||
// whole-file diff is recomputed on every streamed chunk and its Myers
|
||||
// alignment is not monotonic in payload length, so a hunk-aware window that
|
||||
// kept whole change segments gained and lost rows tick to tick — the box
|
||||
// stuttered, and the earlier high-water fix traded that for a half-empty
|
||||
// rectangle. A strict fixed-height window keeps the box steady and always
|
||||
// full of real diff context instead of blank padding.
|
||||
// Collapsed uses a "Cursor" tail window: pin the last
|
||||
// EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
|
||||
// stay on screen. The whole-file diff is recomputed on every streamed chunk
|
||||
// and its Myers alignment is not monotonic in payload length, so a hunk-aware
|
||||
// window stutters as rows move between hunks. Expanded deliberately lifts that
|
||||
// cap for the approval-time full view.
|
||||
const allLines = diff.replace(/\n+$/u, "").split("\n");
|
||||
const hiddenLines = Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
|
||||
const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
|
||||
const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
|
||||
let text = "\n\n";
|
||||
if (hiddenLines > 0) {
|
||||
@@ -256,19 +258,11 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
|
||||
text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
|
||||
}
|
||||
text += renderDiffColored(visible.join("\n"), { filePath: rawPath });
|
||||
text += uiTheme.fg("dim", `\n(${label})`);
|
||||
if (!expanded || label !== "preview") text += uiTheme.fg("dim", `\n(${label})`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
|
||||
const icon = uiTheme.getLangIcon(language);
|
||||
if (lineCount !== null) {
|
||||
return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
|
||||
}
|
||||
return uiTheme.fg("dim", `${icon}`);
|
||||
}
|
||||
|
||||
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
|
||||
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme, expanded: boolean): string {
|
||||
const parts: string[] = [];
|
||||
for (const preview of previews) {
|
||||
if (!preview.diff && !preview.error) continue;
|
||||
@@ -278,7 +272,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
|
||||
continue;
|
||||
}
|
||||
if (preview.diff) {
|
||||
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
|
||||
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview")}`);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
@@ -289,16 +283,17 @@ function getCallPreview(
|
||||
rawPath: string,
|
||||
uiTheme: Theme,
|
||||
renderContext: EditRenderContext | undefined,
|
||||
expanded: boolean,
|
||||
): string {
|
||||
const multi = renderContext?.perFileDiffPreview;
|
||||
if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
|
||||
return formatMultiFileStreamingDiff(multi, uiTheme);
|
||||
return formatMultiFileStreamingDiff(multi, uiTheme, expanded);
|
||||
}
|
||||
if (args.previewDiff) {
|
||||
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
|
||||
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview");
|
||||
}
|
||||
if (args.diff && args.op) {
|
||||
return formatStreamingDiff(args.diff, rawPath, uiTheme);
|
||||
return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded);
|
||||
}
|
||||
if (args.diff) {
|
||||
return renderPlainTextPreview(args.diff, uiTheme, rawPath);
|
||||
@@ -328,12 +323,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
||||
}
|
||||
|
||||
function parseHashlineInputPreviewHeader(line: string): string | null {
|
||||
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
||||
// Mirror hashline/input.ts: strip every leading file marker so canonical
|
||||
// `¶ PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths.
|
||||
let prefixEnd = 0;
|
||||
while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
|
||||
const body = line.slice(prefixEnd).trim();
|
||||
const trimmed = line.trimEnd();
|
||||
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
|
||||
// Keep streaming previews tolerant while the closing bracket is still
|
||||
// being generated; the parser enforces the final `[path#TAG]` shape.
|
||||
const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length;
|
||||
const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim();
|
||||
const previewPath = normalizeHashlineInputPreviewPath(body);
|
||||
return previewPath.length > 0 ? previewPath : null;
|
||||
}
|
||||
@@ -383,6 +378,13 @@ function getApplyPatchRenderSummary(
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiffStatsSuffix(diff: string, uiTheme: Theme): string {
|
||||
const { added, removed, hunks } = getDiffStats(diff);
|
||||
const stats = formatDiffStats(added, removed, hunks, uiTheme);
|
||||
if (!stats) return "";
|
||||
return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
|
||||
}
|
||||
|
||||
function renderDiffSection(
|
||||
diff: string,
|
||||
rawPath: string,
|
||||
@@ -390,15 +392,6 @@ function renderDiffSection(
|
||||
uiTheme: Theme,
|
||||
renderDiffFn: (t: string, o?: { filePath?: string }) => string,
|
||||
): string {
|
||||
let text = "";
|
||||
const diffStats = getDiffStats(diff);
|
||||
text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${formatDiffStats(
|
||||
diffStats.added,
|
||||
diffStats.removed,
|
||||
diffStats.hunks,
|
||||
uiTheme,
|
||||
)}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
|
||||
|
||||
const {
|
||||
text: truncatedDiff,
|
||||
hiddenHunks,
|
||||
@@ -407,7 +400,7 @@ function renderDiffSection(
|
||||
? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
|
||||
: truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
|
||||
|
||||
text += `\n\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
|
||||
let text = `\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
|
||||
if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
|
||||
const remainder: string[] = [];
|
||||
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
|
||||
@@ -470,23 +463,31 @@ export const editToolRenderer = {
|
||||
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
|
||||
const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
|
||||
const spinner =
|
||||
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
|
||||
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
|
||||
// Show file count hint for multi-file edits
|
||||
let fileCount = hashlineInputSummary?.entries.length ?? applyPatchSummary?.entries.length ?? 0;
|
||||
if (Array.isArray(editArgs.edits)) {
|
||||
fileCount = countEditFiles(editArgs.edits);
|
||||
}
|
||||
if (fileCount > 1) {
|
||||
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
|
||||
}
|
||||
text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
|
||||
if (applyPatchSummary?.error) {
|
||||
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), CALL_TEXT_PREVIEW_WIDTH))}`;
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
return framedBlock(uiTheme, width => {
|
||||
let header = renderStatusLine(
|
||||
{ icon: "pending", spinnerFrame: options?.spinnerFrame, title: getOperationTitle(op), description },
|
||||
uiTheme,
|
||||
);
|
||||
if (fileCount > 1) header += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
|
||||
let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded);
|
||||
if (applyPatchSummary?.error) {
|
||||
body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
|
||||
}
|
||||
const bodyLines = body ? body.split("\n") : [];
|
||||
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
|
||||
return {
|
||||
header,
|
||||
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
|
||||
state: applyPatchSummary?.error ? "error" : "pending",
|
||||
borderColor: applyPatchSummary?.error ? "error" : "borderMuted",
|
||||
width,
|
||||
contentPaddingLeft: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
renderResult(
|
||||
@@ -528,11 +529,6 @@ function renderSingleFileResult(
|
||||
"";
|
||||
const op = args?.op || firstEdit?.op || details?.op;
|
||||
const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
|
||||
const { language } = formatEditDescription(rawPath, uiTheme, { rename });
|
||||
|
||||
const editTextSource = args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch;
|
||||
const metadataLineCount = editTextSource ? countLines(editTextSource) : null;
|
||||
const metadataLine = op !== "delete" ? `\n${formatMetadataLine(metadataLineCount, language, uiTheme)}` : "";
|
||||
|
||||
const displayErrorText = isError && details && "displayErrorText" in details ? details.displayErrorText : undefined;
|
||||
const errorText = isError
|
||||
@@ -541,61 +537,57 @@ function renderSingleFileResult(
|
||||
(result.content?.find(c => c.type === "text")?.text ?? "")
|
||||
: "";
|
||||
|
||||
let cached: RenderCache | undefined;
|
||||
return framedBlock(uiTheme, width => {
|
||||
const { expanded, renderContext } = options;
|
||||
const editDiffPreview = renderContext?.editDiffPreview;
|
||||
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
|
||||
|
||||
return {
|
||||
render(width) {
|
||||
const { expanded, renderContext } = options;
|
||||
const editDiffPreview = renderContext?.editDiffPreview;
|
||||
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
|
||||
const key = new Hasher().bool(expanded).u32(width).digest();
|
||||
if (cached?.key === key) return cached.lines;
|
||||
const firstChangedLine =
|
||||
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
|
||||
(details && !isError ? details.firstChangedLine : undefined);
|
||||
const linkPath = details && "path" in details ? details.path : undefined;
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine, linkPath });
|
||||
|
||||
const firstChangedLine =
|
||||
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
|
||||
(details && !isError ? details.firstChangedLine : undefined);
|
||||
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
|
||||
// Change stats ride inline on the header bar next to the path.
|
||||
const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
|
||||
const headerDiff = isError ? undefined : details?.diff || previewDiff;
|
||||
const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
|
||||
const header =
|
||||
renderStatusLine({ icon: isError ? "error" : "success", title: getOperationTitle(op), description }, uiTheme) +
|
||||
statsSuffix;
|
||||
|
||||
const header = renderStatusLine(
|
||||
{
|
||||
icon: isError ? "error" : "success",
|
||||
title: getOperationTitle(op),
|
||||
description,
|
||||
},
|
||||
uiTheme,
|
||||
let body = "";
|
||||
if (isError) {
|
||||
if (errorText) body = uiTheme.fg("error", replaceTabs(errorText, rawPath));
|
||||
} else if (details?.diff) {
|
||||
body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
} else if (editDiffPreview) {
|
||||
if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath));
|
||||
else if (editDiffPreview.diff)
|
||||
body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
}
|
||||
if (details?.diagnostics) {
|
||||
body += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
|
||||
uiTheme.getLangIcon(getLanguageFromPath(fp)),
|
||||
);
|
||||
let text = header;
|
||||
text += metadataLine;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (errorText) {
|
||||
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText, rawPath))}`;
|
||||
}
|
||||
} else if (details?.diff) {
|
||||
text += renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
} else if (editDiffPreview) {
|
||||
if ("error" in editDiffPreview) {
|
||||
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath))}`;
|
||||
} else if (editDiffPreview.diff) {
|
||||
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
||||
}
|
||||
}
|
||||
// Diff lines self-wrap with a continuation gutter; pre-wrap to the frame's
|
||||
// inner width so renderOutputBlock's generic wrap is a no-op. Edit frames
|
||||
// use a flush left border because code-frame gutters already provide padding.
|
||||
const innerWidth = Math.max(1, width - 2);
|
||||
const bodyLines = body.length > 0 ? body.split("\n").flatMap(line => wrapEditRendererLine(line, innerWidth)) : [];
|
||||
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
|
||||
|
||||
if (details?.diagnostics) {
|
||||
text += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
|
||||
uiTheme.getLangIcon(getLanguageFromPath(fp)),
|
||||
);
|
||||
}
|
||||
|
||||
const lines =
|
||||
width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
|
||||
cached = { key, lines };
|
||||
return lines;
|
||||
},
|
||||
invalidate() {
|
||||
cached = undefined;
|
||||
},
|
||||
};
|
||||
return {
|
||||
header,
|
||||
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
|
||||
state: isError ? "error" : options.isPartial ? "pending" : "success",
|
||||
borderColor: isError ? "error" : "borderMuted",
|
||||
width,
|
||||
contentPaddingLeft: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderMultiFileResult(
|
||||
@@ -650,7 +642,7 @@ function renderMultiFileResult(
|
||||
},
|
||||
invalidate() {
|
||||
cached = undefined;
|
||||
for (const c of fileComponents) c.invalidate();
|
||||
for (const c of fileComponents) c.invalidate?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
||||
return previews.length > 0 ? previews : null;
|
||||
},
|
||||
renderStreamingFallback() {
|
||||
// Never leak raw hashline syntax (`64:`, `|payload`, `¶path#hash`)
|
||||
// Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`)
|
||||
// to the user — the streaming preview already projects every
|
||||
// parseable op onto the real file via applyPartialTo, and an
|
||||
// unparseable trailing chunk renders as "no preview yet" rather
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AgentOutputManager } from "../../task/output-manager";
|
||||
import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
|
||||
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs } from "../heartbeat";
|
||||
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
|
||||
import { IdleTimeout } from "../idle-timeout";
|
||||
import { disposeAllVmContexts } from "../js/context-manager";
|
||||
import { executeJs } from "../js/executor";
|
||||
@@ -231,12 +231,62 @@ describe("runEvalAgent", () => {
|
||||
});
|
||||
await expect(runEvalAgent({ prompt: "fail" }, { session: makeSession() })).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
// Regression: a runtime-limit abort returns exitCode=1, stderr="", error=undefined,
|
||||
// aborted=true, abortReason="Subagent runtime limit exceeded (...)". The previous
|
||||
// failure-message coalesce stopped at the empty `stderr` (since `??` only skips
|
||||
// nullish values) and shipped an empty error through the bridge — Python then
|
||||
// surfaced the generic `bridge call '__agent__' failed`. See #2006.
|
||||
it("surfaces abortReason for aborts that leave stderr empty", async () => {
|
||||
mockAgents();
|
||||
const runSpy = vi.spyOn(taskExecutor, "runSubprocess");
|
||||
runSpy.mockImplementationOnce(async options =>
|
||||
singleResult(options, {
|
||||
exitCode: 1,
|
||||
output: "",
|
||||
stderr: "",
|
||||
error: undefined,
|
||||
aborted: true,
|
||||
abortReason: "Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
|
||||
}),
|
||||
);
|
||||
runSpy.mockImplementationOnce(async options =>
|
||||
singleResult(options, {
|
||||
exitCode: 1,
|
||||
output: "",
|
||||
stderr: " ",
|
||||
error: " ",
|
||||
aborted: true,
|
||||
abortReason: "Cancelled by caller",
|
||||
}),
|
||||
);
|
||||
runSpy.mockImplementationOnce(async options =>
|
||||
singleResult(options, {
|
||||
exitCode: 1,
|
||||
output: "",
|
||||
stderr: "",
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(runEvalAgent({ prompt: "slow" }, { session: makeSession() })).rejects.toThrow(
|
||||
"Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
|
||||
);
|
||||
// Whitespace-only stderr/error must not mask abortReason either.
|
||||
await expect(runEvalAgent({ prompt: "cancelled" }, { session: makeSession() })).rejects.toThrow(
|
||||
"Cancelled by caller",
|
||||
);
|
||||
// Last resort: still produce a non-empty message even when nothing useful is set,
|
||||
// so Python never falls back to `bridge call '__agent__' failed`.
|
||||
await expect(runEvalAgent({ prompt: "blank" }, { session: makeSession() })).rejects.toThrow(
|
||||
"agent() subagent 'task' failed.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent() through eval runtimes", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setBridgeHeartbeatIntervalMs();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -327,18 +377,6 @@ describe("agent() through eval runtimes", () => {
|
||||
singleResult(options, { output: "hello from python" }),
|
||||
);
|
||||
|
||||
const probe = await executePython('print("probe")', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId: `${sessionId}:probe`,
|
||||
sessionFile,
|
||||
kernelMode: "per-call",
|
||||
});
|
||||
if (probe.exitCode === undefined && probe.cancelled) {
|
||||
expect(probe.output).toBe("");
|
||||
return;
|
||||
}
|
||||
expect(probe.exitCode).toBe(0);
|
||||
|
||||
const result = await executePython('print(agent("hi"))', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
@@ -346,6 +384,10 @@ describe("agent() through eval runtimes", () => {
|
||||
kernelMode: "per-call",
|
||||
toolSession: session,
|
||||
});
|
||||
if (result.exitCode === undefined && result.cancelled) {
|
||||
expect(result.output).toBe("");
|
||||
return; // kernel unavailable in this environment
|
||||
}
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("hello from python");
|
||||
@@ -374,22 +416,14 @@ describe("agent() through eval runtimes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const probe = await executePython('print("probe")', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId: `${sessionId}:probe`,
|
||||
sessionFile,
|
||||
kernelMode: "per-call",
|
||||
});
|
||||
if (probe.exitCode === undefined && probe.cancelled) {
|
||||
expect(probe.output).toBe("");
|
||||
return;
|
||||
}
|
||||
expect(probe.exitCode).toBe(0);
|
||||
|
||||
const result = await executePython(
|
||||
'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
|
||||
{ cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session },
|
||||
);
|
||||
if (result.exitCode === undefined && result.cancelled) {
|
||||
expect(result.output).toBe("");
|
||||
return; // kernel unavailable in this environment
|
||||
}
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
|
||||
@@ -413,7 +447,14 @@ describe("agent() through eval runtimes", () => {
|
||||
// The host must respond the instant the cell aborts so the kernel can
|
||||
// unwind via KeyboardInterrupt instead of being hard-killed (which used to
|
||||
// surface "[kernel] Python kernel shutdown" and lose all session state).
|
||||
let inFlight = 0;
|
||||
let markSaturated: (() => void) | undefined;
|
||||
const saturated = new Promise<void>(resolve => {
|
||||
markSaturated = resolve;
|
||||
});
|
||||
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
||||
// task.maxConcurrency=6 → six bridge calls block at once; signal then.
|
||||
if (++inFlight >= 6) markSaturated?.();
|
||||
await Bun.sleep(9000); // deliberately ignores options.signal
|
||||
return singleResult(options, { output: options.assignment ?? "" });
|
||||
});
|
||||
@@ -433,8 +474,9 @@ describe("agent() through eval runtimes", () => {
|
||||
expect(seed.exitCode).toBe(0);
|
||||
|
||||
const ac = new AbortController();
|
||||
// Abort ~1s in, after the worker threads are blocked in their bridge calls.
|
||||
setTimeout(() => ac.abort(new Error("external interrupt")), 1000);
|
||||
// Abort the instant all six worker threads are confirmed blocked in their
|
||||
// bridge calls (condition-driven) instead of waiting a fixed wall second.
|
||||
void saturated.then(() => ac.abort(new Error("external interrupt")));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await executePython(
|
||||
@@ -560,52 +602,52 @@ describe("agent() through eval runtimes", () => {
|
||||
expect(displayAgentEvents.length).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the idle watchdog armed while a quiet agent() runs past the budget", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-agent-heartbeat-");
|
||||
const { session } = makeEvalSession(tempDir, "js-agent-heartbeat");
|
||||
it("pauses the idle watchdog while a quiet agent() runs past the budget", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-agent-timeout-pause-");
|
||||
const { session } = makeEvalSession(tempDir, "js-agent-timeout-pause");
|
||||
mockAgents();
|
||||
// Heartbeat cadence well under the idle budget so a working-but-silent
|
||||
// subagent re-arms the watchdog several times before it could expire.
|
||||
setBridgeHeartbeatIntervalMs(15);
|
||||
|
||||
// runSubprocess runs far past the budget and emits NO progress of its own
|
||||
// — the only thing standing between the subagent and a spurious idle abort
|
||||
// is the heartbeat keepalive the bridge pumps while it awaits.
|
||||
// runSubprocess runs far past the eval timeout budget and emits NO progress
|
||||
// of its own. The bridge pause must make that delegated time invisible to
|
||||
// the watchdog.
|
||||
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
||||
await Bun.sleep(200);
|
||||
await Bun.sleep(40);
|
||||
return singleResult(options, { output: "done" });
|
||||
});
|
||||
|
||||
// Mirror the eval tool's wiring: an IdleTimeout drives cancellation and
|
||||
// ONLY a bridge heartbeat re-arms it.
|
||||
using idle = new IdleTimeout(60);
|
||||
const ops: string[] = [];
|
||||
using idle = new IdleTimeout(20);
|
||||
const result = await runEvalAgent(
|
||||
{ prompt: "investigate" },
|
||||
{
|
||||
session,
|
||||
signal: idle.signal,
|
||||
emitStatus: event => {
|
||||
if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
|
||||
ops.push(event.op);
|
||||
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
||||
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
expect(result.text).toBe("done");
|
||||
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
|
||||
await Bun.sleep(60);
|
||||
expect(idle.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let agent() progress snapshots re-arm the watchdog without a heartbeat", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-agent-progress-no-rearm-");
|
||||
const { session } = makeEvalSession(tempDir, "js-agent-progress-no-rearm");
|
||||
it("keeps timeout paused despite agent() progress snapshots", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-agent-progress-timeout-pause-");
|
||||
const { session } = makeEvalSession(tempDir, "js-agent-progress-timeout-pause");
|
||||
mockAgents();
|
||||
// Heartbeat slower than the budget: only the immediate beat at call start
|
||||
// fires, so after the budget elapses nothing re-arms the watchdog.
|
||||
setBridgeHeartbeatIntervalMs(10_000);
|
||||
|
||||
// Stream frequent progress snapshots (op:"agent") for well past the budget.
|
||||
// Progress is rendered but MUST NOT count as activity — only heartbeats do.
|
||||
// They render as status, but timeout accounting is controlled only by the
|
||||
// bridge pause/resume events.
|
||||
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
options.onProgress?.({
|
||||
index: options.index,
|
||||
id: options.id,
|
||||
@@ -622,28 +664,30 @@ describe("agent() through eval runtimes", () => {
|
||||
cost: 0,
|
||||
durationMs: i * 10,
|
||||
});
|
||||
await Bun.sleep(10);
|
||||
await Bun.sleep(5);
|
||||
}
|
||||
return singleResult(options, { output: "done" });
|
||||
});
|
||||
|
||||
const ops: string[] = [];
|
||||
using idle = new IdleTimeout(80);
|
||||
await runEvalAgent(
|
||||
using idle = new IdleTimeout(40);
|
||||
const result = await runEvalAgent(
|
||||
{ prompt: "investigate" },
|
||||
{
|
||||
session,
|
||||
signal: idle.signal,
|
||||
emitStatus: event => {
|
||||
ops.push(event.op);
|
||||
if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
|
||||
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
||||
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Progress streamed, but the watchdog still fired: agent snapshots never
|
||||
// re-armed it, and the lone start heartbeat lapsed before the call ended.
|
||||
expect(result.text).toBe("done");
|
||||
expect(ops[0]).toBe(EVAL_TIMEOUT_PAUSE_OP);
|
||||
expect(ops).toContain("agent");
|
||||
expect(idle.signal.aborted).toBe(true);
|
||||
expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP);
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
EVAL_TIMEOUT_PAUSE_OP,
|
||||
EVAL_TIMEOUT_RESUME_OP,
|
||||
isEvalTimeoutControlEvent,
|
||||
withBridgeTimeoutPause,
|
||||
} from "../bridge-timeout";
|
||||
import type { JsStatusEvent } from "../js/shared/types";
|
||||
|
||||
describe("withBridgeTimeoutPause", () => {
|
||||
it("emits one pause before the operation and one resume after it settles", async () => {
|
||||
const events: JsStatusEvent[] = [];
|
||||
|
||||
const value = await withBridgeTimeoutPause(
|
||||
event => events.push(event),
|
||||
async () => {
|
||||
await Bun.sleep(80);
|
||||
return "done";
|
||||
},
|
||||
);
|
||||
|
||||
expect(value).toBe("done");
|
||||
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
||||
|
||||
const settledCount = events.length;
|
||||
await Bun.sleep(40);
|
||||
expect(events.length).toBe(settledCount);
|
||||
});
|
||||
|
||||
it("resumes timeout accounting even when the operation throws", async () => {
|
||||
const events: JsStatusEvent[] = [];
|
||||
|
||||
await expect(
|
||||
withBridgeTimeoutPause(
|
||||
event => events.push(event),
|
||||
async () => {
|
||||
await Bun.sleep(20);
|
||||
throw new Error("boom");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
||||
});
|
||||
|
||||
it("runs the operation without emitting when no status sink is wired", async () => {
|
||||
let ran = 0;
|
||||
|
||||
const value = await withBridgeTimeoutPause(undefined, async () => {
|
||||
ran++;
|
||||
await Bun.sleep(20);
|
||||
return 42;
|
||||
});
|
||||
|
||||
expect(value).toBe(42);
|
||||
expect(ran).toBe(1);
|
||||
});
|
||||
|
||||
it("identifies timeout-control events as non-renderable status", () => {
|
||||
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_PAUSE_OP })).toBe(true);
|
||||
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_RESUME_OP })).toBe(true);
|
||||
expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs, withBridgeHeartbeat } from "../heartbeat";
|
||||
import type { JsStatusEvent } from "../js/shared/types";
|
||||
|
||||
describe("withBridgeHeartbeat", () => {
|
||||
afterEach(() => {
|
||||
setBridgeHeartbeatIntervalMs();
|
||||
});
|
||||
|
||||
it("pumps heartbeat events on cadence while the operation is pending, then stops", async () => {
|
||||
setBridgeHeartbeatIntervalMs(20);
|
||||
const events: JsStatusEvent[] = [];
|
||||
|
||||
const value = await withBridgeHeartbeat(
|
||||
event => events.push(event),
|
||||
async () => {
|
||||
await Bun.sleep(130);
|
||||
return "done";
|
||||
},
|
||||
);
|
||||
|
||||
expect(value).toBe("done");
|
||||
// ~6 ticks fit in 130ms at a 20ms cadence; assert it ticked repeatedly
|
||||
// without pinning the exact count (scheduler jitter).
|
||||
expect(events.length).toBeGreaterThanOrEqual(3);
|
||||
expect(events.every(event => event.op === EVAL_HEARTBEAT_OP)).toBe(true);
|
||||
|
||||
// The interval is cleared once the operation settles: no further ticks.
|
||||
const settledCount = events.length;
|
||||
await Bun.sleep(80);
|
||||
expect(events.length).toBe(settledCount);
|
||||
});
|
||||
|
||||
it("emits a heartbeat immediately so a bridge call extends the budget at once", async () => {
|
||||
// Interval far longer than the operation: the only beat that can fire is
|
||||
// the immediate one at call start. It must still reach the sink.
|
||||
setBridgeHeartbeatIntervalMs(10_000);
|
||||
const events: JsStatusEvent[] = [];
|
||||
|
||||
await withBridgeHeartbeat(
|
||||
event => events.push(event),
|
||||
async () => {
|
||||
await Bun.sleep(30);
|
||||
return "done";
|
||||
},
|
||||
);
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]?.op).toBe(EVAL_HEARTBEAT_OP);
|
||||
});
|
||||
|
||||
it("runs the operation without emitting when no status sink is wired", async () => {
|
||||
setBridgeHeartbeatIntervalMs(5);
|
||||
let ran = 0;
|
||||
|
||||
const value = await withBridgeHeartbeat(undefined, async () => {
|
||||
ran++;
|
||||
await Bun.sleep(40);
|
||||
return 42;
|
||||
});
|
||||
|
||||
expect(value).toBe(42);
|
||||
expect(ran).toBe(1);
|
||||
});
|
||||
|
||||
it("clears the heartbeat even when the operation throws", async () => {
|
||||
setBridgeHeartbeatIntervalMs(15);
|
||||
const events: JsStatusEvent[] = [];
|
||||
|
||||
await expect(
|
||||
withBridgeHeartbeat(
|
||||
event => events.push(event),
|
||||
async () => {
|
||||
await Bun.sleep(60);
|
||||
throw new Error("boom");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
const afterThrow = events.length;
|
||||
await Bun.sleep(60);
|
||||
expect(events.length).toBe(afterThrow);
|
||||
});
|
||||
});
|
||||
@@ -32,21 +32,34 @@ describe("IdleTimeout", () => {
|
||||
expect((idle.signal.reason as DOMException).name).toBe("TimeoutError");
|
||||
});
|
||||
|
||||
it("re-arms on every bump and only fires after activity stops", async () => {
|
||||
using idle = new IdleTimeout(150);
|
||||
// Bump well past a single window; each bump must push the deadline forward
|
||||
// so the watchdog never trips while activity continues.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await Bun.sleep(40);
|
||||
idle.bump();
|
||||
}
|
||||
it("ignores elapsed time while paused and resumes with a fresh window", async () => {
|
||||
using idle = new IdleTimeout(80);
|
||||
idle.pause();
|
||||
await Bun.sleep(160);
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
|
||||
// Activity stopped — the watchdog should now fire within roughly one window.
|
||||
const fired = await abortedWithin(idle.signal, 800);
|
||||
idle.resume();
|
||||
const firedEarly = await abortedWithin(idle.signal, 30);
|
||||
expect(firedEarly).toBe(false);
|
||||
const fired = await abortedWithin(idle.signal, 500);
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it("reference-counts overlapping pauses", async () => {
|
||||
using idle = new IdleTimeout(60);
|
||||
idle.pause();
|
||||
idle.pause();
|
||||
await Bun.sleep(120);
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
|
||||
idle.resume();
|
||||
await Bun.sleep(90);
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
|
||||
idle.resume();
|
||||
const fired = await abortedWithin(idle.signal, 500);
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
it("never fires after dispose()", async () => {
|
||||
const idle = new IdleTimeout(30);
|
||||
idle.dispose();
|
||||
@@ -55,12 +68,13 @@ describe("IdleTimeout", () => {
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores bump() after the watchdog has already fired", async () => {
|
||||
it("ignores pause/resume after the watchdog has already fired", async () => {
|
||||
using idle = new IdleTimeout(30);
|
||||
await abortedWithin(idle.signal, 500);
|
||||
expect(idle.signal.aborted).toBe(true);
|
||||
// Late activity must not un-abort or rearm a settled watchdog.
|
||||
idle.bump();
|
||||
idle.pause();
|
||||
idle.resume();
|
||||
expect(idle.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
__resetWindowsConsoleProbeCache,
|
||||
consoleAttachedViaTTY,
|
||||
hostHasInheritableConsole,
|
||||
shouldHideKernelWindow,
|
||||
} from "../py/spawn-options";
|
||||
|
||||
/**
|
||||
* `shouldHideKernelWindow` decides whether the long-lived Python kernel
|
||||
* subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
|
||||
* option to `CREATE_NO_WINDOW`, which detaches the child from any inherited
|
||||
* console — breaking both (a) `LoadLibraryExW` for NumPy/pandas native
|
||||
* extensions and (b) SIGINT delivery via `GenerateConsoleCtrlEvent`. See
|
||||
* issue #1960. The tests below pin the three layered concerns the PR review
|
||||
* surfaced:
|
||||
*
|
||||
* 1. `shouldHideKernelWindow` — pure predicate over a single boolean.
|
||||
* 2. `consoleAttachedViaTTY` — the TTY-OR fallback used when the Win32 FFI
|
||||
* probe is unavailable; covers the partial-redirection cases.
|
||||
* 3. `hostHasInheritableConsole` — the integration boundary. Off-Windows it
|
||||
* short-circuits to the TTY fallback; on Windows it is expected to
|
||||
* consult `kernel32!GetConsoleWindow()` first, which is the authoritative
|
||||
* signal even for the all-stdio-redirected case.
|
||||
*/
|
||||
describe("shouldHideKernelWindow", () => {
|
||||
it("inherits the host console on Windows when one is attached", () => {
|
||||
// Reporter's repro: omp launched in Windows Terminal, host has a
|
||||
// console, kernel must inherit so `import pandas` doesn't deadlock in
|
||||
// `_multiarray_umath` and SIGINT can recover the cell.
|
||||
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("hides on Windows only when the host has no console at all (true service / daemon)", () => {
|
||||
// CREATE_NO_WINDOW here suppresses the console window Windows would
|
||||
// otherwise auto-allocate for the console-app Python kernel.
|
||||
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("never sets windowsHide off-Windows (the option is a Win32-only flag)", () => {
|
||||
// On POSIX `windowsHide` is a no-op; the predicate must return false
|
||||
// everywhere off-Windows so the spawn site matches pre-fix behavior.
|
||||
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: true })).toBe(false);
|
||||
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: false })).toBe(false);
|
||||
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: true })).toBe(false);
|
||||
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consoleAttachedViaTTY (FFI fallback heuristic)", () => {
|
||||
// The OR of three TTY signals correctly classifies the realistic shell
|
||||
// redirection scenarios that motivated widening the check beyond stdout
|
||||
// in the first review pass (PR #1961). The all-three-redirected case
|
||||
// (false here) is the gap that the Win32 FFI probe in
|
||||
// `hostHasInheritableConsole` is meant to close — this fallback is best-
|
||||
// effort.
|
||||
|
||||
it("treats a fully interactive launch as console-attached", () => {
|
||||
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats `omp -p '...' > out.txt` (stdout-only redirect) as console-attached", () => {
|
||||
// The reviewer's first-pass repro: stdout off the terminal, stdin
|
||||
// and stderr still attached. OR keeps the console.
|
||||
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: false, stderrIsTTY: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats stdin-only redirects (`< in.txt`) as console-attached", () => {
|
||||
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats stderr-only redirects (`2> err.log`) as console-attached", () => {
|
||||
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false only when none of stdin/stdout/stderr is a TTY", () => {
|
||||
// This is the gap: a real Windows Terminal session with all three
|
||||
// streams redirected (`omp ... < in > out 2> err`) lands here.
|
||||
// `hostHasInheritableConsole` uses the Win32 FFI probe to recover
|
||||
// the right answer in that scenario; this helper is the fallback.
|
||||
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: false, stderrIsTTY: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostHasInheritableConsole", () => {
|
||||
afterEach(() => {
|
||||
__resetWindowsConsoleProbeCache();
|
||||
});
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it("matches the TTY-OR fallback off-Windows", () => {
|
||||
// Off-Windows, `windowsHide` is a no-op anyway, but we still
|
||||
// expose `hostHasInheritableConsole` symmetrically. Confirm it
|
||||
// degrades to the same OR the call site would compute by hand.
|
||||
const tty = consoleAttachedViaTTY({
|
||||
stdinIsTTY: !!process.stdin.isTTY,
|
||||
stdoutIsTTY: !!process.stdout.isTTY,
|
||||
stderrIsTTY: !!process.stderr.isTTY,
|
||||
});
|
||||
expect(hostHasInheritableConsole()).toBe(tty);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,16 +4,17 @@ import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
||||
import * as ai from "@oh-my-pi/pi-ai";
|
||||
import { Effort } from "@oh-my-pi/pi-ai";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import { Settings } from "../../config/settings";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs } from "../heartbeat";
|
||||
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
|
||||
import { IdleTimeout } from "../idle-timeout";
|
||||
import { disposeAllVmContexts } from "../js/context-manager";
|
||||
import { executeJs } from "../js/executor";
|
||||
import { runEvalLlm } from "../llm-bridge";
|
||||
import { disposeAllKernelSessions, executePython } from "../py/executor";
|
||||
import { disposeAllKernelSessions, type PythonResult } from "../py/executor";
|
||||
|
||||
function makeModel(provider: string, id: string, extra: Partial<Model<Api>> = {}): Model<Api> {
|
||||
return {
|
||||
@@ -57,6 +58,7 @@ function makeSession(opts: SessionOptions = {}): ToolSession {
|
||||
const modelRegistry = {
|
||||
getAvailable: () => opts.available ?? [SMOL, DEFAULT, SLOW],
|
||||
getApiKey: async () => (opts.apiKey === undefined ? "test-key" : opts.apiKey),
|
||||
resolver: () => async () => (opts.apiKey === undefined ? "test-key" : opts.apiKey),
|
||||
} as unknown as ModelRegistry;
|
||||
return {
|
||||
settings,
|
||||
@@ -96,10 +98,80 @@ function assistant(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
async function runPythonLlmInSubprocess(options: { structured: boolean; tempDir: TempDir }): Promise<PythonResult> {
|
||||
const repoRoot = path.resolve(import.meta.dir, "../../../..");
|
||||
const scriptPath = path.join(options.tempDir.path(), "run-python-llm.ts");
|
||||
const resultPath = path.join(options.tempDir.path(), "python-llm-result.json");
|
||||
const aiPath = path.resolve(import.meta.dir, "../../../../ai/src/index.ts");
|
||||
const executorPath = path.resolve(import.meta.dir, "../py/executor.ts");
|
||||
const settingsPath = path.resolve(import.meta.dir, "../../config/settings.ts");
|
||||
const code = options.structured
|
||||
? 'import json\nprint(json.dumps(llm("hi", schema={"type": "object"})))'
|
||||
: 'print(llm("hi", model="smol"))';
|
||||
const responseContent = options.structured
|
||||
? '[{ type: "toolCall", id: "tc-1", name: "respond", arguments: { ok: true } }]'
|
||||
: '[{ type: "text", text: "hello from python" }]';
|
||||
await Bun.write(
|
||||
scriptPath,
|
||||
`
|
||||
import { vi } from "bun:test";
|
||||
import * as ai from ${JSON.stringify(aiPath)};
|
||||
import { executePython } from ${JSON.stringify(executorPath)};
|
||||
import { Settings } from ${JSON.stringify(settingsPath)};
|
||||
|
||||
const SMOL = {
|
||||
id: "smol",
|
||||
name: "smol",
|
||||
api: "openai-responses",
|
||||
provider: "p",
|
||||
baseUrl: "https://example.test/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
const settings = Settings.isolated({ "async.enabled": false, "task.isolation.mode": "none" });
|
||||
settings.setModelRole("smol", "p/smol");
|
||||
settings.setModelRole("slow", "p/slow");
|
||||
const session = {
|
||||
settings,
|
||||
modelRegistry: {
|
||||
getAvailable: () => [SMOL],
|
||||
getApiKey: async () => "test-key",
|
||||
resolver: () => async () => "test-key",
|
||||
},
|
||||
getActiveModelString: () => "p/smol",
|
||||
};
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "p",
|
||||
model: "smol",
|
||||
stopReason: "stop",
|
||||
content: ${responseContent},
|
||||
});
|
||||
const result = await executePython(${JSON.stringify(code)}, {
|
||||
cwd: ${JSON.stringify(options.tempDir.path())},
|
||||
sessionId: ${JSON.stringify(`py-llm:${options.structured ? "struct" : "plain"}`)},
|
||||
sessionFile: ${JSON.stringify(path.join(options.tempDir.path(), "session.jsonl"))},
|
||||
toolSession: session,
|
||||
kernelMode: "per-call",
|
||||
});
|
||||
await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify(result));
|
||||
process.exit(0);
|
||||
`,
|
||||
);
|
||||
const child = await $`bun ${scriptPath}`.cwd(repoRoot).quiet().nothrow();
|
||||
const stdout = child.stdout.toString();
|
||||
const stderr = child.stderr.toString();
|
||||
if (child.exitCode !== 0) throw new Error(stderr || stdout || `Python llm subprocess exited with ${child.exitCode}`);
|
||||
return (await Bun.file(resultPath).json()) as PythonResult;
|
||||
}
|
||||
|
||||
describe("runEvalLlm", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setBridgeHeartbeatIntervalMs();
|
||||
});
|
||||
|
||||
it("resolves each tier to its expected model", async () => {
|
||||
@@ -217,31 +289,32 @@ describe("runEvalLlm", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the idle watchdog armed while a slow llm() request is in flight", async () => {
|
||||
// A oneshot completion emits no status until it returns; a slow request
|
||||
// must not look like a stalled cell. The bridge pumps a heartbeat while it
|
||||
// awaits, re-arming the watchdog through emitStatus.
|
||||
setBridgeHeartbeatIntervalMs(15);
|
||||
it("pauses the idle watchdog while a slow llm() request is in flight", async () => {
|
||||
// A oneshot completion emits no status until it returns; delegated model
|
||||
// time must be invisible to the eval timeout budget.
|
||||
vi.spyOn(ai, "completeSimple").mockImplementation(async () => {
|
||||
await Bun.sleep(200);
|
||||
return assistant({ text: "the answer" });
|
||||
});
|
||||
|
||||
const ops: string[] = [];
|
||||
using idle = new IdleTimeout(60);
|
||||
const result = await runEvalLlm(
|
||||
{ prompt: "q", model: "smol" },
|
||||
{
|
||||
session: makeSession(),
|
||||
signal: idle.signal,
|
||||
// Mirror the eval tool: only a bridge heartbeat re-arms the watchdog.
|
||||
emitStatus: event => {
|
||||
if (event.op === EVAL_HEARTBEAT_OP) idle.bump();
|
||||
ops.push(event.op);
|
||||
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
||||
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
expect(result.text).toBe("the answer");
|
||||
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "llm"]);
|
||||
expect(idle.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,38 +363,24 @@ describe("llm() through eval runtimes", () => {
|
||||
});
|
||||
|
||||
it("exposes llm() in the Python runtime", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-llm-py-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-llm:${crypto.randomUUID()}`;
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "hello from python" }));
|
||||
|
||||
const result = await executePython('print(llm("hi", model="smol"))', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
toolSession: makeSession(),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("hello from python");
|
||||
const tempDir = TempDir.createSync("@omp-eval-llm-py-");
|
||||
try {
|
||||
const result = await runPythonLlmInSubprocess({ structured: false, tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("hello from python");
|
||||
} finally {
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses structured llm() output in the Python runtime", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-llm-py-struct-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-llm-struct:${crypto.randomUUID()}`;
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue(
|
||||
assistant({ toolCall: { name: "respond", arguments: { ok: true } } }),
|
||||
);
|
||||
|
||||
const result = await executePython('import json\nprint(json.dumps(llm("hi", schema={"type": "object"})))', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
toolSession: makeSession(),
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.output.trim())).toEqual({ ok: true });
|
||||
const tempDir = TempDir.createSync("@omp-eval-llm-py-struct-");
|
||||
try {
|
||||
const result = await runPythonLlmInSubprocess({ structured: true, tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.output.trim())).toEqual({ ok: true });
|
||||
} finally {
|
||||
tempDir.removeSync();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,609 +0,0 @@
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import { Settings } from "../../config/settings";
|
||||
import type { LoadExtensionsResult } from "../../extensibility/extensions/types";
|
||||
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "../../sdk";
|
||||
import * as sdkModule from "../../sdk";
|
||||
import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../session/agent-session";
|
||||
import { TaskTool } from "../../task";
|
||||
import * as discoveryModule from "../../task/discovery";
|
||||
import type { AgentDefinition, TaskParams } from "../../task/types";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { EventBus } from "../../utils/event-bus";
|
||||
import { disposeAllVmContexts } from "../js/context-manager";
|
||||
import { executeJs } from "../js/executor";
|
||||
import { disposeAllKernelSessions, executePython } from "../py/executor";
|
||||
|
||||
function createToolSession(cwd: string, sessionFile: string | null, evalSessionId?: string): ToolSession {
|
||||
const modelRegistry = {
|
||||
authStorage: undefined,
|
||||
refresh: async () => {},
|
||||
getAvailable: () => [],
|
||||
getApiKey: async () => null,
|
||||
} as unknown as ModelRegistry;
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
settings: Settings.isolated({
|
||||
"async.enabled": false,
|
||||
"task.isolation.mode": "none",
|
||||
}),
|
||||
getSessionFile: () => sessionFile,
|
||||
getSessionSpawns: () => "*",
|
||||
getEvalSessionId: evalSessionId ? () => evalSessionId : undefined,
|
||||
modelRegistry,
|
||||
} as unknown as ToolSession;
|
||||
}
|
||||
|
||||
function createBridgeToolSession(resultText: string, calls: unknown[]): ToolSession {
|
||||
const readTool = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: "read",
|
||||
parameters: { type: "object" },
|
||||
async execute(_id: string, args: unknown) {
|
||||
calls.push(args);
|
||||
return { content: [{ type: "text" as const, text: resultText }] };
|
||||
},
|
||||
};
|
||||
const tools = new Map<string, unknown>([["read", readTool]]);
|
||||
return { getToolByName: (name: string) => tools.get(name) } as unknown as ToolSession;
|
||||
}
|
||||
|
||||
function assistantStopMessage(text: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: text ? [{ type: "text", text }] : [],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createYieldingSubagentSession(onPrompt: () => Promise<void>): AgentSession {
|
||||
const listeners: Array<(event: AgentSessionEvent) => void> = [];
|
||||
const state = { messages: [] as AssistantMessage[] };
|
||||
const emit = (event: AgentSessionEvent) => {
|
||||
for (const listener of listeners) listener(event);
|
||||
};
|
||||
return {
|
||||
state,
|
||||
agent: { state: { systemPrompt: ["test"] } },
|
||||
model: undefined,
|
||||
extensionRunner: undefined,
|
||||
sessionManager: {
|
||||
appendSessionInit: () => {},
|
||||
},
|
||||
getActiveToolNames: () => ["eval", "yield"],
|
||||
setActiveToolsByName: async () => {},
|
||||
subscribe: (listener: (event: AgentSessionEvent) => void) => {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
};
|
||||
},
|
||||
prompt: async (_text: string, _options?: PromptOptions) => {
|
||||
await onPrompt();
|
||||
state.messages.push(assistantStopMessage("done"));
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "yield-call",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Result submitted." }],
|
||||
details: { status: "success", data: { ok: true } },
|
||||
},
|
||||
isError: false,
|
||||
});
|
||||
},
|
||||
waitForIdle: async () => {},
|
||||
getLastAssistantMessage: () => state.messages[state.messages.length - 1],
|
||||
abort: async () => {},
|
||||
dispose: async () => {},
|
||||
} as unknown as AgentSession;
|
||||
}
|
||||
|
||||
const taskAgent: AgentDefinition = {
|
||||
name: "task",
|
||||
description: "Task agent",
|
||||
systemPrompt: "Read eval state and yield.",
|
||||
source: "bundled",
|
||||
tools: ["eval", "yield"],
|
||||
};
|
||||
|
||||
const taskParams: TaskParams = {
|
||||
agent: "task",
|
||||
tasks: [{ id: "ReadEval", description: "Read eval state", assignment: "Read parent eval state." }],
|
||||
};
|
||||
|
||||
describe("shared eval executors", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await disposeAllVmContexts();
|
||||
await disposeAllKernelSessions();
|
||||
});
|
||||
|
||||
it("shares JavaScript state across executeJs calls with one session id", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-shared-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-shared:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
|
||||
await executeJs("globalThis.x = 41;", { sessionId, session, sessionFile });
|
||||
const result = await executeJs("return globalThis.x + 1;", { sessionId, session, sessionFile });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("42");
|
||||
});
|
||||
|
||||
it("treats idleTimeoutMs as an inactivity budget, not a fixed timer", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-idle-budget-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-idle-budget:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
|
||||
// With no wall-clock deadlineMs/timeoutMs and no aborting signal, a cell that
|
||||
// runs well past idleTimeoutMs must still complete: the backend must never
|
||||
// derive a competing fixed timer from the inactivity budget.
|
||||
const result = await executeJs("await Bun.sleep(120); return 'done';", {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
idleTimeoutMs: 30,
|
||||
});
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("done");
|
||||
});
|
||||
|
||||
it("shares Python state across executePython calls with one session id", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-py-shared-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-shared:${crypto.randomUUID()}`;
|
||||
|
||||
await executePython("x = 41", { cwd: tempDir.path(), sessionId, sessionFile });
|
||||
const result = await executePython("print(x + 1)", { cwd: tempDir.path(), sessionId, sessionFile });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("42");
|
||||
});
|
||||
|
||||
it("deduplicates concurrent first JavaScript session acquisition", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-cold-start-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-cold-start:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
executeJs(
|
||||
"globalThis.sharedMarker ??= crypto.randomUUID(); await Bun.sleep(50); return globalThis.sharedMarker;",
|
||||
{
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
},
|
||||
),
|
||||
executeJs("globalThis.sharedMarker ??= crypto.randomUUID(); return globalThis.sharedMarker;", {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
}),
|
||||
]);
|
||||
const third = await executeJs("return globalThis.sharedMarker;", { sessionId, session, sessionFile });
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(third.exitCode).toBe(0);
|
||||
expect(first.output.trim()).toBe(second.output.trim());
|
||||
expect(third.output.trim()).toBe(first.output.trim());
|
||||
});
|
||||
|
||||
it("deduplicates concurrent first Python session acquisition", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-py-cold-start-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-cold-start:${crypto.randomUUID()}`;
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
executePython(
|
||||
`import asyncio, uuid
|
||||
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
|
||||
globals()["shared_marker"] = shared_marker
|
||||
await asyncio.sleep(0.05)
|
||||
print(shared_marker)`,
|
||||
{ cwd: tempDir.path(), sessionId, sessionFile },
|
||||
),
|
||||
executePython(
|
||||
`import uuid
|
||||
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
|
||||
globals()["shared_marker"] = shared_marker
|
||||
print(shared_marker)`,
|
||||
{ cwd: tempDir.path(), sessionId, sessionFile },
|
||||
),
|
||||
]);
|
||||
const third = await executePython("print(shared_marker)", { cwd: tempDir.path(), sessionId, sessionFile });
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(third.exitCode).toBe(0);
|
||||
expect(first.output.trim()).toBe(second.output.trim());
|
||||
expect(third.output.trim()).toBe(first.output.trim());
|
||||
});
|
||||
|
||||
it("splits retained Python kernels by cwd for one shared session id", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-py-cwd-");
|
||||
const dirA = path.join(tempDir.path(), "a");
|
||||
const dirB = path.join(tempDir.path(), "b");
|
||||
await fs.mkdir(dirA);
|
||||
await fs.mkdir(dirB);
|
||||
const realDirA = await fs.realpath(dirA);
|
||||
const realDirB = await fs.realpath(dirB);
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-cwd:${crypto.randomUUID()}`;
|
||||
|
||||
const first = await executePython(
|
||||
`import os
|
||||
token = "from-a"
|
||||
print(os.getcwd())`,
|
||||
{
|
||||
cwd: dirA,
|
||||
sessionId,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
const second = await executePython(
|
||||
`import os
|
||||
print(os.getcwd())
|
||||
print("token" in globals())`,
|
||||
{
|
||||
cwd: dirB,
|
||||
sessionId,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
const third = await executePython("print(token)", { cwd: dirA, sessionId, sessionFile });
|
||||
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(first.output.trim()).toBe(realDirA);
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(second.output.trim().split("\n")).toEqual([realDirB, "False"]);
|
||||
expect(third.exitCode).toBe(0);
|
||||
expect(third.output.trim()).toBe("from-a");
|
||||
});
|
||||
|
||||
it("interrupts timed out synchronous Python cells before they mutate shared state", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-py-sync-timeout-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-sync-timeout:${crypto.randomUUID()}`;
|
||||
|
||||
const timedOut = await executePython("import time\ntime.sleep(0.2)\nleaked_after_timeout = True", {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
timeoutMs: 20,
|
||||
});
|
||||
await Bun.sleep(250);
|
||||
const probe = await executePython('print("leaked_after_timeout" in globals())', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
});
|
||||
|
||||
expect(timedOut.cancelled).toBe(true);
|
||||
expect(probe.exitCode).toBe(0);
|
||||
expect(probe.output.trim()).toBe("False");
|
||||
});
|
||||
|
||||
it("settles Python cells that raise SystemExit", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-py-system-exit-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-system-exit:${crypto.randomUUID()}`;
|
||||
|
||||
const result = await executePython('raise SystemExit("bye")', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.output).toContain("SystemExit");
|
||||
expect(result.output).toContain("bye");
|
||||
});
|
||||
|
||||
it("lets a subagent inherit parent JavaScript and Python eval state", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-subagent-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const evalSessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
|
||||
const parentSession = createToolSession(tempDir.path(), sessionFile, evalSessionId);
|
||||
let seenJs = "";
|
||||
let seenPy = "";
|
||||
let capturedOptions: CreateAgentSessionOptions | undefined;
|
||||
|
||||
await executeJs('globalThis.parentSecret = "hello-js";', {
|
||||
sessionId: `js:${evalSessionId}`,
|
||||
session: parentSession,
|
||||
sessionFile,
|
||||
});
|
||||
await executePython('parent_secret = "hello-py"', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId: `python:${evalSessionId}`,
|
||||
sessionFile,
|
||||
});
|
||||
|
||||
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [taskAgent], projectAgentsDir: null });
|
||||
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async (options = {}) => {
|
||||
capturedOptions = options;
|
||||
const inherited = options.parentEvalSessionId;
|
||||
if (!inherited) throw new Error("Missing parent eval session id");
|
||||
return {
|
||||
session: createYieldingSubagentSession(async () => {
|
||||
const jsResult = await executeJs("return globalThis.parentSecret;", {
|
||||
sessionId: `js:${inherited}`,
|
||||
session: parentSession,
|
||||
sessionFile,
|
||||
});
|
||||
const pyResult = await executePython("print(parent_secret)", {
|
||||
cwd: tempDir.path(),
|
||||
sessionId: `python:${inherited}`,
|
||||
sessionFile,
|
||||
});
|
||||
seenJs = jsResult.output.trim();
|
||||
seenPy = pyResult.output.trim();
|
||||
}),
|
||||
extensionsResult: {} as unknown as LoadExtensionsResult,
|
||||
setToolUIContext: () => {},
|
||||
eventBus: new EventBus(),
|
||||
} satisfies CreateAgentSessionResult;
|
||||
});
|
||||
|
||||
const tool = await TaskTool.create(parentSession);
|
||||
await tool.execute("tool-call", taskParams);
|
||||
|
||||
expect(capturedOptions?.parentEvalSessionId).toBe(evalSessionId);
|
||||
expect(seenJs).toBe("hello-js");
|
||||
expect(seenPy).toBe("hello-py");
|
||||
});
|
||||
|
||||
it("routes interleaved JavaScript display output to the matching run", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-interleave-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-interleave:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
|
||||
const first = executeJs('await Bun.sleep(80); display({ label: "A" });', {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
await Bun.sleep(10);
|
||||
const second = executeJs('display({ label: "B" });', {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult.exitCode).toBe(0);
|
||||
expect(secondResult.exitCode).toBe(0);
|
||||
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
|
||||
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
|
||||
});
|
||||
|
||||
it("routes interleaved Python display output to the matching run", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-py-interleave-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-interleave:${crypto.randomUUID()}`;
|
||||
|
||||
const first = executePython(
|
||||
`import asyncio
|
||||
await asyncio.sleep(0.08)
|
||||
display({"label": "A"})`,
|
||||
{
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
await Bun.sleep(10);
|
||||
const second = executePython('display({"label": "B"})', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult.exitCode).toBe(0);
|
||||
expect(secondResult.exitCode).toBe(0);
|
||||
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
|
||||
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
|
||||
});
|
||||
it("preserves module-level singleton state across re-imports of an unchanged file", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-mtime-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-mtime:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
const modulePath = path.join(tempDir.path(), "singleton.ts");
|
||||
const moduleSpec = JSON.stringify(modulePath);
|
||||
await Bun.write(
|
||||
modulePath,
|
||||
"let value = 0;\nexport function set(v) { value = v; }\nexport function get() { return value; }\n",
|
||||
);
|
||||
|
||||
const initResult = await executeJs(`const mod = await import(${moduleSpec}); mod.set(42); return mod.get();`, {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
expect(initResult.exitCode).toBe(0);
|
||||
expect(initResult.output.trim()).toBe("42");
|
||||
|
||||
// Unchanged file: re-import must reuse the existing module namespace so the
|
||||
// counter is still 42. This is the regression — the previous unconditional
|
||||
// `delete require.cache[target]` reset singletons on every dynamic import.
|
||||
const reuseResult = await executeJs(`const mod = await import(${moduleSpec}); return mod.get();`, {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
expect(reuseResult.exitCode).toBe(0);
|
||||
expect(reuseResult.output.trim()).toBe("42");
|
||||
|
||||
// Bump mtime by 5s to simulate an edit; the next import must evict the cache
|
||||
// and re-evaluate the file, dropping the counter back to its initializer.
|
||||
const future = new Date(Date.now() + 5_000);
|
||||
await fs.utimes(modulePath, future, future);
|
||||
|
||||
const reloadResult = await executeJs(`const mod = await import(${moduleSpec}); return mod.get();`, {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
expect(reloadResult.exitCode).toBe(0);
|
||||
expect(reloadResult.output.trim()).toBe("0");
|
||||
});
|
||||
|
||||
it("reloads a local re-export when a transitive dependency changes", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-transitive-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-transitive:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
const leafPath = path.join(tempDir.path(), "leaf.ts");
|
||||
const entryPath = path.join(tempDir.path(), "entry.ts");
|
||||
const entrySpec = JSON.stringify(entryPath);
|
||||
await Bun.write(leafPath, "export const value = 1;\n");
|
||||
await Bun.write(entryPath, 'export { value } from "./leaf.ts";\n');
|
||||
|
||||
const initial = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
expect(initial.exitCode).toBe(0);
|
||||
expect(initial.output.trim()).toBe("1");
|
||||
|
||||
await Bun.write(leafPath, "export const value = 2;\n");
|
||||
const future = new Date(Date.now() + 5_000);
|
||||
await fs.utimes(leafPath, future, future);
|
||||
|
||||
const reloaded = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
expect(reloaded.exitCode).toBe(0);
|
||||
expect(reloaded.output.trim()).toBe("2");
|
||||
});
|
||||
|
||||
it("links a cyclic local module graph without crashing", async () => {
|
||||
// Regression: the loader used to link()+evaluate() each local module individually
|
||||
// inside the recursive linker callback. On any import cycle that re-entered Bun's
|
||||
// node:vm linker mid-instantiation and segfaulted the process (SIGTRAP,
|
||||
// getImportedModule on a null record) — e.g. `await import("…/edit/streaming.ts")`,
|
||||
// whose relative-import subtree is cyclic. The graph must now link in a single pass.
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-cycle-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-cycle:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
const alphaPath = path.join(tempDir.path(), "alpha.ts");
|
||||
const betaPath = path.join(tempDir.path(), "beta.ts");
|
||||
const alphaSpec = JSON.stringify(alphaPath);
|
||||
const betaSpec = JSON.stringify(betaPath);
|
||||
await Bun.write(
|
||||
alphaPath,
|
||||
'import { betaName } from "./beta.ts";\nexport const alphaName = "alpha";\nexport function combined() { return alphaName + ":" + betaName; }\n',
|
||||
);
|
||||
await Bun.write(
|
||||
betaPath,
|
||||
'import { alphaName } from "./alpha.ts";\nexport const betaName = "beta";\nexport function viaAlpha() { return alphaName; }\n',
|
||||
);
|
||||
|
||||
const result = await executeJs(
|
||||
`const a = await import(${alphaSpec});\nconst b = await import(${betaSpec});\nreturn [a.combined(), b.viaAlpha()].join("|");`,
|
||||
{ sessionId, session, sessionFile },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("alpha:beta|alpha");
|
||||
});
|
||||
|
||||
it("loads TypeScript type-only imports in cells and local modules", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-js-type-imports-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `js-type-imports:${crypto.randomUUID()}`;
|
||||
const session = createToolSession(tempDir.path(), sessionFile);
|
||||
const typesPath = path.join(tempDir.path(), "types.ts");
|
||||
const valuesPath = path.join(tempDir.path(), "values.ts");
|
||||
const entryPath = path.join(tempDir.path(), "entry.ts");
|
||||
const typesSpec = JSON.stringify(typesPath);
|
||||
const entrySpec = JSON.stringify(entryPath);
|
||||
await Bun.write(typesPath, "export interface TypeOnly { value: number }\n");
|
||||
await Bun.write(valuesPath, "export interface InlineOnly { value: number }\nexport const imported = 41;\n");
|
||||
await Bun.write(
|
||||
entryPath,
|
||||
[
|
||||
'import type { TypeOnly } from "./types.ts";',
|
||||
'import { type InlineOnly, imported } from "./values.ts";',
|
||||
"export const typeOnly = 1;",
|
||||
"export const inlineType = imported;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await executeJs(
|
||||
`import type { TypeOnly } from ${typesSpec};\nconst mod = await import(${entrySpec});\nreturn mod.typeOnly + mod.inlineType;`,
|
||||
{
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output.trim()).toBe("42");
|
||||
});
|
||||
|
||||
it("refreshes the Python tool proxy when bridge env appears after kernel warm-up", async () => {
|
||||
using tempDir = TempDir.createSync("@omp-eval-py-tool-proxy-");
|
||||
const sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
const sessionId = `py-tool-proxy:${crypto.randomUUID()}`;
|
||||
const bridgeCalls: unknown[] = [];
|
||||
const bridgeSession = createBridgeToolSession("bridge-ok", bridgeCalls);
|
||||
|
||||
const withoutBridge = await executePython(
|
||||
'try:\n print(tool.read({"path": "foo.txt"}))\nexcept Exception as exc:\n print(type(exc).__name__)\n print(str(exc))',
|
||||
{ cwd: tempDir.path(), sessionId, sessionFile },
|
||||
);
|
||||
const withBridge = await executePython('print(tool.read({"path": "foo.txt"}))', {
|
||||
cwd: tempDir.path(),
|
||||
sessionId,
|
||||
sessionFile,
|
||||
toolSession: bridgeSession,
|
||||
});
|
||||
|
||||
expect(withoutBridge.exitCode).toBe(0);
|
||||
expect(withoutBridge.output).toContain("RuntimeError");
|
||||
expect(withoutBridge.output).toContain("tool bridge is unavailable");
|
||||
expect(withBridge.exitCode).toBe(0);
|
||||
expect(withBridge.output.trim()).toBe("bridge-ok");
|
||||
expect(bridgeCalls).toEqual([{ path: "foo.txt", _i: "py prelude" }]);
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,10 @@ import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.m
|
||||
import * as taskDiscovery from "../task/discovery";
|
||||
import * as taskExecutor from "../task/executor";
|
||||
import { AgentOutputManager } from "../task/output-manager";
|
||||
import type { AgentDefinition, AgentProgress } from "../task/types";
|
||||
import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
|
||||
import type { ToolSession } from "../tools";
|
||||
import { ToolError } from "../tools/tool-errors";
|
||||
import { withBridgeHeartbeat } from "./heartbeat";
|
||||
import { withBridgeTimeoutPause } from "./bridge-timeout";
|
||||
import type { JsStatusEvent } from "./js/shared/types";
|
||||
// Import review tools for side effects (registers subagent tool handlers).
|
||||
import "../tools/review";
|
||||
@@ -173,6 +173,26 @@ function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undef
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce a subagent failure into a non-empty, human-meaningful error message.
|
||||
*
|
||||
* When the executor aborts a subagent (runtime limit, parent cancellation, …)
|
||||
* the actionable explanation lives on `abortReason`, while `error`/`stderr`
|
||||
* are routinely empty strings. Plain `??` coalescing stops at the empty string
|
||||
* and ships an empty error through the bridge — Python then surfaces only the
|
||||
* generic `bridge call '__agent__' failed`. See #2006.
|
||||
*/
|
||||
function buildSubagentFailureMessage(agentName: string, result: SingleResult): string {
|
||||
const abortReason = trimToUndefined(result.abortReason);
|
||||
if (result.aborted && abortReason) return abortReason;
|
||||
return (
|
||||
trimToUndefined(result.error) ??
|
||||
trimToUndefined(result.stderr) ??
|
||||
abortReason ??
|
||||
`agent() subagent '${agentName}' failed.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single subagent on behalf of an eval cell's `agent()` call.
|
||||
*/
|
||||
@@ -225,17 +245,15 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
|
||||
getSessionId: options.session.getSessionId ?? (() => null),
|
||||
};
|
||||
const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
|
||||
const parentEvalSessionId = options.session.getEvalSessionId?.() ?? undefined;
|
||||
const mcpManager = options.session.mcpManager ?? MCPManager.instance();
|
||||
const { sessionFile, artifactsDir, contextFile } = await getArtifacts(options.session);
|
||||
const outputManager = getOutputManager(options.session);
|
||||
const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
|
||||
const assignment = parsed.prompt.trim();
|
||||
const context = trimToUndefined(parsed.context);
|
||||
// Pump a heartbeat while the subagent runs so the eval idle watchdog stays
|
||||
// armed across quiet stretches (time-to-first-token, long nested tools)
|
||||
// where `onProgress` would otherwise emit no status to re-arm it.
|
||||
const result = await withBridgeHeartbeat(options.emitStatus, () =>
|
||||
// Suspend eval timeout accounting while the subagent owns control. The
|
||||
// timeout clock restarts once the bridge returns to the cell runtime.
|
||||
const result = await withBridgeTimeoutPause(options.emitStatus, () =>
|
||||
taskExecutor.runSubprocess({
|
||||
cwd: options.session.cwd,
|
||||
agent: effectiveAgent,
|
||||
@@ -261,6 +279,12 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
|
||||
authStorage: options.session.authStorage,
|
||||
modelRegistry: options.session.modelRegistry,
|
||||
settings: options.session.settings,
|
||||
// Eval `agent()` subagents are never wall-clock capped: the parent
|
||||
// cell's idle watchdog is suspended for the whole bridge call
|
||||
// (withBridgeTimeoutPause), so a long-running phase/recovery workflow
|
||||
// must not be killed by `task.maxRuntimeMs`. Force the limit off
|
||||
// regardless of the inherited session setting.
|
||||
maxRuntimeMs: 0,
|
||||
mcpManager,
|
||||
contextFiles,
|
||||
skills: availableSkills,
|
||||
@@ -272,14 +296,16 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
|
||||
parentHindsightSessionState: options.session.getHindsightSessionState?.(),
|
||||
parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
|
||||
parentTelemetry: options.session.getTelemetry?.(),
|
||||
parentEvalSessionId,
|
||||
// Deliberately omit parentEvalSessionId: the parent's Python kernel is
|
||||
// blocked on this bridge call, so sharing the eval session would deadlock
|
||||
// (subagent queues behind the parent's in-flight execution, parent waits
|
||||
// for subagent → circular). Each bridge-spawned subagent gets its own
|
||||
// eval session with an independent kernel.
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0 || result.error) {
|
||||
const failureMessage =
|
||||
result.error ?? result.stderr ?? result.abortReason ?? `agent() subagent '${agentName}' failed.`;
|
||||
throw new ToolError(failureMessage);
|
||||
if (result.exitCode !== 0 || result.error || result.aborted) {
|
||||
throw new ToolError(buildSubagentFailureMessage(agentName, result));
|
||||
}
|
||||
|
||||
options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
|
||||
|
||||
@@ -10,12 +10,12 @@ export interface ExecutorBackendExecOptions {
|
||||
signal?: AbortSignal;
|
||||
session: ToolSession;
|
||||
/**
|
||||
* Inactivity budget in milliseconds (the cell's `timeout`). Cancellation is
|
||||
* driven entirely by `signal`, which the eval tool arms as an idle watchdog
|
||||
* that fires a `TimeoutError` reason after this much time with no progress
|
||||
* (status) events. Backends use this value only for timeout-annotation text
|
||||
* and as cold-start headroom; they MUST NOT derive a competing wall-clock
|
||||
* timer from it.
|
||||
* Runtime-work budget in milliseconds (the cell's `timeout`). Cancellation is
|
||||
* driven entirely by `signal`, which the eval tool arms as a watchdog that
|
||||
* pauses on bridge timeout-control status events and fires a `TimeoutError`
|
||||
* reason only while the Python/JS runtime owns control. Backends use this
|
||||
* value only for timeout-annotation text and as cold-start headroom; they MUST
|
||||
* NOT derive a competing wall-clock timer from it.
|
||||
*/
|
||||
idleTimeoutMs: number;
|
||||
reset: boolean;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Timeout suspension for in-flight host-side eval bridge calls.
|
||||
*
|
||||
* The eval watchdog caps a cell's `timeout` as a budget on the cell runtime's
|
||||
* own work. Host-side `agent()` / `parallel()` / `llm()` bridge calls hand
|
||||
* control to the outer TypeScript process, where the Python kernel or JS VM is
|
||||
* only waiting for a result. While that delegated work is in flight, the cell
|
||||
* timeout must be ignored completely; once the bridge returns and the runtime is
|
||||
* back in control, the watchdog starts a fresh timeout window.
|
||||
*
|
||||
* Bridge helpers express that handoff with synthetic pause/resume status events
|
||||
* on the existing `emitStatus → onStatus` path. Consumers MUST treat these as
|
||||
* timeout-control events only: update the watchdog and drop them from rendered
|
||||
* or persisted cell output.
|
||||
*/
|
||||
import type { JsStatusEvent } from "./js/shared/types";
|
||||
|
||||
/** Synthetic status op emitted when a bridge call leaves the cell runtime. */
|
||||
export const EVAL_TIMEOUT_PAUSE_OP = "timeout-pause";
|
||||
|
||||
/** Synthetic status op emitted when a bridge call returns control to the runtime. */
|
||||
export const EVAL_TIMEOUT_RESUME_OP = "timeout-resume";
|
||||
|
||||
/** Whether a status event is pure eval-timeout control and should not render. */
|
||||
export function isEvalTimeoutControlEvent(event: JsStatusEvent): boolean {
|
||||
return event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@link operation} while suspending the eval watchdog through
|
||||
* {@link emitStatus}. A no-op wrapper when no status sink is wired.
|
||||
*/
|
||||
export async function withBridgeTimeoutPause<T>(
|
||||
emitStatus: ((event: JsStatusEvent) => void) | undefined,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!emitStatus) return operation();
|
||||
emitStatus({ op: EVAL_TIMEOUT_PAUSE_OP });
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
emitStatus({ op: EVAL_TIMEOUT_RESUME_OP });
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Keepalive for in-flight host-side eval bridge calls.
|
||||
*
|
||||
* The eval watchdog ({@link ../tools/eval IdleTimeout}) caps a cell's `timeout`
|
||||
* as a wall-clock budget on the cell's *own* work, but pauses that budget while
|
||||
* a host-side `agent()`/`parallel()` (via `runSubprocess`) or `llm()` (a single
|
||||
* completion) call is in flight. Those calls are the only thing that re-arms the
|
||||
* watchdog — and they can run for long stretches with **no** status of their own
|
||||
* (a subagent's time-to-first-token on a reasoning model, a long quiet nested
|
||||
* tool, or the entire body of a oneshot `llm()` call). Without a keepalive the
|
||||
* watchdog would mistake that delegated work for the cell stalling and abort it
|
||||
* mid-flight, killing the subagent.
|
||||
*
|
||||
* {@link withBridgeHeartbeat} bridges that gap by emitting a synthetic
|
||||
* {@link EVAL_HEARTBEAT_OP} status event immediately when the call begins and
|
||||
* then on a fixed cadence until it settles. The event rides the same
|
||||
* `emitStatus → onStatus` channel both runtimes already forward, so it re-arms
|
||||
* the watchdog without any new plumbing. The heartbeat is the *sole* signal that
|
||||
* extends the budget: consumers MUST treat it as a pure keepalive — bump the
|
||||
* watchdog and drop it (never persist or render it) — see the executor display
|
||||
* sinks and the eval tool's `onStatus` handler. Every other status event
|
||||
* (compute helpers, `log()`/`phase()`, tool results) counts against the budget.
|
||||
*/
|
||||
import type { JsStatusEvent } from "./js/shared/types";
|
||||
|
||||
/**
|
||||
* Synthetic status op emitted purely to keep the eval idle watchdog alive while
|
||||
* a host-side bridge call is in flight. Carries no payload.
|
||||
*/
|
||||
export const EVAL_HEARTBEAT_OP = "heartbeat";
|
||||
|
||||
/**
|
||||
* Heartbeat cadence. Comfortably below the default 30s idle budget (and the
|
||||
* larger budgets long fanouts run under), so a working bridge call always bumps
|
||||
* the watchdog before it expires, while a genuine stall is still bounded once
|
||||
* the call settles and the heartbeat stops.
|
||||
*/
|
||||
const HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
|
||||
let heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS;
|
||||
|
||||
/**
|
||||
* Test seam: override the heartbeat cadence so integration tests can exercise
|
||||
* the keepalive within a sub-second idle budget. Pass no value to restore the
|
||||
* production default.
|
||||
*/
|
||||
export function setBridgeHeartbeatIntervalMs(ms?: number): void {
|
||||
heartbeatIntervalMs = ms === undefined ? HEARTBEAT_INTERVAL_MS : Math.max(1, Math.floor(ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@link operation}, pumping {@link EVAL_HEARTBEAT_OP} status events through
|
||||
* {@link emitStatus} — one immediately, then on a fixed cadence — until it
|
||||
* settles. The immediate beat pauses the watchdog the instant the call begins,
|
||||
* so a bridge call that starts close to the budget edge (after the cell already
|
||||
* spent most of it computing) is not aborted before the first interval tick. A
|
||||
* no-op wrapper when no `emitStatus` sink is wired (the heartbeat would reach
|
||||
* nobody).
|
||||
*/
|
||||
export async function withBridgeHeartbeat<T>(
|
||||
emitStatus: ((event: JsStatusEvent) => void) | undefined,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!emitStatus) return operation();
|
||||
emitStatus({ op: EVAL_HEARTBEAT_OP });
|
||||
const timer = setInterval(() => emitStatus({ op: EVAL_HEARTBEAT_OP }), heartbeatIntervalMs);
|
||||
// Never keep the event loop alive for the heartbeat alone.
|
||||
timer.unref?.();
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
/**
|
||||
* Inactivity watchdog for eval cells.
|
||||
* Watchdog for eval cell work.
|
||||
*
|
||||
* A cell's `timeout` is treated as an *idle* budget rather than a hard
|
||||
* wall-clock deadline: the watchdog aborts {@link signal} (with a
|
||||
* `TimeoutError` reason, matching `AbortSignal.timeout`) only once `idleMs`
|
||||
* elapses with no {@link bump}. Every progress signal re-arms it, so a
|
||||
* long-running fanout that keeps reporting progress (e.g. `agent()` status
|
||||
* updates, `log()`/`phase()`) never trips the timeout, while a genuinely
|
||||
* stalled cell still gets interrupted.
|
||||
* A cell's `timeout` bounds time while the Python kernel or JS VM is in control.
|
||||
* Host-side bridge calls can {@link pause} the watchdog so delegated
|
||||
* `agent()`/`parallel()`/`llm()` work is ignored completely, then {@link resume}
|
||||
* starts a fresh timeout window once the runtime gets control back.
|
||||
*
|
||||
* The timer self-reschedules instead of being torn down and recreated on every
|
||||
* bump, so a high-frequency stream of bumps (sub-second agent progress) costs
|
||||
* one timestamp write per event rather than churning a timer each time.
|
||||
* The active timer self-reschedules instead of being torn down on every
|
||||
* activity event, so frequent activity costs one timestamp write per event.
|
||||
* Pause is reference-counted because `parallel()` can have multiple bridge calls
|
||||
* in flight at once.
|
||||
*/
|
||||
export class IdleTimeout {
|
||||
readonly #controller = new AbortController();
|
||||
@@ -20,6 +18,7 @@ export class IdleTimeout {
|
||||
#deadlineMs: number;
|
||||
#timer: NodeJS.Timeout | undefined;
|
||||
#settled = false;
|
||||
#pauseDepth = 0;
|
||||
|
||||
constructor(idleMs: number) {
|
||||
this.#idleMs = Math.max(1, Math.floor(idleMs));
|
||||
@@ -27,21 +26,40 @@ export class IdleTimeout {
|
||||
this.#arm(this.#idleMs);
|
||||
}
|
||||
|
||||
/** Aborts with a `TimeoutError` reason once the inactivity budget is exhausted. */
|
||||
/** Aborts with a `TimeoutError` reason once the active timeout window is exhausted. */
|
||||
get signal(): AbortSignal {
|
||||
return this.#controller.signal;
|
||||
}
|
||||
|
||||
/** Configured inactivity budget in milliseconds. */
|
||||
/** Configured active timeout window in milliseconds. */
|
||||
get idleMs(): number {
|
||||
return this.#idleMs;
|
||||
}
|
||||
|
||||
/** Record activity, pushing the inactivity deadline forward by `idleMs`. */
|
||||
/** Record runtime activity, pushing the active deadline forward by `idleMs`. */
|
||||
bump(): void {
|
||||
if (this.#settled) return;
|
||||
if (this.#settled || this.#pauseDepth > 0) return;
|
||||
this.#deadlineMs = Date.now() + this.#idleMs;
|
||||
}
|
||||
/** Suspend timeout accounting while control is delegated to host-side work. */
|
||||
pause(): void {
|
||||
if (this.#settled) return;
|
||||
this.#pauseDepth++;
|
||||
if (this.#pauseDepth !== 1) return;
|
||||
if (this.#timer) {
|
||||
clearTimeout(this.#timer);
|
||||
this.#timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume timeout accounting with a fresh timeout window. */
|
||||
resume(): void {
|
||||
if (this.#settled || this.#pauseDepth === 0) return;
|
||||
this.#pauseDepth--;
|
||||
if (this.#pauseDepth > 0) return;
|
||||
this.#deadlineMs = Date.now() + this.#idleMs;
|
||||
this.#arm(this.#idleMs);
|
||||
}
|
||||
|
||||
/** Stop the watchdog. Safe to call multiple times. */
|
||||
dispose(): void {
|
||||
@@ -65,7 +83,7 @@ export class IdleTimeout {
|
||||
}
|
||||
|
||||
#onExpire(): void {
|
||||
if (this.#settled) return;
|
||||
if (this.#settled || this.#pauseDepth > 0) return;
|
||||
const remainingMs = this.#deadlineMs - Date.now();
|
||||
if (remainingMs > 0) {
|
||||
// A bump moved the deadline forward after this timer was armed; wait
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
|
||||
import { EVAL_HEARTBEAT_OP } from "../heartbeat";
|
||||
import { isEvalTimeoutControlEvent } from "../bridge-timeout";
|
||||
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
|
||||
import type { JsStatusEvent } from "./shared/types";
|
||||
|
||||
@@ -10,9 +10,9 @@ export interface JsExecutorOptions {
|
||||
timeoutMs?: number;
|
||||
deadlineMs?: number;
|
||||
/**
|
||||
* Inactivity budget (ms). Used for worker cold-start headroom and
|
||||
* timeout-annotation text when the caller drives cancellation via an
|
||||
* idle-aware `signal` instead of `deadlineMs`/`timeoutMs`. Never arms a timer.
|
||||
* Runtime-work budget (ms). Used for worker cold-start headroom and
|
||||
* timeout-annotation text when the caller drives cancellation via the eval
|
||||
* watchdog `signal` instead of `deadlineMs`/`timeoutMs`. Never arms a timer.
|
||||
*/
|
||||
idleTimeoutMs?: number;
|
||||
onChunk?: (chunk: string) => Promise<void> | void;
|
||||
@@ -85,9 +85,9 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
|
||||
options.signal && timeoutSignal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: (options.signal ?? timeoutSignal);
|
||||
// The eval tool drives cancellation via an idle-aware `signal` and passes only
|
||||
// an inactivity budget; use it solely as worker cold-start headroom and never
|
||||
// derive a competing fixed timer from it.
|
||||
// The eval tool drives cancellation via its own watchdog `signal` and passes
|
||||
// only the runtime-work budget; use it solely as worker cold-start headroom
|
||||
// and never derive a competing fixed timer from it.
|
||||
const acquireBudgetMs = legacyTimeoutMs ?? options.idleTimeoutMs;
|
||||
|
||||
try {
|
||||
@@ -105,10 +105,10 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
|
||||
onText: chunk => outputSink.push(chunk),
|
||||
onDisplay: output => {
|
||||
if (output.type === "status") {
|
||||
// Heartbeats are pure idle-watchdog keepalives: forward them so
|
||||
// the eval tool re-arms its timer, but never store or render them.
|
||||
// Timeout-control events drive the eval watchdog only; never
|
||||
// store or render them as cell output.
|
||||
options.onStatus?.(output.event);
|
||||
if (output.event.op === EVAL_HEARTBEAT_OP) return;
|
||||
if (isEvalTimeoutControlEvent(output.event)) return;
|
||||
}
|
||||
displayOutputs.push(output);
|
||||
},
|
||||
|
||||
@@ -15,10 +15,11 @@ import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent
|
||||
import { type Api, Effort, getSupportedEfforts, type Model, type Tool } from "@oh-my-pi/pi-ai";
|
||||
import * as z from "zod/v4";
|
||||
import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils";
|
||||
|
||||
import { expandRoleAlias, formatModelString, resolveModelFromString } from "../config/model-resolver";
|
||||
import type { ToolSession } from "../tools";
|
||||
import { ToolError } from "../tools/tool-errors";
|
||||
import { withBridgeHeartbeat } from "./heartbeat";
|
||||
import { withBridgeTimeoutPause } from "./bridge-timeout";
|
||||
import type { JsStatusEvent } from "./js/shared/types";
|
||||
|
||||
/** Synthetic bridge name reserved for the `llm()` helper across both runtimes. */
|
||||
@@ -112,8 +113,9 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = await options.session.modelRegistry?.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
const registry = options.session.modelRegistry;
|
||||
const apiKey = await registry?.getApiKey(model);
|
||||
if (!registry || !apiKey) {
|
||||
throw new ToolError(
|
||||
`llm() has no API key for ${formatModelString(model)}. Configure credentials for this provider or choose another tier.`,
|
||||
);
|
||||
@@ -132,10 +134,9 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
|
||||
const telemetry = resolveTelemetry(options.session.getTelemetry?.(), options.session.getSessionId?.() ?? undefined);
|
||||
|
||||
// A oneshot completion emits no status until it returns, so pump a heartbeat
|
||||
// while it runs to keep the eval idle watchdog armed across a slow (e.g.
|
||||
// reasoning-tier) request that would otherwise look like a stalled cell.
|
||||
const response = await withBridgeHeartbeat(options.emitStatus, () =>
|
||||
// Suspend eval timeout accounting while the model request owns control. The
|
||||
// timeout clock restarts once the bridge returns to the cell runtime.
|
||||
const response = await withBridgeTimeoutPause(options.emitStatus, () =>
|
||||
instrumentedCompleteSimple(
|
||||
model,
|
||||
{
|
||||
@@ -144,7 +145,10 @@ export async function runEvalLlm(args: unknown, options: EvalLlmBridgeOptions):
|
||||
tools,
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
apiKey: registry.resolver(model.provider, {
|
||||
sessionId: options.session.getSessionId?.() ?? undefined,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
signal: options.signal,
|
||||
reasoning: reasoningForTier(tier, model),
|
||||
toolChoice: schema ? { type: "tool", name: STRUCTURED_TOOL_NAME } : undefined,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Settings } from "../../config/settings";
|
||||
import { OutputSink } from "../../session/streaming-output";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
|
||||
import { EVAL_HEARTBEAT_OP } from "../heartbeat";
|
||||
import { isEvalTimeoutControlEvent } from "../bridge-timeout";
|
||||
import type { JsStatusEvent } from "../js/shared/types";
|
||||
import {
|
||||
checkPythonKernelAvailability,
|
||||
@@ -27,8 +27,8 @@ export interface PythonExecutorOptions {
|
||||
/** Absolute wall-clock deadline in milliseconds since epoch */
|
||||
deadlineMs?: number;
|
||||
/**
|
||||
* Inactivity budget (ms). Used only for timeout-annotation text when the
|
||||
* caller drives cancellation via an idle-aware `signal` instead of a
|
||||
* Runtime-work budget (ms). Used only for timeout-annotation text when the
|
||||
* caller drives cancellation via the eval watchdog `signal` instead of a
|
||||
* wall-clock `deadlineMs`/`timeoutMs`. Does not arm a timer.
|
||||
*/
|
||||
idleTimeoutMs?: number;
|
||||
@@ -492,10 +492,10 @@ async function executeWithKernel(
|
||||
// long-running bridge helpers (e.g. `agent()`) surface progress mid-cell.
|
||||
const collectDisplay = (output: KernelDisplayOutput) => {
|
||||
if (output.type === "status") {
|
||||
// Heartbeats are pure idle-watchdog keepalives: forward them so the
|
||||
// eval tool re-arms its timer, but never store or render them.
|
||||
// Timeout-control events drive the eval watchdog only; never store or
|
||||
// render them as cell output.
|
||||
options?.onStatus?.(output.event);
|
||||
if (output.event.op === EVAL_HEARTBEAT_OP) return;
|
||||
if (isEvalTimeoutControlEvent(output.event)) return;
|
||||
}
|
||||
displayOutputs.push(output);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { type KernelDisplayOutput, renderKernelDisplay } from "./display";
|
||||
import { PYTHON_PRELUDE } from "./prelude";
|
||||
import RUNNER_SCRIPT from "./runner.py" with { type: "text" };
|
||||
import { enumeratePythonRuntimes, filterEnv, type PythonRuntime, resolvePythonRuntime } from "./runtime";
|
||||
import { hostHasInheritableConsole, shouldHideKernelWindow } from "./spawn-options";
|
||||
|
||||
export type { KernelDisplayOutput, PythonStatusEvent } from "./display";
|
||||
export { renderKernelDisplay } from "./display";
|
||||
@@ -253,7 +254,16 @@ export class PythonKernel {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
// Detached from any inherited console only when the host itself
|
||||
// has no console — kernel32!GetConsoleWindow() is authoritative
|
||||
// (works even when every stdio stream is redirected), with a
|
||||
// TTY-OR fallback when the FFI probe is unavailable. See #1960
|
||||
// for the numpy/pandas LoadLibraryExW hang + SIGINT-recovery
|
||||
// failure that motivates the predicate.
|
||||
windowsHide: shouldHideKernelWindow({
|
||||
platform: process.platform,
|
||||
hostHasInheritableConsole: hostHasInheritableConsole(),
|
||||
}),
|
||||
});
|
||||
kernel.#proc = proc;
|
||||
kernel.#stdin = proc.stdin;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Subprocess spawn-option helpers for the Python kernel.
|
||||
*
|
||||
* Pure helpers (`shouldHideKernelWindow`, `consoleAttachedViaTTY`) live here
|
||||
* so they can be unit-tested without dragging in the kernel's runtime
|
||||
* dependencies. The effectful `hostHasInheritableConsole` wraps a Win32 FFI
|
||||
* probe with a TTY fallback and is the function `kernel.ts` actually calls.
|
||||
*/
|
||||
import { dlopen, FFIType } from "bun:ffi";
|
||||
|
||||
/**
|
||||
* Decide whether the long-lived Python kernel subprocess should be spawned
|
||||
* with `windowsHide: true`.
|
||||
*
|
||||
* On Windows, Bun maps `windowsHide: true` to the `CREATE_NO_WINDOW` flag,
|
||||
* which detaches the child from any inherited console. The Python kernel
|
||||
* runs user code that imports NumPy/pandas; those native extensions
|
||||
* (`numpy/_core/_multiarray_umath.pyd` + bundled OpenBLAS/SLEEF thread-pool
|
||||
* init) can deadlock inside `LoadLibraryExW` when no console is attached,
|
||||
* and a console-less child cannot receive SIGINT via
|
||||
* `GenerateConsoleCtrlEvent` (the recovery path the host relies on). See
|
||||
* issue #1960.
|
||||
*
|
||||
* So on Windows we hide only when the host itself has no console to share.
|
||||
* In any launch where a console is attached — even one with every stdio
|
||||
* stream redirected — the kernel inherits the parent's console, matching
|
||||
* `python.exe` invoked from `cmd.exe`, which keeps native imports and
|
||||
* SIGINT recovery working.
|
||||
*
|
||||
* Short-lived helper subprocesses elsewhere in the codebase (LSP probes,
|
||||
* git, plugin installs) keep `windowsHide: true` because they don't load
|
||||
* complex native modules and the brief console flash would be user-visible
|
||||
* noise.
|
||||
*/
|
||||
export function shouldHideKernelWindow(opts: {
|
||||
platform: NodeJS.Platform;
|
||||
hostHasInheritableConsole: boolean;
|
||||
}): boolean {
|
||||
if (opts.platform !== "win32") return false;
|
||||
return !opts.hostHasInheritableConsole;
|
||||
}
|
||||
|
||||
/**
|
||||
* TTY-based fallback used when the Win32 console probe is unavailable.
|
||||
*
|
||||
* Returns `true` if any of stdin/stdout/stderr is currently a TTY. This
|
||||
* correctly detects the common interactive launches and the partial-
|
||||
* redirection cases (`omp -p > out.txt`, `< in.txt`, `2> err.log`) where at
|
||||
* least one stream stays bound to the terminal. The all-stdio-redirected
|
||||
* case (`< in > out 2> err` from a console) is the reason we prefer the
|
||||
* Win32 probe over this fallback whenever possible.
|
||||
*/
|
||||
export function consoleAttachedViaTTY(opts: {
|
||||
stdinIsTTY: boolean;
|
||||
stdoutIsTTY: boolean;
|
||||
stderrIsTTY: boolean;
|
||||
}): boolean {
|
||||
return opts.stdinIsTTY || opts.stdoutIsTTY || opts.stderrIsTTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe `kernel32.dll!GetConsoleWindow()` to detect whether the current
|
||||
* Windows process owns a console window.
|
||||
*
|
||||
* Returns `true` for a non-NULL HWND, `false` when NULL (no console — true
|
||||
* service / `DETACHED_PROCESS` / GUI parent), and `null` when the probe
|
||||
* itself fails (off-Windows, FFI disabled, or unexpected kernel32 layout).
|
||||
* A `null` return means "don't trust me, use the TTY fallback".
|
||||
*
|
||||
* Cached on first call because in practice the console attachment of a
|
||||
* long-lived OMP host never changes for the lifetime of the process, and
|
||||
* we don't want to re-dlopen kernel32 on every kernel spawn.
|
||||
*/
|
||||
type ConsoleProbeResult = boolean | null;
|
||||
let cachedWindowsConsoleProbe: { value: ConsoleProbeResult } | undefined;
|
||||
|
||||
function probeWindowsConsoleWindow(): ConsoleProbeResult {
|
||||
if (cachedWindowsConsoleProbe) return cachedWindowsConsoleProbe.value;
|
||||
let value: ConsoleProbeResult = null;
|
||||
try {
|
||||
const lib = dlopen("kernel32.dll", {
|
||||
GetConsoleWindow: { args: [], returns: FFIType.ptr },
|
||||
});
|
||||
try {
|
||||
const hwnd = lib.symbols.GetConsoleWindow();
|
||||
// FFIType.ptr returns `Pointer | null`; a 0 pointer should also be
|
||||
// treated as NULL defensively in case Bun ever returns 0n / 0.
|
||||
value = hwnd !== null && hwnd !== 0;
|
||||
} finally {
|
||||
lib.close();
|
||||
}
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
cachedWindowsConsoleProbe = { value };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Reset the cached Win32 probe result. Test-only; not part of the public surface. */
|
||||
export function __resetWindowsConsoleProbeCache(): void {
|
||||
cachedWindowsConsoleProbe = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the host process owns a console its children can inherit.
|
||||
*
|
||||
* - On Windows, the authoritative signal is `GetConsoleWindow()`. It returns
|
||||
* a non-NULL HWND whenever the process has a console attached, regardless
|
||||
* of how the standard streams are redirected — so an `omp -p ... < in.txt
|
||||
* > out.txt 2> err.log` launched from a real Windows Terminal session is
|
||||
* correctly classified as console-attached and the kernel keeps its
|
||||
* inheritable console.
|
||||
* - On any other platform, or if the FFI probe fails, fall back to the
|
||||
* TTY-OR heuristic. That still catches the common interactive cases.
|
||||
*/
|
||||
export function hostHasInheritableConsole(): boolean {
|
||||
if (process.platform === "win32") {
|
||||
const native = probeWindowsConsoleWindow();
|
||||
if (native !== null) return native;
|
||||
}
|
||||
return consoleAttachedViaTTY({
|
||||
stdinIsTTY: !!process.stdin.isTTY,
|
||||
stdoutIsTTY: !!process.stdout.isTTY,
|
||||
stderrIsTTY: !!process.stderr.isTTY,
|
||||
});
|
||||
}
|
||||
@@ -294,6 +294,9 @@ export class TtsrManager {
|
||||
|
||||
/** Add a TTSR rule to be monitored. */
|
||||
addRule(rule: Rule): boolean {
|
||||
if (!this.#settings.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (this.#rules.has(rule.name)) {
|
||||
return false;
|
||||
}
|
||||
@@ -357,6 +360,9 @@ export class TtsrManager {
|
||||
}
|
||||
|
||||
#matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
|
||||
if (!this.#settings.enabled) {
|
||||
return [];
|
||||
}
|
||||
const matches: Rule[] = [];
|
||||
for (const [name, entry] of this.#rules) {
|
||||
if (!this.#canTrigger(name)) {
|
||||
@@ -433,6 +439,9 @@ export class TtsrManager {
|
||||
|
||||
/** Check if any TTSR rules are registered. */
|
||||
hasRules(): boolean {
|
||||
if (!this.#settings.enabled) {
|
||||
return false;
|
||||
}
|
||||
return this.#rules.size > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,35 @@ async function getHeadTag(api: CustomCommandAPI): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
|
||||
try {
|
||||
return (await git.branch.current(api.cwd)) ?? "HEAD";
|
||||
} catch {
|
||||
return "HEAD";
|
||||
}
|
||||
}
|
||||
|
||||
async function getPushRemote(api: CustomCommandAPI, branch: string): Promise<string | undefined> {
|
||||
try {
|
||||
return (
|
||||
(await git.config.getBranch(api.cwd, branch, "pushRemote")) ??
|
||||
(await git.config.getBranch(api.cwd, branch, "remote"))
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getHeadTagContext(api: CustomCommandAPI): Promise<{ branch: string; headTag?: string; remote: string }> {
|
||||
const branch = await getCurrentBranch(api);
|
||||
const [headTag, pushRemote] = await Promise.all([getHeadTag(api), getPushRemote(api, branch)]);
|
||||
return {
|
||||
headTag,
|
||||
branch,
|
||||
remote: pushRemote ?? "origin",
|
||||
};
|
||||
}
|
||||
|
||||
export class GreenCommand implements CustomCommand {
|
||||
name = "green";
|
||||
description = "Generate a prompt to iterate on CI failures until the branch is green";
|
||||
@@ -19,7 +48,7 @@ export class GreenCommand implements CustomCommand {
|
||||
constructor(private api: CustomCommandAPI) {}
|
||||
|
||||
async execute(_args: string[], _ctx: HookCommandContext): Promise<string> {
|
||||
const headTag = await getHeadTag(this.api);
|
||||
return prompt.render(ciGreenRequestTemplate, { headTag });
|
||||
const { headTag, branch, remote } = await getHeadTagContext(this.api);
|
||||
return prompt.render(ciGreenRequestTemplate, { headTag, branch, remote });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +354,9 @@ export class ExtensionRunner {
|
||||
"ctrl+o": true,
|
||||
"ctrl+t": true,
|
||||
"ctrl+g": true,
|
||||
"alt+m": true,
|
||||
// Default chord for `app.message.followUp` (Windows Terminal can't deliver Ctrl+Enter; #1903).
|
||||
"ctrl+q": true,
|
||||
"shift+tab": true,
|
||||
"shift+ctrl+p": true,
|
||||
"alt+enter": true,
|
||||
|
||||
@@ -25,7 +25,6 @@ export async function runDoctorChecks(): Promise<DoctorCheck[]> {
|
||||
const apiKeys = [
|
||||
{ name: "ANTHROPIC_API_KEY", description: "Anthropic API" },
|
||||
{ name: "OPENAI_API_KEY", description: "OpenAI API" },
|
||||
{ name: "PERPLEXITY_API_KEY", description: "Perplexity search" },
|
||||
{ name: "EXA_API_KEY", description: "Exa search" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
type MarketplaceAutoUpdateMode = "off" | "notify" | "auto";
|
||||
|
||||
interface MarketplaceAutoUpdateOptions {
|
||||
autoUpdate: MarketplaceAutoUpdateMode;
|
||||
resolveActiveProjectRegistryPath: (cwd: string) => Promise<string | null>;
|
||||
clearPluginRootsCache: () => void;
|
||||
}
|
||||
|
||||
export function scheduleMarketplaceAutoUpdate(options: MarketplaceAutoUpdateOptions): void {
|
||||
if (options.autoUpdate === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
void runMarketplaceAutoUpdate(options);
|
||||
}
|
||||
|
||||
async function runMarketplaceAutoUpdate(options: MarketplaceAutoUpdateOptions): Promise<void> {
|
||||
try {
|
||||
// Startup perf: marketplace manager pulls scraper/fetch/cache code; keep it out of the initial TUI graph.
|
||||
const {
|
||||
MarketplaceManager,
|
||||
getInstalledPluginsRegistryPath,
|
||||
getMarketplacesCacheDir,
|
||||
getMarketplacesRegistryPath,
|
||||
getPluginsCacheDir,
|
||||
} = await import("./marketplace");
|
||||
const mgr = new MarketplaceManager({
|
||||
marketplacesRegistryPath: getMarketplacesRegistryPath(),
|
||||
installedRegistryPath: getInstalledPluginsRegistryPath(),
|
||||
projectInstalledRegistryPath: (await options.resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
|
||||
marketplacesCacheDir: getMarketplacesCacheDir(),
|
||||
pluginsCacheDir: getPluginsCacheDir(),
|
||||
clearPluginRootsCache: options.clearPluginRootsCache,
|
||||
});
|
||||
await mgr.refreshStaleMarketplaces();
|
||||
const updates = await mgr.checkForUpdates();
|
||||
if (updates.length === 0) return;
|
||||
if (options.autoUpdate === "auto") {
|
||||
await mgr.upgradeAllPlugins();
|
||||
logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s)`);
|
||||
} else {
|
||||
logger.debug(`${updates.length} marketplace plugin update(s) available — /marketplace upgrade`);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — network failure, corrupt data, offline.
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import type { Theme, ThemeColor } from "../../modes/theme/theme";
|
||||
import goalDescription from "../../prompts/tools/goal.md" with { type: "text" };
|
||||
import { formatDuration } from "../../slash-commands/helpers/format";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { formatErrorMessage, TRUNCATE_LENGTHS } from "../../tools/render-utils";
|
||||
import { formatErrorDetail, TRUNCATE_LENGTHS } from "../../tools/render-utils";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { renderStatusLine, truncateToWidth } from "../../tui";
|
||||
import { framedBlock, renderStatusLine, truncateToWidth } from "../../tui";
|
||||
import { completionBudgetReport, remainingTokens } from "../runtime";
|
||||
import type { Goal, GoalStatus, GoalToolDetails } from "../state";
|
||||
|
||||
@@ -173,8 +173,7 @@ export const goalToolRenderer = {
|
||||
if (args.op === "create" && args.token_budget !== undefined) {
|
||||
meta.push(`budget ${formatNumber(args.token_budget)}`);
|
||||
}
|
||||
const text = renderStatusLine({ icon: "pending", title: "Goal", description, meta }, uiTheme);
|
||||
return new Text(text, 0, 0);
|
||||
return new Text(renderStatusLine({ icon: "pending", title: "Goal", description, meta }, uiTheme), 0, 0);
|
||||
},
|
||||
|
||||
renderResult(
|
||||
@@ -190,51 +189,62 @@ export const goalToolRenderer = {
|
||||
|
||||
if (result.isError) {
|
||||
const header = renderStatusLine({ icon: "error", title: "Goal", description }, uiTheme);
|
||||
const body = formatErrorMessage(fallbackText || "Goal tool failed", uiTheme);
|
||||
return new Text([header, body].join("\n"), 0, 0);
|
||||
return framedBlock(uiTheme, width => ({
|
||||
header,
|
||||
sections: [{ lines: formatErrorDetail(fallbackText || "Goal tool failed", uiTheme).split("\n") }],
|
||||
state: "error",
|
||||
borderColor: "error",
|
||||
width,
|
||||
}));
|
||||
}
|
||||
|
||||
const goal = details?.goal ?? null;
|
||||
if (!goal) {
|
||||
const header = renderStatusLine({ icon: "warning", title: "Goal", description }, uiTheme);
|
||||
const body = uiTheme.fg("muted", "No active goal.");
|
||||
return new Text([header, body].join("\n"), 0, 0);
|
||||
return new Text(
|
||||
renderStatusLine({ icon: "warning", title: "Goal", description, meta: ["no active goal"] }, uiTheme),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
renderStatusLine(
|
||||
{
|
||||
icon: "success",
|
||||
title: "Goal",
|
||||
description,
|
||||
badge: { label: goal.status, color: goalBadgeColor(goal.status) },
|
||||
},
|
||||
uiTheme,
|
||||
),
|
||||
const header = renderStatusLine(
|
||||
{
|
||||
icon: "success",
|
||||
title: "Goal",
|
||||
description,
|
||||
badge: { label: goal.status, color: goalBadgeColor(goal.status) },
|
||||
},
|
||||
uiTheme,
|
||||
);
|
||||
|
||||
const lines: string[] = [];
|
||||
const objectiveText = truncateToWidth(goal.objective.trim(), TRUNCATE_LENGTHS.LONG);
|
||||
lines.push(` ${uiTheme.italic(uiTheme.fg("muted", `"${objectiveText}"`))}`);
|
||||
lines.push(uiTheme.italic(uiTheme.fg("muted", `"${objectiveText}"`)));
|
||||
|
||||
const used = formatNumber(goal.tokensUsed);
|
||||
const tokensLine =
|
||||
goal.tokenBudget !== undefined
|
||||
? `${used} / ${formatNumber(goal.tokenBudget)} tokens (${formatNumber(Math.max(0, goal.tokenBudget - goal.tokensUsed))} left)`
|
||||
: `${used} tokens`;
|
||||
lines.push(` ${uiTheme.fg("dim", tokensLine)}`);
|
||||
|
||||
const metaParts = [tokensLine];
|
||||
if (goal.timeUsedSeconds > 0) {
|
||||
lines.push(` ${uiTheme.fg("dim", `${formatDuration(goal.timeUsedSeconds * 1000)} elapsed`)}`);
|
||||
metaParts.push(`${formatDuration(goal.timeUsedSeconds * 1000)} elapsed`);
|
||||
}
|
||||
lines.push(uiTheme.fg("dim", metaParts.join(" · ")));
|
||||
|
||||
const report = details?.completionBudgetReport;
|
||||
const sections: Array<{ label?: string; lines: string[] }> = [{ lines }];
|
||||
if (report) {
|
||||
lines.push("");
|
||||
lines.push(uiTheme.italic(uiTheme.fg("muted", report)));
|
||||
sections.push({ label: "Report", lines: report.split("\n").map(line => uiTheme.fg("muted", line)) });
|
||||
}
|
||||
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
return framedBlock(uiTheme, width => ({
|
||||
header,
|
||||
sections,
|
||||
state: "success",
|
||||
borderColor: "borderMuted",
|
||||
width,
|
||||
}));
|
||||
},
|
||||
|
||||
mergeCallAndResult: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as path from "node:path";
|
||||
import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
|
||||
import { applyWorkspaceEdit } from "./edits";
|
||||
@@ -147,6 +148,7 @@ const CLIENT_CAPABILITIES = {
|
||||
failureHandling: "textOnlyTransactional",
|
||||
},
|
||||
configuration: true,
|
||||
workspaceFolders: true,
|
||||
symbol: {
|
||||
dynamicRegistration: false,
|
||||
symbolKind: {
|
||||
@@ -172,49 +174,69 @@ const CLIENT_CAPABILITIES = {
|
||||
// LSP Message Protocol
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse a single LSP message from a buffer.
|
||||
* Returns the parsed message and remaining buffer, or null if incomplete.
|
||||
*/
|
||||
function parseMessage(
|
||||
buffer: Buffer,
|
||||
): { message: LspJsonRpcResponse | LspJsonRpcNotification; remaining: Buffer } | null {
|
||||
// Only decode enough to find the header
|
||||
const headerEndIndex = findHeaderEnd(buffer);
|
||||
if (headerEndIndex === -1) return null;
|
||||
|
||||
const headerText = new TextDecoder().decode(buffer.slice(0, headerEndIndex));
|
||||
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
|
||||
if (!contentLengthMatch) return null;
|
||||
|
||||
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
||||
const messageStart = headerEndIndex + 4; // Skip \r\n\r\n
|
||||
const messageEnd = messageStart + contentLength;
|
||||
|
||||
if (buffer.length < messageEnd) return null;
|
||||
|
||||
const messageBytes = buffer.subarray(messageStart, messageEnd);
|
||||
const messageText = new TextDecoder().decode(messageBytes);
|
||||
const remaining = buffer.subarray(messageEnd);
|
||||
|
||||
return {
|
||||
message: JSON.parse(messageText),
|
||||
remaining,
|
||||
};
|
||||
}
|
||||
// Reused for all full (non-streaming) decodes; each decode() resets state, so a
|
||||
// single instance is safe and avoids per-message TextDecoder allocation.
|
||||
const MESSAGE_DECODER = new TextDecoder("utf-8");
|
||||
|
||||
/**
|
||||
* Find the end of the header section (before \r\n\r\n)
|
||||
* Locate the `\r\n\r\n` header terminator across the pending chunk list.
|
||||
* Returns the absolute byte index of the first `\r`, or -1 when not present.
|
||||
* Equivalent to scanning the contiguous concatenation of the chunks.
|
||||
*/
|
||||
function findHeaderEnd(buffer: Uint8Array): number {
|
||||
for (let i = 0; i < buffer.length - 3; i++) {
|
||||
if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) {
|
||||
return i;
|
||||
function findHeaderEndInChunks(chunks: Buffer[]): number {
|
||||
let global = 0;
|
||||
let b0 = -1;
|
||||
let b1 = -1;
|
||||
let b2 = -1;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const b3 = chunk[i];
|
||||
if (b0 === 13 && b1 === 10 && b2 === 13 && b3 === 10) {
|
||||
return global - 3;
|
||||
}
|
||||
b0 = b1;
|
||||
b1 = b2;
|
||||
b2 = b3;
|
||||
global++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Copy the byte range [from, to) out of the pending chunk list into one Buffer. */
|
||||
function copyChunkRange(chunks: Buffer[], from: number, to: number): Buffer {
|
||||
const out = Buffer.allocUnsafe(to - from);
|
||||
let global = 0;
|
||||
let written = 0;
|
||||
for (const chunk of chunks) {
|
||||
const chunkEnd = global + chunk.length;
|
||||
if (chunkEnd > from && global < to) {
|
||||
const start = Math.max(from, global) - global;
|
||||
const end = Math.min(to, chunkEnd) - global;
|
||||
chunk.copy(out, written, start, end);
|
||||
written += end - start;
|
||||
}
|
||||
global = chunkEnd;
|
||||
if (global >= to) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop the first `count` bytes from the pending chunk list in place. */
|
||||
function dropChunkFront(chunks: Buffer[], count: number): void {
|
||||
let removed = 0;
|
||||
while (chunks.length > 0) {
|
||||
const head = chunks[0];
|
||||
if (removed + head.length <= count) {
|
||||
removed += head.length;
|
||||
chunks.shift();
|
||||
} else {
|
||||
chunks[0] = head.subarray(count - removed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMessage(
|
||||
sink: Bun.FileSink,
|
||||
message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
|
||||
@@ -247,22 +269,43 @@ async function startMessageReader(client: LspClient): Promise<void> {
|
||||
|
||||
const reader = (client.proc.stdout as ReadableStream<Uint8Array>).getReader();
|
||||
|
||||
// Incoming bytes are buffered as a list of chunks and only joined when a full
|
||||
// message is framed. Concatenating the accumulator on every read was O(n^2)
|
||||
// for messages that span many reads (e.g. a large initial diagnostics burst).
|
||||
const pendingChunks: Buffer[] = [];
|
||||
let pendingLen = 0;
|
||||
if (client.messageBuffer.length > 0) {
|
||||
const seed = Buffer.from(client.messageBuffer);
|
||||
pendingChunks.push(seed);
|
||||
pendingLen = seed.length;
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Atomically update buffer before processing
|
||||
const currentBuffer: Buffer = Buffer.concat([client.messageBuffer, value]);
|
||||
client.messageBuffer = currentBuffer;
|
||||
pendingChunks.push(Buffer.from(value));
|
||||
pendingLen += value.length;
|
||||
|
||||
// Process all complete messages in buffer
|
||||
// Use local variable to avoid race with concurrent buffer updates
|
||||
let workingBuffer = currentBuffer;
|
||||
let parsed = parseMessage(workingBuffer);
|
||||
while (parsed) {
|
||||
const { message, remaining } = parsed;
|
||||
workingBuffer = remaining;
|
||||
// Drain every complete message currently buffered.
|
||||
while (true) {
|
||||
const headerEnd = findHeaderEndInChunks(pendingChunks);
|
||||
if (headerEnd === -1) break;
|
||||
|
||||
const headerText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, 0, headerEnd));
|
||||
const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i);
|
||||
if (!contentLengthMatch) break;
|
||||
|
||||
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
||||
const messageStart = headerEnd + 4; // Skip \r\n\r\n
|
||||
const messageEnd = messageStart + contentLength;
|
||||
if (pendingLen < messageEnd) break;
|
||||
|
||||
const messageText = MESSAGE_DECODER.decode(copyChunkRange(pendingChunks, messageStart, messageEnd));
|
||||
const message: LspJsonRpcResponse | LspJsonRpcNotification = JSON.parse(messageText);
|
||||
dropChunkFront(pendingChunks, messageEnd);
|
||||
pendingLen -= messageEnd;
|
||||
|
||||
// Route message
|
||||
if ("id" in message && message.id !== undefined) {
|
||||
@@ -299,12 +342,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed = parseMessage(workingBuffer);
|
||||
}
|
||||
|
||||
// Atomically commit processed buffer
|
||||
client.messageBuffer = workingBuffer;
|
||||
}
|
||||
} catch (err) {
|
||||
// Connection closed or error - reject all pending requests
|
||||
@@ -313,11 +351,34 @@ async function startMessageReader(client: LspClient): Promise<void> {
|
||||
}
|
||||
client.pendingRequests.clear();
|
||||
} finally {
|
||||
// Persist any unparsed remainder so a restarted reader resumes mid-message.
|
||||
client.messageBuffer =
|
||||
pendingChunks.length === 0
|
||||
? new Uint8Array(0)
|
||||
: pendingChunks.length === 1
|
||||
? pendingChunks[0]
|
||||
: Buffer.concat(pendingChunks, pendingLen);
|
||||
reader.releaseLock();
|
||||
client.isReading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the workspace folder list advertised to the server. Identical shape
|
||||
* for `initialize` params and `workspace/workspaceFolders` server requests.
|
||||
*/
|
||||
function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: string }> {
|
||||
return [{ uri: fileToUri(client.cwd), name: path.basename(client.cwd) || "workspace" }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workspace/workspaceFolders requests from the server.
|
||||
*/
|
||||
async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
|
||||
if (typeof message.id !== "number") return;
|
||||
await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workspace/configuration requests from the server.
|
||||
*/
|
||||
@@ -364,6 +425,10 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
|
||||
await handleConfigurationRequest(client, message);
|
||||
return;
|
||||
}
|
||||
if (message.method === "workspace/workspaceFolders") {
|
||||
await handleWorkspaceFoldersRequest(client, message);
|
||||
return;
|
||||
}
|
||||
if (message.method === "workspace/applyEdit") {
|
||||
await handleApplyEditRequest(client, message);
|
||||
return;
|
||||
@@ -412,7 +477,66 @@ async function sendResponse(
|
||||
/** Timeout for warmup initialize requests (5 seconds) */
|
||||
export const WARMUP_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Max time to wait for the server to report project loading completion via $/progress */
|
||||
/** Max time to poll rust-analyzer after progress ends but before Cargo workspaces are ready. */
|
||||
const RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS = 5_000;
|
||||
const RUST_ANALYZER_WORKSPACE_READY_POLL_MS = 100;
|
||||
const RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS = 2_000;
|
||||
const RUST_ANALYZER_STATUS_REQUEST_TIMEOUT_MS = 1_000;
|
||||
const rustAnalyzerReadyClients = new WeakSet<LspClient>();
|
||||
|
||||
function commandBasename(command: string): string {
|
||||
const slash = command.lastIndexOf("/");
|
||||
const backslash = command.lastIndexOf("\\");
|
||||
const separator = Math.max(slash, backslash);
|
||||
return separator === -1 ? command : command.slice(separator + 1);
|
||||
}
|
||||
|
||||
function isRustAnalyzerClient(client: LspClient): boolean {
|
||||
return (
|
||||
commandBasename(client.config.command) === "rust-analyzer" ||
|
||||
(client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
|
||||
);
|
||||
}
|
||||
|
||||
function isRustAnalyzerStatusTimeout(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.startsWith("LSP request rust-analyzer/analyzerStatus timed out after ");
|
||||
}
|
||||
|
||||
async function waitForRustAnalyzerWorkspace(client: LspClient, signal?: AbortSignal): Promise<void> {
|
||||
if (rustAnalyzerReadyClients.has(client)) {
|
||||
return;
|
||||
}
|
||||
const timings = client.config.workspaceReadyTimings;
|
||||
const timeoutMs = timings?.timeoutMs ?? RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS;
|
||||
const pollMs = timings?.pollMs ?? RUST_ANALYZER_WORKSPACE_READY_POLL_MS;
|
||||
const settleMs = timings?.settleMs ?? RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS;
|
||||
const statusRequestTimeoutMs = timings?.statusRequestTimeoutMs ?? RUST_ANALYZER_STATUS_REQUEST_TIMEOUT_MS;
|
||||
const started = Date.now();
|
||||
const deadline = started + timeoutMs;
|
||||
while (true) {
|
||||
throwIfAborted(signal);
|
||||
let status: unknown;
|
||||
try {
|
||||
status = await sendRequest(client, "rust-analyzer/analyzerStatus", {}, signal, statusRequestTimeoutMs);
|
||||
} catch (err) {
|
||||
if (!isRustAnalyzerStatusTimeout(err) || Date.now() >= deadline) {
|
||||
return;
|
||||
}
|
||||
await Bun.sleep(pollMs);
|
||||
continue;
|
||||
}
|
||||
const ready = typeof status === "string" && !status.startsWith("No workspaces");
|
||||
if (ready && Date.now() - started >= settleMs) {
|
||||
rustAnalyzerReadyClients.add(client);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return;
|
||||
}
|
||||
await Bun.sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_LOAD_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Max time to wait for graceful LSP shutdown and process exit. */
|
||||
@@ -530,7 +654,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
|
||||
rootPath: cwd,
|
||||
capabilities: CLIENT_CAPABILITIES,
|
||||
initializationOptions: config.initOptions ?? {},
|
||||
workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
|
||||
workspaceFolders: currentWorkspaceFolders(client),
|
||||
},
|
||||
undefined, // signal
|
||||
initTimeoutMs,
|
||||
@@ -635,6 +759,9 @@ export async function waitForProjectLoaded(client: LspClient, signal?: AbortSign
|
||||
? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
|
||||
: []),
|
||||
]);
|
||||
if (isRustAnalyzerClient(client)) {
|
||||
await waitForRustAnalyzerWorkspace(client, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -304,6 +304,32 @@ const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
|
||||
const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
|
||||
const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
|
||||
const WORKSPACE_SYMBOL_LIMIT = 200;
|
||||
const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
|
||||
"definition",
|
||||
"type_definition",
|
||||
"implementation",
|
||||
"references",
|
||||
"rename",
|
||||
"hover",
|
||||
]);
|
||||
|
||||
const RUST_WORKSPACE_MARKERS = ["Cargo.toml", "rust-analyzer.toml"] as const;
|
||||
|
||||
function hasRustWorkspaceAncestor(filePath: string): boolean {
|
||||
let dir = path.dirname(filePath);
|
||||
while (true) {
|
||||
for (const marker of RUST_WORKSPACE_MARKERS) {
|
||||
if (fs.existsSync(path.join(dir, marker))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
return false;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function limitDiagnosticMessages(messages: string[]): string[] {
|
||||
if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
|
||||
@@ -1940,10 +1966,21 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
try {
|
||||
const client = await getOrCreateClient(serverConfig, this.session.cwd);
|
||||
const targetFile = resolvedFile;
|
||||
const isRustAnalyzerServer =
|
||||
serverName === "rust-analyzer" ||
|
||||
path.basename(serverConfig.command) === "rust-analyzer" ||
|
||||
(serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
|
||||
const needsProjectIndex =
|
||||
targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
|
||||
const rustWorkspaceWait =
|
||||
needsProjectIndex && isRustAnalyzerServer && targetFile !== null && hasRustWorkspaceAncestor(targetFile);
|
||||
|
||||
if (targetFile) {
|
||||
await ensureFileOpen(client, targetFile, signal);
|
||||
}
|
||||
if (rustWorkspaceWait) {
|
||||
await waitForProjectLoaded(client, signal);
|
||||
}
|
||||
|
||||
// For project-aware servers, references/rename/definition without a `symbol`
|
||||
// silently falls back to the first non-whitespace column on the line, which
|
||||
@@ -1968,10 +2005,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
|
||||
let output: string;
|
||||
|
||||
// Wait for project loading to complete before cross-file operations
|
||||
// to ensure the server has indexed all project files.
|
||||
const crossFileActions = new Set(["definition", "type_definition", "implementation", "references", "rename"]);
|
||||
if (crossFileActions.has(action)) {
|
||||
if (needsProjectIndex && !isRustAnalyzerServer) {
|
||||
await waitForProjectLoaded(client, signal);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
truncateToWidth,
|
||||
} from "../tools/render-utils";
|
||||
import { renderStatusLine } from "../tui";
|
||||
import { CachedOutputBlock } from "../tui/output-block";
|
||||
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
||||
import type { LspParams, LspToolDetails } from "./types";
|
||||
|
||||
// =============================================================================
|
||||
@@ -138,7 +138,7 @@ export function renderResult(
|
||||
|
||||
const outputBlock = new CachedOutputBlock();
|
||||
|
||||
return {
|
||||
return markFramedBlockComponent({
|
||||
render(width: number): string[] {
|
||||
// Read mutable state at render time
|
||||
const { expanded, isPartial, spinnerFrame } = options;
|
||||
@@ -194,7 +194,7 @@ export function renderResult(
|
||||
invalidate() {
|
||||
outputBlock.invalidate();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -356,6 +356,16 @@ export interface ServerConfig {
|
||||
disabled?: boolean;
|
||||
/** Per-server warmup timeout in milliseconds. Overrides the global WARMUP_TIMEOUT_MS for this server during startup. */
|
||||
warmupTimeoutMs?: number;
|
||||
/**
|
||||
* Per-server overrides for rust-analyzer workspace-ready polling. When omitted, the module
|
||||
* defaults are used. Primarily a tuning/test seam to bound the multi-second settle window.
|
||||
*/
|
||||
workspaceReadyTimings?: {
|
||||
timeoutMs?: number;
|
||||
pollMs?: number;
|
||||
settleMs?: number;
|
||||
statusRequestTimeoutMs?: number;
|
||||
};
|
||||
capabilities?: ServerCapabilities;
|
||||
/** If true, this is a linter/formatter server (e.g., Biome) - used only for diagnostics/actions, not type intelligence */
|
||||
isLinter?: boolean;
|
||||
|
||||
@@ -153,9 +153,10 @@ export function formatDiagnostic(diagnostic: Diagnostic, filePath: string): stri
|
||||
const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/;
|
||||
|
||||
/**
|
||||
* Reformat pre-formatted diagnostic messages into grep-style directory/file groups.
|
||||
* Reformat pre-formatted diagnostic messages into a multi-level, prefix-folded
|
||||
* directory/file grouping (see `formatGroupedFiles`).
|
||||
* Input: ["path:line:col [sev] msg", ...]
|
||||
* Output: "# dir/\n## file.ts\n line:col [sev] msg"
|
||||
* Output: "# pkg/src/\n## file.ts\n line:col [sev] msg"
|
||||
*
|
||||
* Messages that don't match the expected format are appended ungrouped at the end.
|
||||
*/
|
||||
|
||||
@@ -40,19 +40,13 @@ import {
|
||||
resolveActiveProjectRegistryPath,
|
||||
} from "./discovery/helpers";
|
||||
import { injectOmpExtensionCliRoots } from "./discovery/omp-extension-roots";
|
||||
import { exportFromFile } from "./export/html";
|
||||
import { ExtensionRunner } from "./extensibility/extensions/runner";
|
||||
import type { ExtensionUIContext } from "./extensibility/extensions/types";
|
||||
import {
|
||||
getInstalledPluginsRegistryPath,
|
||||
getMarketplacesCacheDir,
|
||||
getMarketplacesRegistryPath,
|
||||
getPluginsCacheDir,
|
||||
MarketplaceManager,
|
||||
} from "./extensibility/plugins/marketplace";
|
||||
import { scheduleMarketplaceAutoUpdate } from "./extensibility/plugins/marketplace-auto-update";
|
||||
import type { MCPManager } from "./mcp";
|
||||
import { InteractiveMode, runAcpMode, runPrintMode, runRpcMode } from "./modes";
|
||||
import { ALL_SCENES, runSetupWizard, selectSetupScenes } from "./modes/setup-wizard";
|
||||
import { InteractiveMode } from "./modes/interactive-mode";
|
||||
import type { PrintModeOptions } from "./modes/print-mode";
|
||||
import { CURRENT_SETUP_VERSION } from "./modes/setup-version";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
|
||||
import type { SubmittedUserInput } from "./modes/types";
|
||||
import {
|
||||
@@ -72,6 +66,13 @@ import type { LspStartupServerInfo } from "./tools";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
|
||||
import { EventBus } from "./utils/event-bus";
|
||||
|
||||
type RunAcpMode = (createSession: AcpSessionFactory) => Promise<never>;
|
||||
type RunPrintMode = (session: AgentSession, options: PrintModeOptions) => Promise<void>;
|
||||
type RunRpcMode = (
|
||||
session: AgentSession,
|
||||
setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
|
||||
) => Promise<never>;
|
||||
|
||||
async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
|
||||
if (!settings.get("startup.checkUpdate")) {
|
||||
return;
|
||||
@@ -261,17 +262,29 @@ async function runInteractiveMode(
|
||||
eventBus,
|
||||
);
|
||||
|
||||
const setupScenes = await selectSetupScenes(settings.get("setupVersion"), ALL_SCENES, mode, {
|
||||
resuming,
|
||||
isTTY: process.stdin.isTTY && process.stdout.isTTY,
|
||||
setupWizardEnabled: settings.get("startup.setupWizard"),
|
||||
force: forceSetupWizard,
|
||||
// Cold-launch gate: the full setup wizard (every scene + the overlay and
|
||||
// their TUI/OAuth/search/theme deps) is heavy, yet the common case only needs
|
||||
// to know whether the stored setup version is current. Lazy-load the wizard
|
||||
// barrel only when setup is stale or forced; otherwise skip it entirely.
|
||||
const storedSetupVersion = settings.get("setupVersion");
|
||||
const setupWizard =
|
||||
forceSetupWizard || storedSetupVersion < CURRENT_SETUP_VERSION ? await import("./modes/setup-wizard") : undefined;
|
||||
const setupScenes = setupWizard
|
||||
? await setupWizard.selectSetupScenes(storedSetupVersion, setupWizard.ALL_SCENES, mode, {
|
||||
resuming,
|
||||
isTTY: process.stdin.isTTY && process.stdout.isTTY,
|
||||
setupWizardEnabled: settings.get("startup.setupWizard"),
|
||||
force: forceSetupWizard,
|
||||
})
|
||||
: [];
|
||||
|
||||
await mode.init({
|
||||
suppressWelcomeIntro: resuming || setupScenes.length > 0,
|
||||
clearInitialTerminalHistory: true,
|
||||
});
|
||||
|
||||
await mode.init({ suppressWelcomeIntro: setupScenes.length > 0 });
|
||||
|
||||
if (setupScenes.length > 0) {
|
||||
await runSetupWizard(mode, setupScenes);
|
||||
if (setupWizard && setupScenes.length > 0) {
|
||||
await setupWizard.runSetupWizard(mode, setupScenes);
|
||||
}
|
||||
|
||||
versionCheckPromise
|
||||
@@ -285,12 +298,11 @@ async function runInteractiveMode(
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Cold-launch cleanup: wipe the terminal scrollback before painting the
|
||||
// resumed/new transcript. The TUI's initial paint deliberately preserves
|
||||
// native scrollback (prior shell content), but on `omp`/`omp -c` that leaves
|
||||
// the previous run's welcome + transcript stacked above the fresh one. Every
|
||||
// in-process session load already clears via `clearTerminalHistory`; the cold
|
||||
// launch is the lone path that did not.
|
||||
// Cold-launch cleanup: the first paint already clears native history, and this
|
||||
// replay replaces the welcome/startup frame with the resumed/new transcript.
|
||||
// Every in-process session load also uses `clearTerminalHistory`; cold launch
|
||||
// follows the same clean-cutover path instead of preserving a previous run's
|
||||
// transcript above the fresh one.
|
||||
mode.renderInitialMessages(undefined, { preserveExistingChat: true, clearTerminalHistory: true });
|
||||
|
||||
for (const notify of notifs) {
|
||||
@@ -716,7 +728,7 @@ async function buildSessionOptions(
|
||||
interface RunRootCommandDependencies {
|
||||
createAgentSession?: typeof createAgentSession;
|
||||
discoverAuthStorage?: typeof discoverAuthStorage;
|
||||
runAcpMode?: typeof runAcpMode;
|
||||
runAcpMode?: RunAcpMode;
|
||||
settings?: Settings;
|
||||
forceSetupWizard?: boolean;
|
||||
}
|
||||
@@ -773,6 +785,7 @@ export async function runRootCommand(
|
||||
let result: string;
|
||||
try {
|
||||
const outputPath = parsedArgs.messages.length > 0 ? parsedArgs.messages[0] : undefined;
|
||||
const { exportFromFile } = await import("./export/html");
|
||||
result = await exportFromFile(parsedArgs.export, outputPath);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to export session";
|
||||
@@ -940,33 +953,11 @@ export async function runRootCommand(
|
||||
|
||||
await pluginPreloadPromise;
|
||||
|
||||
// Background marketplace auto-update — never blocks startup.
|
||||
const autoUpdate = settingsInstance.get("marketplace.autoUpdate");
|
||||
if (autoUpdate !== "off") {
|
||||
void (async () => {
|
||||
try {
|
||||
const mgr = new MarketplaceManager({
|
||||
marketplacesRegistryPath: getMarketplacesRegistryPath(),
|
||||
installedRegistryPath: getInstalledPluginsRegistryPath(),
|
||||
projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
|
||||
marketplacesCacheDir: getMarketplacesCacheDir(),
|
||||
pluginsCacheDir: getPluginsCacheDir(),
|
||||
clearPluginRootsCache: clearPluginRootsAndCaches,
|
||||
});
|
||||
await mgr.refreshStaleMarketplaces();
|
||||
const updates = await mgr.checkForUpdates();
|
||||
if (updates.length === 0) return;
|
||||
if (autoUpdate === "auto") {
|
||||
await mgr.upgradeAllPlugins();
|
||||
logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s)`);
|
||||
} else {
|
||||
logger.debug(`${updates.length} marketplace plugin update(s) available — /marketplace upgrade`);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — network failure, corrupt data, offline.
|
||||
}
|
||||
})();
|
||||
}
|
||||
scheduleMarketplaceAutoUpdate({
|
||||
autoUpdate: settingsInstance.get("marketplace.autoUpdate"),
|
||||
resolveActiveProjectRegistryPath,
|
||||
clearPluginRootsCache: clearPluginRootsAndCaches,
|
||||
});
|
||||
|
||||
const { options: sessionOptions } = await logger.time(
|
||||
"buildSessionOptions",
|
||||
@@ -988,7 +979,7 @@ export async function runRootCommand(
|
||||
// Both are no-ops when OTEL_EXPORTER_OTLP_ENDPOINT is unset. An empty config
|
||||
// is enough to enable telemetry — content capture is governed by the
|
||||
// standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var.
|
||||
initTelemetryExport();
|
||||
await initTelemetryExport();
|
||||
if (isTelemetryExportEnabled()) {
|
||||
sessionOptions.telemetry = {};
|
||||
}
|
||||
@@ -1027,7 +1018,9 @@ export async function runRootCommand(
|
||||
rawArgs,
|
||||
createSession,
|
||||
});
|
||||
await (deps.runAcpMode ?? runAcpMode)(createAcpSession);
|
||||
// Branch-only protocol runner: keep ACP server code out of normal interactive startup.
|
||||
const runAcpMode = deps.runAcpMode ?? (await import("./modes/acp/acp-mode")).runAcpMode;
|
||||
await runAcpMode(createAcpSession);
|
||||
} else {
|
||||
// Resolve extension-registered CLI flags before creating the session so a
|
||||
// bad `@file` fails fast WITHOUT leaving a junk session/breadcrumb
|
||||
@@ -1091,6 +1084,8 @@ export async function runRootCommand(
|
||||
}
|
||||
|
||||
if (mode === "rpc" || mode === "rpc-ui") {
|
||||
// Branch-only protocol runner: keep RPC host code out of normal interactive startup.
|
||||
const runRpcMode: RunRpcMode = (await import("./modes/rpc/rpc-mode")).runRpcMode;
|
||||
await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined);
|
||||
} else if (isInteractive) {
|
||||
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
|
||||
@@ -1109,7 +1104,7 @@ export async function runRootCommand(
|
||||
|
||||
if ($env.PI_TIMING) {
|
||||
logger.printTimings();
|
||||
if ($env.PI_TIMING === "x") {
|
||||
if (logger.shouldExitAfterTimings()) {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
@@ -1132,6 +1127,8 @@ export async function runRootCommand(
|
||||
initialImages,
|
||||
);
|
||||
} else {
|
||||
// Branch-only single-shot runner: keep print-mode code out of normal interactive startup.
|
||||
const runPrintMode: RunPrintMode = (await import("./modes/print-mode")).runPrintMode;
|
||||
await runPrintMode(session, {
|
||||
mode,
|
||||
messages: initialArgs.messages,
|
||||
|
||||
@@ -3,8 +3,9 @@ import type * as fsNode from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import { clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { type ApiKey, clampThinkingLevelForModel, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
|
||||
import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
@@ -271,7 +272,10 @@ async function runPhase1(options: {
|
||||
const result = await runStage1Job({
|
||||
claim,
|
||||
model: phase1Model,
|
||||
apiKey: phase1ApiKey,
|
||||
apiKey: modelRegistry.resolver(phase1Model.provider, {
|
||||
sessionId: session.sessionId,
|
||||
baseUrl: phase1Model.baseUrl,
|
||||
}),
|
||||
modelMaxTokens: computeModelTokenBudget(phase1Model, config),
|
||||
config,
|
||||
metadata: session.agent?.metadataForProvider(phase1Model.provider),
|
||||
@@ -428,7 +432,10 @@ async function runPhase2(options: {
|
||||
const consolidated = await runConsolidationModel({
|
||||
memoryRoot,
|
||||
model: phase2Model,
|
||||
apiKey: phase2ApiKey,
|
||||
apiKey: modelRegistry.resolver(phase2Model.provider, {
|
||||
sessionId: session.sessionId,
|
||||
baseUrl: phase2Model.baseUrl,
|
||||
}),
|
||||
metadata: session.agent?.metadataForProvider(phase2Model.provider),
|
||||
});
|
||||
await applyConsolidation(memoryRoot, consolidated);
|
||||
@@ -574,7 +581,7 @@ function extractPersistableMessages(payload: string): AgentMessage[] {
|
||||
async function runStage1Job(options: {
|
||||
claim: Stage1Claim;
|
||||
model: Model;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
modelMaxTokens: number;
|
||||
config: MemoryRuntimeConfig;
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -718,7 +725,7 @@ async function readRolloutSummaries(memoryRoot: string): Promise<string> {
|
||||
async function runConsolidationModel(options: {
|
||||
memoryRoot: string;
|
||||
model: Model;
|
||||
apiKey: string;
|
||||
apiKey: ApiKey;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<{
|
||||
memoryMd: string;
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
export * from "../mnemopi";
|
||||
export type {
|
||||
MnemopiBackendConfig,
|
||||
MnemopiLlmMode,
|
||||
MnemopiProviderOptions,
|
||||
MnemopiScoping,
|
||||
} from "../mnemopi/config";
|
||||
export type {
|
||||
MnemopiMemoryEditOperation,
|
||||
MnemopiMemoryEditOptions,
|
||||
MnemopiMemoryEditResult,
|
||||
MnemopiSessionState,
|
||||
MnemopiSessionStateOptions,
|
||||
} from "../mnemopi/state";
|
||||
export * from "./local-backend";
|
||||
export * from "./off-backend";
|
||||
export * from "./resolve";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Settings } from "../config/settings";
|
||||
import { hindsightBackend } from "../hindsight";
|
||||
import { mnemopiBackend } from "../mnemopi";
|
||||
import { localBackend } from "./local-backend";
|
||||
import { offBackend } from "./off-backend";
|
||||
import type { MemoryBackend } from "./types";
|
||||
@@ -18,10 +16,10 @@ import type { MemoryBackend } from "./types";
|
||||
* `memories.enabled` remains accepted only as a legacy migration input. Once
|
||||
* a config is loaded, `memory.backend` is the sole runtime selector.
|
||||
*/
|
||||
export function resolveMemoryBackend(settings: Settings): MemoryBackend {
|
||||
export async function resolveMemoryBackend(settings: Settings): Promise<MemoryBackend> {
|
||||
const id = settings.get("memory.backend");
|
||||
if (id === "hindsight") return hindsightBackend;
|
||||
if (id === "mnemopi") return mnemopiBackend;
|
||||
if (id === "hindsight") return (await import("../hindsight/backend")).hindsightBackend;
|
||||
if (id === "mnemopi") return (await import("../mnemopi/backend")).mnemopiBackend;
|
||||
if (id === "local") return localBackend;
|
||||
return offBackend;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Memory backend abstraction.
|
||||
*
|
||||
* Backends are mutually exclusive — `resolveMemoryBackend(settings)` returns
|
||||
* Backends are mutually exclusive — `await resolveMemoryBackend(settings)` resolves
|
||||
* exactly one. Implementations MUST be self-contained: they own the per-session
|
||||
* state they create in `start()` and tear it down on `clear()`.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Mnemopi } from "@oh-my-pi/pi-mnemopi";
|
||||
import { BankManager } from "@oh-my-pi/pi-mnemopi/core";
|
||||
import { type DiagnosticSummary, inspectDatabase } from "@oh-my-pi/pi-mnemopi/diagnose";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import { resolveRoleSelection } from "../config/model-resolver";
|
||||
import type { MemoryBackend, MemoryBackendStartOptions } from "../memory-backend/types";
|
||||
@@ -334,7 +335,10 @@ async function resolveMnemopiProviderOptions(
|
||||
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
apiKey: modelRegistry.resolver(model.provider, {
|
||||
sessionId,
|
||||
baseUrl: model.baseUrl,
|
||||
}),
|
||||
maxTokens: opts?.maxTokens,
|
||||
temperature: opts?.temperature,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
type Agent,
|
||||
@@ -62,7 +63,7 @@ import { MCPManager } from "../../mcp/manager";
|
||||
import type { MCPServerConfig } from "../../mcp/types";
|
||||
import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { type PlanApprovalDetails, renameApprovedPlanFile, resolvePlanTitle } from "../../plan-mode/approved-plan";
|
||||
import { type PlanApprovalDetails, resolveApprovedPlan } from "../../plan-mode/approved-plan";
|
||||
import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
|
||||
import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
|
||||
import {
|
||||
@@ -1425,24 +1426,16 @@ export class AcpAgent implements Agent {
|
||||
if (!state?.enabled) {
|
||||
throw new ToolError("Plan mode is not active.");
|
||||
}
|
||||
const planFilePath = state.planFilePath;
|
||||
const planContent = await this.#readAcpPlanFile(session, planFilePath);
|
||||
if (planContent === null) {
|
||||
throw new ToolError(
|
||||
`Plan file not found at ${planFilePath}. Write the finalized plan to ${planFilePath} before requesting approval.`,
|
||||
);
|
||||
}
|
||||
const normalized = resolvePlanTitle({
|
||||
const { planFilePath, planContent, title } = await resolveApprovedPlan({
|
||||
suppliedTitle: extra?.title,
|
||||
planContent,
|
||||
planFilePath,
|
||||
statePlanFilePath: state.planFilePath,
|
||||
readPlan: url => this.#readAcpPlanFile(session, url),
|
||||
listPlanFiles: () => this.#listAcpLocalPlanFiles(session),
|
||||
});
|
||||
const finalPlanFilePath = `local://${normalized.fileName}`;
|
||||
const approved = await this.#requestAcpPlanApprovalChoice(session.sessionId, normalized.title, planContent);
|
||||
const approved = await this.#requestAcpPlanApprovalChoice(session.sessionId, title, planContent);
|
||||
const details: PlanApprovalDetails = {
|
||||
planFilePath,
|
||||
finalPlanFilePath,
|
||||
title: normalized.title,
|
||||
title,
|
||||
planExists: true,
|
||||
};
|
||||
if (!approved) {
|
||||
@@ -1458,16 +1451,10 @@ export class AcpAgent implements Agent {
|
||||
details,
|
||||
};
|
||||
}
|
||||
// Approved. Rename plan to its titled filename, set the plan
|
||||
// reference so the next turn injects the plan content as
|
||||
// context, then exit plan mode so the agent regains full tools.
|
||||
await renameApprovedPlanFile({
|
||||
planFilePath,
|
||||
finalPlanFilePath,
|
||||
getArtifactsDir: () => session.sessionManager.getArtifactsDir(),
|
||||
getSessionId: () => session.sessionManager.getSessionId(),
|
||||
});
|
||||
session.setPlanReferencePath(finalPlanFilePath);
|
||||
// Approved. Set the plan reference so the next turn injects the plan
|
||||
// content as context (the file keeps its agent-chosen name — no
|
||||
// rename), then exit plan mode so the agent regains full tools.
|
||||
session.setPlanReferencePath(planFilePath);
|
||||
session.setStandingResolveHandler?.(null);
|
||||
session.setPlanModeState(undefined);
|
||||
try {
|
||||
@@ -1486,7 +1473,7 @@ export class AcpAgent implements Agent {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Plan approved at ${finalPlanFilePath}. Plan mode exited; proceed with the implementation.`,
|
||||
text: `Plan approved at ${planFilePath}. Plan mode exited; proceed with the implementation.`,
|
||||
},
|
||||
],
|
||||
details,
|
||||
@@ -1518,6 +1505,26 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/** `local://` URLs of plan files in the session-local root, newest first —
|
||||
* the `resolveApprovedPlan` fallback for a dropped `extra.title`. */
|
||||
async #listAcpLocalPlanFiles(session: AgentSession): Promise<string[]> {
|
||||
const localRoot = this.#resolveAcpPlanFilePath(session, "local://");
|
||||
try {
|
||||
const entries = await fs.readdir(localRoot, { withFileTypes: true });
|
||||
const plans = await Promise.all(
|
||||
entries
|
||||
.filter(entry => entry.isFile() && /plan\.md$/i.test(entry.name))
|
||||
.map(async entry => {
|
||||
const stat = await fs.stat(path.join(localRoot, entry.name)).catch(() => null);
|
||||
return { url: `local://${entry.name}`, mtime: stat?.mtimeMs ?? 0 };
|
||||
}),
|
||||
);
|
||||
return plans.sort((a, b) => b.mtime - a.mtime).map(plan => plan.url);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the ACP client to confirm plan approval. Returns `true` only on an
|
||||
* explicit `APPROVE_OPTION` selection. Refine, dismissal (`undefined`), or
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
matchesKey,
|
||||
padding,
|
||||
replaceTabs,
|
||||
ScrollView,
|
||||
Spacer,
|
||||
Text,
|
||||
truncateToWidth,
|
||||
@@ -205,9 +206,12 @@ class AgentListPane implements Component {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const overflow = this.agents.length > this.maxVisible;
|
||||
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
||||
const start = this.scrollOffset;
|
||||
const end = Math.min(start + this.maxVisible, this.agents.length);
|
||||
|
||||
const rows: string[] = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const agent = this.agents[i];
|
||||
const selected = i === this.selectedIndex;
|
||||
@@ -224,12 +228,17 @@ class AgentListPane implements Component {
|
||||
line = theme.fg("dim", line);
|
||||
}
|
||||
|
||||
lines.push(truncateToWidth(line, width));
|
||||
rows.push(truncateToWidth(line, rowWidth));
|
||||
}
|
||||
|
||||
if (this.agents.length > this.maxVisible) {
|
||||
lines.push(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.agents.length})`));
|
||||
}
|
||||
const sv = new ScrollView(rows, {
|
||||
height: rows.length,
|
||||
scrollbar: "auto",
|
||||
totalRows: this.agents.length,
|
||||
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
||||
});
|
||||
sv.setScrollOffset(this.scrollOffset);
|
||||
lines.push(...sv.render(width));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import { settings } from "../../config/settings";
|
||||
import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
import { isSilentAbort } from "../../session/messages";
|
||||
import { isSilentAbort, resolveAbortLabel } from "../../session/messages";
|
||||
import { resolveImageOptions } from "../../tools/render-utils";
|
||||
|
||||
/**
|
||||
@@ -18,6 +18,15 @@ export class AssistantMessageComponent extends Container {
|
||||
#convertedKittyImages = new Map<string, ImageContent>();
|
||||
#kittyConversionsInFlight = new Set<string>();
|
||||
#transcriptBlockFinalized: boolean;
|
||||
/**
|
||||
* When true, the turn-ending `Error: …` line for `stopReason === "error"` is
|
||||
* suppressed because the same error is currently shown in the pinned banner
|
||||
* above the editor (see `EventController` + `ErrorBannerComponent`). Avoids
|
||||
* rendering the identical error twice (inline + banner) at the error moment.
|
||||
* Restored to `false` when the banner is cleared at the next turn so the
|
||||
* transcript keeps the error in history.
|
||||
*/
|
||||
#errorPinned = false;
|
||||
|
||||
constructor(
|
||||
message?: AssistantMessage,
|
||||
@@ -49,6 +58,18 @@ export class AssistantMessageComponent extends Container {
|
||||
this.hideThinkingBlock = hide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle suppression of the inline `Error: …` line while the same error is
|
||||
* pinned in the banner above the editor. Re-renders so the change is visible.
|
||||
*/
|
||||
setErrorPinned(pinned: boolean): void {
|
||||
if (this.#errorPinned === pinned) return;
|
||||
this.#errorPinned = pinned;
|
||||
if (this.#lastMessage) {
|
||||
this.updateContent(this.#lastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
isTranscriptBlockFinalized(): boolean {
|
||||
return this.#transcriptBlockFinalized;
|
||||
}
|
||||
@@ -187,10 +208,6 @@ export class AssistantMessageComponent extends Container {
|
||||
c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
|
||||
);
|
||||
|
||||
if (hasVisibleContent) {
|
||||
this.#contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Render content in order
|
||||
let thinkingIndex = 0;
|
||||
for (let i = 0; i < message.content.length; i++) {
|
||||
@@ -236,17 +253,14 @@ export class AssistantMessageComponent extends Container {
|
||||
const hasToolCalls = message.content.some(c => c.type === "toolCall");
|
||||
if (!hasToolCalls) {
|
||||
if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
|
||||
const abortMessage =
|
||||
message.errorMessage && message.errorMessage !== "Request was aborted"
|
||||
? message.errorMessage
|
||||
: "Operation aborted";
|
||||
const abortMessage = resolveAbortLabel(message.errorMessage);
|
||||
if (hasVisibleContent) {
|
||||
this.#contentContainer.addChild(new Spacer(1));
|
||||
} else {
|
||||
this.#contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
|
||||
} else if (message.stopReason === "error") {
|
||||
} else if (message.stopReason === "error" && !this.#errorPinned) {
|
||||
const errorMsg = message.errorMessage || "Unknown error";
|
||||
this.#contentContainer.addChild(new Spacer(1));
|
||||
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Container } from "@oh-my-pi/pi-tui";
|
||||
|
||||
/**
|
||||
* Capabilities a mounted {@link ChatBlock} may use against its host transcript.
|
||||
* Kept minimal so blocks never reach into the full TUI/InteractiveMode surface.
|
||||
*/
|
||||
export interface ChatBlockHost {
|
||||
/** Schedule a repaint of the transcript. */
|
||||
requestRender(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle-aware transcript block — the "return a block, let the host mount it"
|
||||
* primitive, modelled on React/Svelte component lifecycles.
|
||||
*
|
||||
* Producers build and return a `ChatBlock` instead of poking `chatContainer` and
|
||||
* `ui.requestRender()` directly. The host (`ctx.present`) appends it and calls
|
||||
* {@link mount}, which runs {@link onMount}; effects started there register
|
||||
* teardown via {@link onCleanup}. The block repaints through {@link requestRender}
|
||||
* — never touching the TUI — and tears down exactly once on {@link finish}
|
||||
* (self-complete: stop the animation, keep the final frame in the transcript) or
|
||||
* {@link dispose} (host discards it, e.g. a transcript reset).
|
||||
*
|
||||
* While mounted and unfinished a block reports `isTranscriptBlockFinalized() ===
|
||||
* false` so {@link "../components/transcript-container".TranscriptContainer}
|
||||
* keeps it in the live, repaintable region on ED3-risk terminals; after
|
||||
* `finish()`/`dispose()` it reports `true` and freezes at its final content.
|
||||
*/
|
||||
export abstract class ChatBlock extends Container {
|
||||
#host: ChatBlockHost | undefined;
|
||||
#cleanups: Array<() => void> = [];
|
||||
#active = false;
|
||||
#disposed = false;
|
||||
|
||||
/**
|
||||
* Run setup after the block is in the transcript: start timers/subscriptions
|
||||
* and register their teardown with {@link onCleanup}. Default: no-op (a block
|
||||
* whose content is fixed at construction needs no mount work).
|
||||
*/
|
||||
protected onMount(): void {}
|
||||
|
||||
/**
|
||||
* Register a teardown to run on {@link finish}/{@link dispose}, à la a
|
||||
* `useEffect` cleanup. If the block is already disposed the cleanup runs
|
||||
* immediately so callers never leak.
|
||||
*/
|
||||
protected onCleanup(cleanup: () => void): void {
|
||||
if (this.#disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
this.#cleanups.push(cleanup);
|
||||
}
|
||||
|
||||
/** Ask the host to repaint. No-op before mount or after dispose. */
|
||||
protected requestRender(): void {
|
||||
this.#host?.requestRender();
|
||||
}
|
||||
|
||||
/** True between {@link mount} and {@link finish}/{@link dispose}. */
|
||||
protected get active(): boolean {
|
||||
return this.#active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only: attach the host and run {@link onMount}. Idempotent — a second
|
||||
* call (e.g. a transcript rebuild that re-presents the same instance) is a
|
||||
* no-op.
|
||||
*/
|
||||
mount(host: ChatBlockHost): void {
|
||||
if (this.#host || this.#disposed) return;
|
||||
this.#host = host;
|
||||
this.#active = true;
|
||||
this.onMount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-complete: stop ongoing effects and freeze the block at its current
|
||||
* content, leaving it rendered in the transcript. Use when the operation the
|
||||
* block represents finishes (connection resolved, download done).
|
||||
*/
|
||||
finish(): void {
|
||||
if (!this.#active) return;
|
||||
this.#active = false;
|
||||
this.#runCleanups();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only teardown: release everything and propagate to children. Called
|
||||
* when the host permanently discards the block (transcript reset). Idempotent.
|
||||
*/
|
||||
override dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#active = false;
|
||||
this.#runCleanups();
|
||||
super.dispose();
|
||||
this.#host = undefined;
|
||||
}
|
||||
|
||||
/** Live blocks stay repaintable; finished/disposed ones may freeze. */
|
||||
isTranscriptBlockFinalized(): boolean {
|
||||
return !this.#active;
|
||||
}
|
||||
|
||||
#runCleanups(): void {
|
||||
const cleanups = this.#cleanups.splice(0);
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { type Component, matchesKey, padding, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { replaceTabs } from "../../tools/render-utils";
|
||||
import { highlightCode, theme } from "../theme/theme";
|
||||
import type { CopyTarget } from "../utils/copy-targets";
|
||||
import {
|
||||
matchesSelectCancel,
|
||||
matchesSelectDown,
|
||||
matchesSelectPageDown,
|
||||
matchesSelectPageUp,
|
||||
matchesSelectUp,
|
||||
} from "../utils/keybinding-matchers";
|
||||
import { keyHint, rawKeyHint } from "./keybinding-hints";
|
||||
import { bottomBorder, divider, row, topBorder } from "./overlay-box";
|
||||
|
||||
/** Minimum rows reserved for the tree even on short terminals. */
|
||||
const MIN_TREE_ROWS = 3;
|
||||
/** Fixed chrome rows: top border, two dividers, footer, bottom border. */
|
||||
const CHROME_ROWS = 5;
|
||||
|
||||
export interface CopySelectorCallbacks {
|
||||
/** A copy target was chosen — copy its `content`. */
|
||||
onPick: (target: CopyTarget) => void;
|
||||
/** The picker was dismissed. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface FlatNode {
|
||||
target: CopyTarget;
|
||||
depth: number;
|
||||
/** Last among its siblings (drives └─ vs ├─). */
|
||||
isLast: boolean;
|
||||
/** Per-ancestor flag: does ancestor at that level have a following sibling? */
|
||||
ancestorHasNext: boolean[];
|
||||
}
|
||||
|
||||
/** Render one tree connector as exactly three cells (e.g. "├─ ", "└─ ", "|--"). */
|
||||
function connectorCells(symbol: string): string {
|
||||
const chars = Array.from(symbol);
|
||||
return (chars[0] ?? " ") + (chars[1] ?? theme.tree.horizontal) + (chars[2] ?? " ");
|
||||
}
|
||||
|
||||
/** The 3-cell ancestor gutter: a vertical guide when the ancestor continues. */
|
||||
function gutterCells(hasNext: boolean): string {
|
||||
return `${hasNext ? theme.tree.vertical : " "} `;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen `/copy` picker rendered as a `/tree`-style tree inside one
|
||||
* outlined box: a title, the tree of copy targets (recent assistant messages
|
||||
* with their code blocks nested beneath), a live preview of the highlighted
|
||||
* node, and a keybinding footer. Every node copies its `content` on Enter.
|
||||
*/
|
||||
export class CopySelectorComponent implements Component {
|
||||
#roots: CopyTarget[];
|
||||
#cursorId: string;
|
||||
#treeRows = MIN_TREE_ROWS;
|
||||
// Reused across renders to wrap preview content to the pane width.
|
||||
#previewText = new Text("", 0, 0);
|
||||
|
||||
constructor(
|
||||
roots: CopyTarget[],
|
||||
private readonly callbacks: CopySelectorCallbacks,
|
||||
) {
|
||||
this.#roots = roots;
|
||||
this.#cursorId = roots[0]?.id ?? "";
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
#flatten(): FlatNode[] {
|
||||
const out: FlatNode[] = [];
|
||||
const walk = (nodes: CopyTarget[], depth: number, ancestorHasNext: boolean[]) => {
|
||||
nodes.forEach((target, i) => {
|
||||
const isLast = i === nodes.length - 1;
|
||||
out.push({ target, depth, isLast, ancestorHasNext });
|
||||
if (target.children?.length) walk(target.children, depth + 1, [...ancestorHasNext, !isLast]);
|
||||
});
|
||||
};
|
||||
walk(this.#roots, 0, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (matchesSelectCancel(keyData)) {
|
||||
this.callbacks.onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const flat = this.#flatten();
|
||||
if (flat.length === 0) return;
|
||||
const idx = Math.max(
|
||||
0,
|
||||
flat.findIndex(n => n.target.id === this.#cursorId),
|
||||
);
|
||||
|
||||
if (matchesSelectUp(keyData)) {
|
||||
this.#cursorId = flat[idx === 0 ? flat.length - 1 : idx - 1]!.target.id;
|
||||
} else if (matchesSelectDown(keyData)) {
|
||||
this.#cursorId = flat[idx === flat.length - 1 ? 0 : idx + 1]!.target.id;
|
||||
} else if (matchesSelectPageUp(keyData)) {
|
||||
this.#cursorId = flat[Math.max(0, idx - this.#treeRows)]!.target.id;
|
||||
} else if (matchesSelectPageDown(keyData)) {
|
||||
this.#cursorId = flat[Math.min(flat.length - 1, idx + this.#treeRows)]!.target.id;
|
||||
} else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
||||
const target = flat[idx]!.target;
|
||||
if (target.content !== undefined) this.callbacks.onPick(target);
|
||||
}
|
||||
}
|
||||
|
||||
#renderTree(width: number, flat: FlatNode[], cursorIdx: number, rows: number): string[] {
|
||||
const inner = Math.max(0, width - 4);
|
||||
const start = Math.max(0, Math.min(cursorIdx - Math.floor(rows / 2), Math.max(0, flat.length - rows)));
|
||||
const out: string[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const i = start + r;
|
||||
const node = flat[i];
|
||||
if (!node) {
|
||||
out.push(row("", width));
|
||||
continue;
|
||||
}
|
||||
const target = node.target;
|
||||
const isSelected = i === cursorIdx;
|
||||
|
||||
let prefix = "";
|
||||
for (let l = 0; l < node.depth - 1; l++) prefix += gutterCells(node.ancestorHasNext[l]!);
|
||||
if (node.depth > 0) prefix += connectorCells(node.isLast ? theme.tree.last : theme.tree.branch);
|
||||
|
||||
const cursor = isSelected ? "❯ " : " ";
|
||||
const hint = target.hint ?? "";
|
||||
const hintWidth = hint ? visibleWidth(hint) + 2 : 0;
|
||||
const used = visibleWidth(cursor) + visibleWidth(prefix);
|
||||
const labelPlain = truncateToWidth(target.label, Math.max(1, inner - used - hintWidth));
|
||||
const left = isSelected
|
||||
? theme.fg("accent", cursor) + theme.fg("dim", prefix) + theme.bold(theme.fg("accent", labelPlain))
|
||||
: cursor + theme.fg("dim", prefix) + labelPlain;
|
||||
const gap = Math.max(1, inner - used - visibleWidth(labelPlain) - visibleWidth(hint));
|
||||
out.push(row(left + padding(gap) + (hint ? theme.fg("dim", hint) : ""), width));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
#renderPreview(width: number, target: CopyTarget | undefined, rows: number): string[] {
|
||||
const out: string[] = [];
|
||||
const hint = target?.hint;
|
||||
out.push(row(theme.fg("dim", `Preview${hint ? ` · ${hint}` : ""}`), width));
|
||||
|
||||
const contentRows = rows - 1;
|
||||
if (!target || contentRows <= 0) {
|
||||
while (out.length < rows) out.push(row("", width));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Code/command previews are syntax-highlighted; everything else is shown
|
||||
// as plain text. Both are wrapped (not hard-truncated) to the pane width.
|
||||
const isCode = target.language !== undefined;
|
||||
const source = isCode
|
||||
? highlightCode(replaceTabs(target.preview), target.language).join("\n")
|
||||
: replaceTabs(target.preview);
|
||||
this.#previewText.setText(source);
|
||||
const wrapped = this.#previewText.render(Math.max(1, width - 4));
|
||||
|
||||
const hasMore = wrapped.length > contentRows;
|
||||
const visibleCount = hasMore ? contentRows - 1 : Math.min(wrapped.length, contentRows);
|
||||
for (let k = 0; k < contentRows; k++) {
|
||||
if (k < visibleCount) {
|
||||
out.push(row(isCode ? wrapped[k]! : theme.fg("muted", wrapped[k]!), width));
|
||||
} else if (k === visibleCount && hasMore) {
|
||||
out.push(row(theme.fg("dim", `… ${wrapped.length - visibleCount} more lines`), width));
|
||||
} else {
|
||||
out.push(row("", width));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const height = process.stdout.rows || 40;
|
||||
const flat = this.#flatten();
|
||||
const cursorIdx = Math.max(
|
||||
0,
|
||||
flat.findIndex(n => n.target.id === this.#cursorId),
|
||||
);
|
||||
const selected = flat[cursorIdx]?.target;
|
||||
|
||||
const available = Math.max(MIN_TREE_ROWS + 1, height - CHROME_ROWS);
|
||||
const treeRows = Math.max(1, Math.min(flat.length, Math.floor(available / 2)));
|
||||
this.#treeRows = treeRows;
|
||||
const previewRows = Math.max(1, available - treeRows);
|
||||
|
||||
const footer = [
|
||||
rawKeyHint("↑↓", "move"),
|
||||
keyHint("tui.select.confirm", "copy"),
|
||||
keyHint("tui.select.cancel", "quit"),
|
||||
].join(theme.fg("dim", " · "));
|
||||
|
||||
return [
|
||||
topBorder(width, "Copy to clipboard"),
|
||||
...this.#renderTree(width, flat, cursorIdx, treeRows),
|
||||
divider(width),
|
||||
...this.#renderPreview(width, selected, previewRows),
|
||||
divider(width),
|
||||
row(footer, width),
|
||||
bottomBorder(width),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type ConfigurableEditorAction = Extract<
|
||||
| "app.clear"
|
||||
| "app.exit"
|
||||
| "app.suspend"
|
||||
| "app.display.reset"
|
||||
| "app.thinking.cycle"
|
||||
| "app.model.cycleForward"
|
||||
| "app.model.cycleBackward"
|
||||
@@ -30,10 +31,11 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
|
||||
"app.clear": ["ctrl+c"],
|
||||
"app.exit": ["ctrl+d"],
|
||||
"app.suspend": ["ctrl+z"],
|
||||
"app.display.reset": ["ctrl+l"],
|
||||
"app.thinking.cycle": ["shift+tab"],
|
||||
"app.model.cycleForward": ["ctrl+p"],
|
||||
"app.model.cycleBackward": ["shift+ctrl+p"],
|
||||
"app.model.select": ["ctrl+l"],
|
||||
"app.model.select": ["alt+m"],
|
||||
"app.model.selectTemporary": ["alt+p"],
|
||||
"app.tools.expand": ["ctrl+o"],
|
||||
"app.thinking.toggle": ["ctrl+t"],
|
||||
@@ -45,6 +47,21 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
|
||||
"app.clipboard.copyPrompt": ["alt+shift+c"],
|
||||
};
|
||||
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
||||
|
||||
export function extractBracketedImagePastePath(data: string): string | undefined {
|
||||
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
||||
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
||||
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
|
||||
|
||||
const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
|
||||
if (!pasted || /[\r\n]/.test(pasted)) return undefined;
|
||||
if (!BRACKETED_IMAGE_PATH_REGEX.test(pasted)) return undefined;
|
||||
return pasted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom editor that handles configurable app-level shortcuts for coding-agent.
|
||||
*/
|
||||
@@ -65,6 +82,7 @@ export class CustomEditor extends Editor {
|
||||
onEscape?: () => void;
|
||||
onClear?: () => void;
|
||||
onExit?: () => void;
|
||||
onDisplayReset?: () => void;
|
||||
onCycleThinkingLevel?: () => void;
|
||||
onCycleModelForward?: () => void;
|
||||
onCycleModelBackward?: () => void;
|
||||
@@ -79,6 +97,8 @@ export class CustomEditor extends Editor {
|
||||
onCopyPrompt?: () => void;
|
||||
/** Called when the configured image-paste shortcut is pressed. */
|
||||
onPasteImage?: () => Promise<boolean>;
|
||||
/** Called when a bracketed paste contains exactly one image-file path. */
|
||||
onPasteImagePath?: (path: string) => void;
|
||||
/** Called when the configured raw text-paste shortcut is pressed. */
|
||||
onPasteTextRaw?: () => void;
|
||||
/** Called when the configured dequeue shortcut is pressed. */
|
||||
@@ -134,6 +154,12 @@ export class CustomEditor extends Editor {
|
||||
return;
|
||||
}
|
||||
|
||||
const pastedImagePath = extractBracketedImagePastePath(data);
|
||||
if (pastedImagePath && this.onPasteImagePath) {
|
||||
this.onPasteImagePath(pastedImagePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured image paste (async - fires and handles result)
|
||||
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
|
||||
void this.onPasteImage();
|
||||
@@ -158,6 +184,12 @@ export class CustomEditor extends Editor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured display reset shortcut
|
||||
if (this.#matchesAction(data, "app.display.reset") && this.onDisplayReset) {
|
||||
this.onDisplayReset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured suspend shortcut
|
||||
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
|
||||
this.onSuspend();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
|
||||
import { Box, Container } from "@oh-my-pi/pi-tui";
|
||||
import type { MessageRenderer } from "../../extensibility/extensions/types";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import type { CustomMessage } from "../../session/messages";
|
||||
@@ -20,8 +20,6 @@ export class CustomMessageComponent extends Container {
|
||||
) {
|
||||
super();
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create box with custom background (used for default rendering)
|
||||
this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* stay in their respective files.
|
||||
*/
|
||||
|
||||
import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { type Component, Container, Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { getSymbolTheme, theme } from "../../modes/theme/theme";
|
||||
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -31,7 +31,6 @@ export function buildExecutionFrame(
|
||||
): { contentContainer: Container; loader: Loader } {
|
||||
const borderColor = (str: string) => theme.fg(colorKey, str);
|
||||
|
||||
parent.addChild(new Spacer(1));
|
||||
parent.addChild(new DynamicBorder(borderColor));
|
||||
|
||||
const contentContainer = new Container();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
extractPrintableText,
|
||||
matchesKey,
|
||||
padding,
|
||||
ScrollView,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
@@ -134,25 +135,33 @@ export class ExtensionList implements Component {
|
||||
const startIdx = this.#scrollOffset;
|
||||
const endIdx = Math.min(startIdx + this.#maxVisible, this.#listItems.length);
|
||||
|
||||
// Reserve the rightmost column for the scrollbar when overflowing
|
||||
const overflow = this.#listItems.length > this.#maxVisible;
|
||||
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
||||
|
||||
// Render visible items
|
||||
const rows: string[] = [];
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const listItem = this.#listItems[i];
|
||||
const isSelected = this.#focused && i === this.#selectedIndex;
|
||||
|
||||
if (listItem.type === "master") {
|
||||
lines.push(this.#renderMasterSwitch(listItem, isSelected, width));
|
||||
rows.push(this.#renderMasterSwitch(listItem, isSelected, rowWidth));
|
||||
} else if (listItem.type === "kind-header") {
|
||||
lines.push(this.#renderKindHeader(listItem, isSelected, width));
|
||||
rows.push(this.#renderKindHeader(listItem, isSelected, rowWidth));
|
||||
} else {
|
||||
lines.push(this.#renderExtensionRow(listItem.item, isSelected, width, masterDisabled));
|
||||
rows.push(this.#renderExtensionRow(listItem.item, isSelected, rowWidth, masterDisabled));
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
if (this.#listItems.length > this.#maxVisible) {
|
||||
const indicator = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#listItems.length})`);
|
||||
lines.push(indicator);
|
||||
}
|
||||
const sv = new ScrollView(rows, {
|
||||
height: rows.length,
|
||||
scrollbar: "auto",
|
||||
totalRows: this.#listItems.length,
|
||||
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
||||
});
|
||||
sv.setScrollOffset(this.#scrollOffset);
|
||||
lines.push(...sv.render(width));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Input,
|
||||
matchesKey,
|
||||
padding,
|
||||
ScrollView,
|
||||
Spacer,
|
||||
Text,
|
||||
truncateToWidth,
|
||||
@@ -115,15 +116,19 @@ class HistoryResultsList implements Component {
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.#maxVisible, this.#results.length);
|
||||
|
||||
const overflow = this.#results.length > this.#maxVisible;
|
||||
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
||||
const rows: string[] = [];
|
||||
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const entry = this.#results[i];
|
||||
const isSelected = i === this.#selectedIndex;
|
||||
|
||||
const timeStr = relativeTime(entry.created_at);
|
||||
const timeWidth = visibleWidth(timeStr);
|
||||
const showTime = width >= gutterWidth + 12 + timeWidth;
|
||||
const showTime = rowWidth >= gutterWidth + 12 + timeWidth;
|
||||
|
||||
const promptBudget = Math.max(4, width - gutterWidth - (showTime ? timeWidth + 1 : 0));
|
||||
const promptBudget = Math.max(4, rowWidth - gutterWidth - (showTime ? timeWidth + 1 : 0));
|
||||
const normalized = entry.prompt.replace(/\s+/g, " ").trim();
|
||||
const plain = truncateToWidth(normalized, promptBudget);
|
||||
const highlighted = highlightTokens(plain, this.#tokens);
|
||||
@@ -133,21 +138,24 @@ class HistoryResultsList implements Component {
|
||||
|
||||
if (showTime) {
|
||||
// Pad the prompt region so the timestamp sits flush right with a one-cell gap.
|
||||
line = `${truncateToWidth(line, width - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
|
||||
line = `${truncateToWidth(line, rowWidth - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
|
||||
}
|
||||
|
||||
lines.push(
|
||||
rows.push(
|
||||
isSelected
|
||||
? theme.bg("selectedBg", truncateToWidth(line, width, Ellipsis.Omit, true))
|
||||
: truncateToWidth(line, width),
|
||||
? theme.bg("selectedBg", truncateToWidth(line, rowWidth, Ellipsis.Omit, true))
|
||||
: truncateToWidth(line, rowWidth),
|
||||
);
|
||||
}
|
||||
|
||||
if (startIndex > 0 || endIndex < this.#results.length) {
|
||||
const scrollText = ` ${this.#selectedIndex + 1}/${this.#results.length}`;
|
||||
lines.push(theme.fg("muted", truncateToWidth(scrollText, width, Ellipsis.Omit)));
|
||||
}
|
||||
|
||||
const sv = new ScrollView(rows, {
|
||||
height: rows.length,
|
||||
scrollbar: "auto",
|
||||
totalRows: this.#results.length,
|
||||
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
||||
});
|
||||
sv.setScrollOffset(startIndex);
|
||||
lines.push(...sv.render(width));
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
|
||||
import { Box, Container } from "@oh-my-pi/pi-tui";
|
||||
import type { HookMessageRenderer } from "../../extensibility/hooks/types";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import type { HookMessage } from "../../session/messages";
|
||||
@@ -23,8 +23,6 @@ export class HookMessageComponent extends Container {
|
||||
) {
|
||||
super();
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create box with purple background (used for default rendering)
|
||||
this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getKeybindings,
|
||||
Input,
|
||||
matchesKey,
|
||||
ScrollView,
|
||||
Spacer,
|
||||
type Tab,
|
||||
TabBar,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type TUI,
|
||||
visibleWidth,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../../config/model-registry";
|
||||
import { resolveModelRoleValue } from "../../config/model-resolver";
|
||||
@@ -147,6 +149,7 @@ export class ModelSelectorComponent extends Container {
|
||||
#tui: TUI;
|
||||
#scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
#temporaryOnly: boolean;
|
||||
#currentContextTokens: number;
|
||||
|
||||
#menuRoleActions: MenuRoleAction[] = [];
|
||||
|
||||
@@ -172,7 +175,7 @@ export class ModelSelectorComponent extends Container {
|
||||
scopedModels: ReadonlyArray<ScopedModelItem>,
|
||||
onSelect: RoleSelectCallback,
|
||||
onCancel: () => void,
|
||||
options?: { temporaryOnly?: boolean; initialSearchInput?: string },
|
||||
options?: { temporaryOnly?: boolean; initialSearchInput?: string; currentContextTokens?: number },
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -183,6 +186,9 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#onSelectCallback = onSelect;
|
||||
this.#onCancelCallback = onCancel;
|
||||
this.#temporaryOnly = options?.temporaryOnly ?? false;
|
||||
const currentContextTokens = options?.currentContextTokens ?? 0;
|
||||
this.#currentContextTokens =
|
||||
Number.isFinite(currentContextTokens) && currentContextTokens > 0 ? Math.floor(currentContextTokens) : 0;
|
||||
const initialSearchInput = options?.initialSearchInput;
|
||||
|
||||
// Initialize menu role actions (built-in + custom from settings)
|
||||
@@ -215,8 +221,8 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#searchInput.setValue(initialSearchInput);
|
||||
}
|
||||
this.#searchInput.onSubmit = () => {
|
||||
// Enter on search input opens menu if we have a selection
|
||||
if (this.#filteredModels[this.#selectedIndex]) {
|
||||
// Enter on search input opens menu if we have an enabled selection
|
||||
if (this.#getSelectedItem()) {
|
||||
this.#openMenu();
|
||||
}
|
||||
};
|
||||
@@ -460,7 +466,11 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#filteredModels = models;
|
||||
this.#canonicalModels = canonicalModels;
|
||||
this.#filteredCanonicalModels = canonicalModels;
|
||||
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1));
|
||||
const visibleModels = this.#isCanonicalTab() ? canonicalModels : models;
|
||||
this.#selectedIndex = this.#coerceSelectedIndex(
|
||||
Math.min(this.#selectedIndex, Math.max(0, visibleModels.length - 1)),
|
||||
visibleModels,
|
||||
);
|
||||
}
|
||||
|
||||
async #loadModels(): Promise<void> {
|
||||
@@ -626,6 +636,74 @@ export class ModelSelectorComponent extends Container {
|
||||
return this.#getActiveTabId() === CANONICAL_TAB;
|
||||
}
|
||||
|
||||
#isModelOverContextLimit(model: Model): boolean {
|
||||
const contextWindow = model.contextWindow ?? 0;
|
||||
return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow;
|
||||
}
|
||||
|
||||
#isItemDisabled(item: ModelItem | CanonicalModelItem): boolean {
|
||||
return this.#isModelOverContextLimit(item.model);
|
||||
}
|
||||
|
||||
#formatContextLimitSuffix(model: Model): string {
|
||||
if (!this.#isModelOverContextLimit(model)) {
|
||||
return "";
|
||||
}
|
||||
return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow).toLowerCase()}`;
|
||||
}
|
||||
|
||||
#getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
|
||||
return this.#isCanonicalTab() ? this.#filteredCanonicalModels : this.#filteredModels;
|
||||
}
|
||||
|
||||
#coerceSelectedIndex(
|
||||
index: number,
|
||||
visibleItems: ReadonlyArray<ModelItem | CanonicalModelItem> = this.#getVisibleItems(),
|
||||
): number {
|
||||
const maxIndex = visibleItems.length - 1;
|
||||
if (maxIndex < 0) {
|
||||
return 0;
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(index, maxIndex));
|
||||
const clampedItem = visibleItems[clamped];
|
||||
if (clampedItem && !this.#isItemDisabled(clampedItem)) {
|
||||
return clamped;
|
||||
}
|
||||
for (let i = clamped + 1; i <= maxIndex; i++) {
|
||||
const item = visibleItems[i];
|
||||
if (item && !this.#isItemDisabled(item)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
for (let i = clamped - 1; i >= 0; i--) {
|
||||
const item = visibleItems[i];
|
||||
if (item && !this.#isItemDisabled(item)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
#moveSelection(delta: number): void {
|
||||
const visibleItems = this.#getVisibleItems();
|
||||
const count = visibleItems.length;
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
let index = this.#selectedIndex;
|
||||
for (let step = 0; step < count; step++) {
|
||||
index = (index + delta + count) % count;
|
||||
const item = visibleItems[index];
|
||||
if (item && !this.#isItemDisabled(item)) {
|
||||
this.#selectedIndex = index;
|
||||
this.#updateList();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.#selectedIndex = this.#coerceSelectedIndex(this.#selectedIndex, visibleItems);
|
||||
this.#updateList();
|
||||
}
|
||||
|
||||
#filterModels(query: string): void {
|
||||
const activeTabId = this.#getActiveTabId();
|
||||
const activeProviderId = this.#getActiveProviderId();
|
||||
@@ -696,8 +774,11 @@ export class ModelSelectorComponent extends Container {
|
||||
this.#filteredCanonicalModels = baseCanonicalModels;
|
||||
}
|
||||
|
||||
const visibleCount = isCanonicalTab ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
|
||||
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, visibleCount - 1));
|
||||
const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
|
||||
this.#selectedIndex = this.#coerceSelectedIndex(
|
||||
Math.min(this.#selectedIndex, Math.max(0, visibleItems.length - 1)),
|
||||
visibleItems,
|
||||
);
|
||||
this.#updateList();
|
||||
}
|
||||
|
||||
@@ -778,6 +859,7 @@ export class ModelSelectorComponent extends Container {
|
||||
|
||||
const showProvider = this.#getActiveTabId() === ALL_TAB;
|
||||
|
||||
const rows: string[] = [];
|
||||
// Show visible slice of filtered models
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = visibleItems[i];
|
||||
@@ -786,6 +868,8 @@ export class ModelSelectorComponent extends Container {
|
||||
const providerItem = isCanonicalTab ? undefined : (item as ModelItem);
|
||||
|
||||
const isSelected = i === this.#selectedIndex;
|
||||
const isDisabled = this.#isItemDisabled(item);
|
||||
const disabledSuffix = this.#formatContextLimitSuffix(item.model);
|
||||
|
||||
// Build role badges (inverted: color as background, black text)
|
||||
const roleBadgeTokens: string[] = [];
|
||||
@@ -815,34 +899,42 @@ export class ModelSelectorComponent extends Container {
|
||||
if (isCanonicalTab) {
|
||||
const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
|
||||
const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
|
||||
line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}`;
|
||||
line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}${disabledSuffix}`;
|
||||
} else if (showProvider) {
|
||||
const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
|
||||
line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}`;
|
||||
line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}${disabledSuffix}`;
|
||||
} else {
|
||||
line = `${prefix}${theme.fg("accent", item.id)}${badgeText}`;
|
||||
line = `${prefix}${theme.fg("accent", item.id)}${badgeText}${disabledSuffix}`;
|
||||
}
|
||||
} else {
|
||||
const prefix = " ";
|
||||
if (isCanonicalTab) {
|
||||
const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
|
||||
const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
|
||||
line = `${prefix}${item.id}${variants}${backing}${badgeText}`;
|
||||
line = `${prefix}${item.id}${variants}${backing}${badgeText}${disabledSuffix}`;
|
||||
} else if (showProvider) {
|
||||
const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
|
||||
line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}`;
|
||||
line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}${disabledSuffix}`;
|
||||
} else {
|
||||
line = `${prefix}${item.id}${badgeText}`;
|
||||
line = `${prefix}${item.id}${badgeText}${disabledSuffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.#listContainer.addChild(new Text(line, 0, 0));
|
||||
if (isDisabled) {
|
||||
line = theme.fg("dim", Bun.stripANSI(line));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < visibleItems.length) {
|
||||
const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${visibleItems.length})`);
|
||||
this.#listContainer.addChild(new Text(scrollInfo, 0, 0));
|
||||
if (rows.length > 0) {
|
||||
const sv = new ScrollView(rows, {
|
||||
height: rows.length,
|
||||
scrollbar: "auto",
|
||||
totalRows: visibleItems.length,
|
||||
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
||||
});
|
||||
sv.setScrollOffset(startIndex);
|
||||
this.#listContainer.addChild(sv);
|
||||
}
|
||||
|
||||
// Show error message or "no results" if empty
|
||||
@@ -863,8 +955,14 @@ export class ModelSelectorComponent extends Container {
|
||||
const suffix = isCanonicalTab
|
||||
? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)`
|
||||
: "";
|
||||
const limitWarning = this.#isItemDisabled(selected)
|
||||
? theme.fg(
|
||||
"dim",
|
||||
` — current context ${formatNumber(this.#currentContextTokens).toLowerCase()} > ${formatNumber(selected.model.contextWindow).toLowerCase()} limit`,
|
||||
)
|
||||
: "";
|
||||
this.#listContainer.addChild(
|
||||
new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`), 0, 0),
|
||||
new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`) + limitWarning, 0, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -890,7 +988,8 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
|
||||
#openMenu(): void {
|
||||
if (!this.#getSelectedItem()) return;
|
||||
const selectedItem = this.#getSelectedItem();
|
||||
if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
|
||||
|
||||
this.#isMenuOpen = true;
|
||||
this.#menuStep = "role";
|
||||
@@ -978,26 +1077,20 @@ export class ModelSelectorComponent extends Container {
|
||||
|
||||
// Up arrow - navigate list (wrap to bottom when at top)
|
||||
if (matchesSelectUp(keyData)) {
|
||||
const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
|
||||
if (itemCount === 0) return;
|
||||
this.#selectedIndex = this.#selectedIndex === 0 ? itemCount - 1 : this.#selectedIndex - 1;
|
||||
this.#updateList();
|
||||
this.#moveSelection(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Down arrow - navigate list (wrap to top when at bottom)
|
||||
if (matchesSelectDown(keyData)) {
|
||||
const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
|
||||
if (itemCount === 0) return;
|
||||
this.#selectedIndex = this.#selectedIndex === itemCount - 1 ? 0 : this.#selectedIndex + 1;
|
||||
this.#updateList();
|
||||
this.#moveSelection(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter - open context menu or select directly in temporary mode
|
||||
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
||||
const selectedItem = this.#getSelectedItem();
|
||||
if (selectedItem) {
|
||||
if (selectedItem && !this.#isItemDisabled(selectedItem)) {
|
||||
if (this.#temporaryOnly) {
|
||||
// In temporary mode, skip menu and select directly
|
||||
this.#handleSelect(selectedItem, null);
|
||||
@@ -1020,7 +1113,7 @@ export class ModelSelectorComponent extends Container {
|
||||
}
|
||||
#handleMenuInput(keyData: string): void {
|
||||
const selectedItem = this.#getSelectedItem();
|
||||
if (!selectedItem) return;
|
||||
if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
|
||||
|
||||
const optionCount =
|
||||
this.#menuStep === "thinking" && this.#menuSelectedRole !== null
|
||||
@@ -1079,6 +1172,9 @@ export class ModelSelectorComponent extends Container {
|
||||
role: string | null,
|
||||
thinkingLevel?: ConfiguredThinkingLevel,
|
||||
): void {
|
||||
if (this.#isItemDisabled(item)) {
|
||||
return;
|
||||
}
|
||||
// For temporary role, don't save to settings - just notify caller
|
||||
if (role === null) {
|
||||
this.#onSelectCallback(item.model, null, undefined, item.selector);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user