Codemode and write rows keep their last highlighting in a CodeHighlighter
that checks theme and grammar changes itself. Markdown reuses the top-level
blocks of its last render, held weakly, and assistant messages reuse their
Markdown instances while streaming. Text.setText() keeps its wrapped lines
for unchanged text.
closes#10971
Fixes#4748.
## Problem
Extensions that import `keyText`/`keyHint` from
`@earendil-works/pi-coding-agent` can resolve their own copy of the
package (their own `node_modules`, or the bundled `cli.js` vs
`dist/index.js`). That copy's keybindings are never set by the host, so
it falls back to a manager with only `tui.*` bindings, and
`keyHint("app.tools.expand", "to expand")` renders as ` to expand`.
## Change
The host passes its own `keyText` and `keyHint` to extension renderers,
the same way it passes `theme`:
- `ToolRenderContext` (`renderCall`/`renderResult` context):
`context.keyHint(...)`
- `MessageRenderOptions` and `EntryRenderOptions`:
`options.keyHint(...)`
All three extend a new exported `KeybindingHints` interface. The docs
and the `debug-provider` example now use the injected functions and warn
against importing them in renderers.
Not done:
- Moving the `pi-tui` keybindings singleton to `globalThis`. Per the
discussion in #7011, extension-facing state should be host-owned and
passed through the extension API instead of shared through process-wide
module state.
- `ctx.ui.keyText`/`keyHint`. No reported case needs them outside
renderers, RPC and print modes never install app keybindings, and adding
them to `ExtensionUIContext` would break SDK hosts that implement it.
## Tests
- `test/extension-keybinding-hints.test.ts`: tool, message, and entry
renderers format a user-overridden `app.tools.expand` binding.
- `npm run check` passes.
Fixes#11592.
## Problem
WSL starts Windows programs in the current directory, translated to
`\\wsl.localhost\<distro>\<cwd>`. When that path exceeds MAX_PATH, every
Windows program fails to start with `Invalid argument`. pi inherits its
cwd when spawning `powershell.exe`, so in a deep directory:
- clipboard image paste returns `null` (the PowerShell backend fails,
stderr is discarded)
- copy via `powershell.exe` (WSL without WSLg) fails
- opening the browser for sign-in fails (`xdg-open` on WSL runs
`explorer.exe`)
Reproduced on WSL (FedoraLinux-44): `powershell.exe` and `cmd.exe` work
from a 225-char cwd and fail from 230. With an image on the Windows
clipboard, `readClipboardImage()` returns the PNG from `/tmp` and `null`
from a 245-char cwd; with this change it returns the PNG from both.
`LongPathsEnabled=1` does not help.
## Changes
- `runClipboardCommand` and `openBrowser` run from `/` on POSIX. These
tools never use the cwd (temp files are passed by absolute path), and
`/` is always the shortest UNC path. `tmpdir()` was not used because
`$TMPDIR` can itself be deep.
- The external editor keeps pi's cwd on purpose, so project-relative
paths and integrated terminals keep working. A Windows editor such as
`code` can still fail from a deep WSL directory, as with git's
`core.editor`. A comment documents this.
- External editor failures were silent: the UI redrew over the error
immediately. pi now shows `Failed to launch external editor: <cmd>`,
`External editor exited with code N; prompt unchanged`, or the signal
name. This shows in the status line for the main editor and inline in
the extension editor dialog.
## Testing
- New tests: clipboard commands run from `/` (POSIX); an editor that
cannot be launched is reported (POSIX); the existing failure test checks
the message.
- Verified on WSL by running the real modules: clipboard image paste
from a deep cwd, and editor success, exit code 1, launch failure, and
SIGTERM.
- On Windows, the 3 existing `external-editor.test.ts` tests already
fail on `main`: the editor command is split on spaces, so `C:\Program
Files\nodejs\node.exe` breaks. Not addressed here.
This PR is AI-generated.
Fixes#10488
## Problem
On Windows, `c:\Users\me` and `C:\Users\me` are the same directory, but
pi compared paths as exact strings (`===`, `startsWith`, map and set
keys). `realpathSync` keeps the input casing, so canonicalizing does not
help.
The reported case, with cwd `c:\Users\me\project` and home
`C:\Users\me`:
1. Ancestor discovery finds `c:\Users\me\.agents\skills`. The exclusion
of the user dir `C:\Users\me\.agents\skills` does not match, so global
skills load as project skills.
2. The same dir loads again as user skills. Dedup does not match either,
so every skill is reported as a name collision.
3. `hasTrustRequiringProjectResources` treats `~/.agents/skills` as a
project resource that requires trust.
The same pattern affected other places:
- Saved trust decisions in `trust.json` were not found when cwd casing
differed, so pi asked for trust again.
- With a custom session dir, `--continue`, resume by id, and the session
list hid sessions recorded with different cwd casing.
- Git worktree `AGENTS.md` shadowing failed when cwd casing differed
from git's paths.
- Local package matching (`pi remove c:\pkg` vs configured `C:\pkg`) and
package identity across global and project settings.
- Extension preload matching, skill scope classification, resource
source info, the config and session selector keys, and the session
import self-check.
Separately, home-to-`~` shortening used an unanchored `startsWith(home)`
in four places, so `/home/me2/x` was shown as `~2/x`.
## Fix
New helpers in `utils/paths.ts`, all based on one comparison key:
- `getPathComparisonKey(path)`: the resolved path, lowercased on
Windows. It does not touch the filesystem.
- `pathsEqual(a, b)`
- `isPathWithin(parent, child)`: true for equal paths or paths inside
`parent`, matching whole path segments.
- `shortenHomePath(path, home?)`: replaces a leading home dir with `~`.
The helpers pick `path.win32` or `path.posix` from `process.platform` at
call time, so tests can exercise real Windows paths (drive letters,
UNC).
`getResourcePathKey(path)` in `source-info.ts` is the dedup key for
resources. Synthetic identifiers (`builtin:<name>`, `<inline:...>`) stay
unchanged, because they are not filesystem paths and their lookup is
case-sensitive. File paths go through realpath plus the comparison key.
The helpers are used only for comparison; stored and displayed paths are
unchanged. Trust store lookups and `set` match keys case-insensitively
on Windows.
Unchanged on purpose:
- The package install-root guards (`resolveManagedPath`,
`pruneEmptyGitParents`, the `node_modules` dependency check) keep exact
string checks. Both sides come from the same root string, and exact is
the stricter option.
- Checks based on `path.relative()` (`getCwdRelativePath`, the changelog
containment check) were already correct: Node's `path.win32.relative`
ignores case.
NTFS can enable per-directory case sensitivity, but it is rarely used
outside WSL interop, so it is ignored (see the comment on
`getPathComparisonKey`).
## Tests
- `test/paths.test.ts`: Windows drive-letter, UNC and root cases, POSIX
case sensitivity, segment-boundary containment, and home shortening,
including root and UNC homes.
- `test/source-info.test.ts`: file paths fold case on Windows; builtin
and inline identifiers keep their case.
- `test/trust-manager.test.ts`: the `.agents/skills` exclusion with a
lowercase `HOME`, plus saved trust decisions matched and replaced across
casing (simulated win32).
An end-to-end test of the reported loader scenario is not feasible on
Linux: the loader resolves paths with POSIX `node:path`, and the
filesystem is case-sensitive.
`npm run check` passes. The coding-agent suite passes, except for 13
failures that also occur without this change: clipboard tool detection
in this environment, and one test that needs built `dist/` output.
At startup, interactive mode printed every skill diagnostic under the
heading `[Skill conflicts]`. Most of these are validation warnings for
skills that still load, for example `description exceeds 1024 characters
(1508)` or name-format warnings. The heading made them look like name
collisions.
This change renames the heading to `[Skill warnings]`. Validation and
loading behavior are unchanged. The prompt and theme headings are left
as they are.
**Testing**
- Updated the assertion in
`packages/coding-agent/test/interactive-mode-status.test.ts` ("still
shows diagnostics on quiet startup when requested") to expect `[Skill
warnings]`. It fails against the old heading and passes with the change.
All 37 tests in that file pass.
- `npm run check` reports six type errors in `packages/ai` tests about
model IDs. Unchanged `origin/main` reports the same six errors, so they
are unrelated to this change. It reports nothing in `coding-agent`.
Fixes#11521
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
tui.select.pageUp/pageDown were configurable but ignored by SelectList,
SettingsList, editor autocomplete, and most coding-agent selectors. In
fullscreen mode, the default PageUp/PageDown also scrolled the transcript
instead of reaching the focused list.
- add getSelectPageIndex() and handle page keys in SelectList, SettingsList,
editor autocomplete, and the model, scoped models, auth, user message,
settings submenu, llama search, and /mcp selectors
- add Component.capturesViewportInput: a focused component that sets it
receives fullscreen viewport keys instead of the transcript, as a focused
overlay already does
- let page keys move an open autocomplete menu before app actions, so a
remapped Ctrl+V pages the menu instead of pasting an image
closes#7629
Co-authored-by: muyiyr <940955635@qq.com>
Addresses #5958.
Adds `changelogPath` to `PiManifest`. Safe package-relative changelog
resolution, with `CHANGELOG.md` fallback, and carries it through
`PathMetadata`/`SourceInfo`.
Added extension changelog handling like Pi’s own changelog.
If the package exposes `pi.changelogPath` or has `CHANGELOG.md`, Pi
stores the last seen package version in settings. On each fresh
interactive launch, we compare the stored version to the installed
package version. Then parses the changelog entries in between, shows an
Extension Updates section, then marks the current version as seen. The
first time an extension is seen, its version is recorded without showing
a changelog, same as Pi's own on a fresh install. `collapseChangelog`
condenses this to one line per package.
Any update that bumps the extension's version shows this on the next
launch, e.g.:
```bash
pi update --extensions
pi update --all
pi update <extension-source>
pi update --extension <extension-source>
pi update --self --extensions
pi update pi --extensions
pi update self --extensions
```
<img width="798" height="626" alt="Screenshot 2026-08-28 at 16 34 15"
src="https://github.com/user-attachments/assets/c882f01c-94b9-4857-b9b7-243d88c0d437"
/>
In fullscreen mode, the startup `Model scope: …` notice was still in the
shell after pi exited. With `fullscreenExitOutput: "resume-hint"`, the
shell showed it above the resume command.
Cause: `InteractiveMode.init()` printed the notice with `console.log`
before the renderer started. In fullscreen mode that output goes to the
terminal's normal screen. Pi then draws on the alternate screen, and
when it exits the terminal switches back to the normal screen, where the
notice is still visible.
The change:
- Building the notice text moves into `getModelScopeNotice()`.
- Regular mode prints it with `console.log` before the TUI starts, as
before.
- Fullscreen mode adds it to the header container below the built-in
header. It uses a `ThemedText`, so the text is built after the terminal
reports its colors and follows theme changes.
Since fullscreen is the default `tuiMode`, this applies to every user
who has `enabledModels` set and quiet startup off.
Testing:
- Added
`packages/coding-agent/test/interactive-mode-model-scope.test.ts`. It
checks two things for fullscreen: the notice renders in the header and
`console.log` is not called, and nothing is added when startup details
are hidden.
- `npm run check` passes.
- Manual check in tmux with `enabledModels` set:
- fullscreen + `resume-hint`: before the change, `Model scope:
claude-sonnet-4-5, claude-opus-4-5 (Ctrl+P to cycle)` was left in the
shell after `/quit`. Now the line shows inside pi's header and the shell
is clean after exit.
- `tuiMode: "regular"`: unchanged, the line is still printed above the
header.
Fixes#11508
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
Inline `ctx.ui.custom()` components never report pi as blocked through
OSC 7501 program status. This is on purpose, because the same API hosts
loaders. As a result, a custom dialog that waits for the user (an
approval prompt, the `question.ts` example) shows up as `working`, and
the terminal cannot tell that pi needs input.
The extension dialog stack already stores a blocked status for each
dialog and reports the visible one. This change adds an optional
`blocked` option to `ctx.ui.custom()`:
```ts
ctx.ui.custom(factory, { blocked: { kind: "question", message: "Pick a color" } });
```
The inline branch of `showExtensionCustom()` passes it to
`pushExtensionDialog()`. Components without the option behave as before.
Overlays ignore the option because they are not part of the dialog
stack. `docs/tui.md` documents the option.
Testing:
- Added a test to `test/interactive-mode-extension-dialogs.test.ts`:
- A custom component that opts in reports `blocked`.
- A plain custom component stacked on top of it clears the blocked
status.
- Closing the plain component restores the blocked status.
- Closing the opted-in component clears it.
- The test fails without the change to `interactive-mode.ts` and passes
with it.
- `npm run check`: biome and lint pass, and there are no type errors in
coding-agent. The only errors are existing type errors in
`packages/ai/test` from locally generated model data. They are not
related to this change.
Fixes#11428
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
When thinking was hidden and the hidden-thinking label was set to `""`
(for example with `ctx.ui.setHiddenThinkingLabel("")`), every assistant
message that contained thinking still drew blank rows:
- thinking followed by a tool call drew 2 blank rows;
- thinking followed by text drew 3 blank rows before the text.
`AssistantMessageComponent.updateContent()` had three causes:
- It counted hidden thinking as visible content when deciding on the
leading and trailing spacers.
- It always added the label component. With an empty label the theme's
escape codes are still there, so the component rendered one blank row.
**What changed**
- A hidden thinking run with an empty label is now skipped entirely. Its
run index is still advanced, so click overrides on other runs keep their
indices.
- Spacers are now added lazily, just before the next visible block. The
result is the same as before: one spacer before the first visible block,
and one after a visible thinking run that is followed by more visible
content. But spacing now follows what actually renders.
- Default behaviour (the "Thinking..." label, or expanded thinking) is
unchanged.
**Testing**
- Added two tests to `test/assistant-message.test.ts`:
- a hidden thinking run with an empty label renders nothing, both before
a tool call (`[]`) and before text (`["", "answer"]`);
- with the default label, rendering still gives `["", "Thinking...", "",
"answer"]`.
- The empty-label test fails without the fix and passes with it.
- `npm run check` passes. The `test/assistant-message.test.ts` and
`test/interactive-mode*` tests pass.
- Rendering the component directly, with hidden thinking and an empty
label:
- thinking + tool call: before, 2 blank rows; after, `[]`;
- thinking + text: before, 3 blank rows then the text; after, `["", "
hi"]`.
Fixes#11402
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
`ctx.ui.setEditorComponent()` always cleared the editor slot and put the
new editor there with focus. If an extension dialog
(`confirm`/`select`/`input`/`editor`, or an inline `custom()` component)
was open at that moment, the editor replaced it on screen. The dialog
was still on the dialog stack and its promise still pending, but nobody
could see or answer it. It hung until its timeout, or forever without
one.
Now `setCustomEditorComponent` leaves the editor slot and focus alone
while a dialog is open. The new editor is still created and wired up,
and `showTopExtensionDialog` shows it when the last dialog closes.
Without an open dialog nothing changes.
Testing:
- Added a regression test to
`test/interactive-mode-extension-dialogs.test.ts`. It opens a confirm,
replaces the editor, then checks that the confirm stays visible and
answerable and that the new editor is focused afterwards. It fails
without the fix and passes with it.
- `npm run check` passes.
- Ran the reproduction from the issue in tmux (`pi -e ./a.ts -e ./b.ts`,
where a.ts opens a non-blocking `confirm` in `session_start` and b.ts
calls `setEditorComponent`).
- Before: only the editor shows, and the confirm times out.
- After: the confirm shows, and Enter gives `answer: true`.
Fixes#11431
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
- ai: OAuth logins only accept http(s) URLs from servers (shared
trustedHttpUrl, now also checked by Radius); pi-messages rejects
contentIndex values that are not the next or an existing block; the
CLI auth file is written with mode 0600
- ai: OpenCode DeepSeek V4.1 Flash (renamed from V4 Flash upstream) no
longer sends prompt_cache_retention
- tui: setTitle() replaces control characters; export
replaceControlCharacters()
- coding-agent: the login dialog only opens web URLs and never prints
control characters from sign-in, device-code, or info URLs
- durable: partial commits no longer merge a new __proto__ member into
Object.prototype
- ai: StreamingJsonParser / ToolCallArgumentsStream read each argument delta once (tolerant,
immutable snapshots, linear cost); every provider, pi-messages and the faux provider use it
and keep stream state (partialJson, partialArgs, index, customInput, streamIndex, Bedrock
reasoning bytes) off the content blocks; grammar tool input appends per delta
- agent: streamProxy uses the same parser
- chord: Delta append ops own their text (ownedSlice), so ops and replicas no longer keep the
whole growing string alive
- durable, ai, mcp: bounded error excerpts and summaries own their characters
- coding-agent: tool calls render streamed arguments once per frame, not once per delta
/model and /scoped-models listed only `id [provider]`, so renamed
overrides from models.json were invisible in the list. Rows now show
the name in parentheses when it has words the id lacks, e.g.
`minimax/minimax-m3 (MiniMax M3 [parasail]) [openrouter]`. Names that
only restate the id with different casing, separators, or a dropped
namespace/version suffix stay hidden.
closes#5916
fuzzyMatch scored only the greedy leftmost alignment, so a query could be
scattered over an earlier unrelated part of the text (opus via "openrouter",
astra via "radius radius/gpt") and the real match ranked last. It now also
scores every contiguous occurrence and keeps the best alignment.
/resume matched one concatenated id/name/messages/cwd string. Scattered
matches in megabytes of message text or in UUIDs were noise, and the
position penalty buried real hits deep in a transcript. Fields are now
matched separately: name and cwd fuzzy, id and messages exact only, with no
position penalty. Ranking is name text, then id/message/cwd text, then
scattered name/cwd matches. Lowercased message text is cached per session.
closes#11220
Knowing how annoying re-logins in corporate environments can be, I think
we can open the browser and copy the code to the clipboard (what the user
asks for) without forcing it upon users (which I assume was the reason
it was removed in 8f0e9251b9).
This change adds optional browser opening and code copying during
device-code login after an additional confirmation (usually Enter).
It should work for all providers, with an optional opt-out controlled
by the provider author, not user settings.
Fixes#9282.
* feat(coding-agent): add messageId and timestamp to markdown transformer context
* fix(coding-agent): update compaction render test for messageMeta argument
---------
Co-authored-by: Petr Houška <houskape@gmail.com>
A second select/confirm/input/editor dialog or inline custom() component
replaced the visible one and left its promise pending forever. Dialogs in
the editor slot now form a stack: closing the visible dialog shows the
previous one again, and dialogs opened from inside a custom component work.
closes#11166
done() from ctx.ui.custom({ overlay: true }) closed the overlay with
ui.hideOverlay(), which removes the topmost overlay instead of the one
that called done(). With a shell overlay open under a dialog overlay
from another extension, the shell's exit removed the dialog: the
finished shell overlay stayed on screen ignoring input and the dialog's
promise never resolved. Hide the overlay through its own handle.
closes#10648
Print mode now writes a warning to stderr and exits 1 when the final
response stopped at the model's output token limit. The partial text
is still written to stdout.
fixes#10799
* feat(coding-agent): use pi.dev configuration schemas
* refactor(coding-agent): separate schema URL from output path
* docs(coding-agent): document published schema URLs
Editor and Input wrap their drawn cursor in fake cursor markers. The TUI
resolves them to reverse video, or drops the focused one when the hardware
cursor is shown, so terminals that extend edge cell colors into the window
padding no longer stretch the drawn cursor at the first or last column.
Also keep CURSOR_MARKER when an overlay ends directly left of the cursor.
Report idle, working, blocked, done, and error to terminals that support
the Program Status Protocol. ProcessTerminal sends the support query with
the keyboard query and reports only after the terminal answers before DA1;
PI_PROGRAM_STATUS=1|0 overrides detection. Interactive mode reports runs,
compaction, extension dialogs, and logins. agent_settled now carries
aborted so cancelled runs report idle.
closes#10607
pi-ai's AssistantMessageEventStream sets AssistantMessage.durationMs with a
monotonic clock for every API implementation. The agent loop and the durable
tool task set ToolResultMessage.durationMs around execute(). Durable task
records carry startedAt/endedAt stamped by the Session. The coding agent's bash
renderer shows Took from the recorded duration, also after a reload.
closes#10549
Selection coordinates survived session switches and other transcript rebuilds, highlighting unrelated text in the new transcript. Reset selection and multi-click state before re-rendering session entries.
Based on #9310.
fixes#9311
Co-authored-by: xdagiz <xdagiz@protonmail.com>
* fix(coding-agent): keep syntax colors on multiline tokens
- format each line of a highlighted span separately
- add regression test for python docstrings
- fixes#10143
* fix(coding-agent): use text color for string interpolations
- map highlight.js `subst` scope to text color
When the terminal goes away, stdin reads and setRawMode fail with EIO (pi left in an orphaned background process group) or ENOTTY (revoked tty on macOS). These were uncaught, recorded in crashes.json, and prompted users to run /bug. Handle them on stdin like stdout/stderr and skip crash recording for dead terminal errors.
Resolve the QuickJS wasm path once and spawn the bundled codemode worker
from an in-memory data: URL. Show a restart hint when errors occur after
the install changed on disk.
closes#10439
Add pi.registerToolRenderer((toolName, next) => renderers), which chooses the
renderers for calls to any tool, including tools that are not registered.
Resolvers run in extension load order; next() returns what the remaining
resolvers, then the registered tool, would use. Interactive mode and HTML
export both resolve through it.
The MCP extension uses it to render every mcp__<server>__<tool> call with the
MCP renderers, so calls in a resumed session no longer render fully expanded
before their server connects, or for good if it never does.
closes#10285
Kitty-protocol terminals only accept PNG. pi-tui now exposes
setImageTranscoder(); Image converts JPEG/GIF/WebP through it and shows
the text fallback without one. coding-agent registers a photon-based
transcoder, so images from extensions and all ToolExecutionComponent
hosts render.
closes#10292
Login and MCP sign-in screens show a copy hint and copy the URL to the
clipboard (OSC 52 over SSH). /mcp login in the TUI now uses the MCP
sign-in screen instead of a notification.
Replaces the 3D pi logo easter egg on header logo click. Armin's bitmap is
built from one cube per pixel, using the theme accent color. Regular mode
keeps the inline Armin art.
The top-level /login menu offers Sign in with Radius with its status,
with an animated shimmer on the Radius row and a short Radius intro.
After a Radius sign-in it offers to configure the Radius MCP server in
the global mcp.json with auth.provider and reloads. Cancelling a login
returns to the menu it was started from. OAuth sign-ins without a
subscription are labeled as accounts.
Co-authored-by: niloc <5925270+colindaymond@users.noreply.github.com>
Palette colors now keep the bell-curve falloff and are
also capped at the palette color's OKLCH chroma times that falloff.
Lightness and hue are unchanged, so contrast is unchanged.
closes#10255
The user message's Markdown pads and colors its own background instead of
sitting in a Box that kept a second full-width copy of every line. The output
is identical.
Tools without a custom call renderer now show their arguments as key=value
pairs when collapsed and one key: value line per argument when expanded.
MCP tools get a server/tool title and results that collapse to 5 lines.