dnouri/pi-coding-agent

The ergonomic agent harness for Emacs.

Emacs Lisp

293

291 commits

updated Sep 19, 2026

See the code

README

#+title: Pilish
#+author: Daniel Nouri
#+options: toc:nil num:nil

#+html: <a href="https://melpa.org/#/pilish"><img alt="MELPA" src="https://melpa.org/packages/pilish-badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/test-unit.yml"><img alt="Unit Tests" src="https://github.com/dnouri/pilish/actions/workflows/test-unit.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/test-integration.yml"><img alt="Integration Tests" src="https://github.com/dnouri/pilish/actions/workflows/test-integration.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/test-gui.yml"><img alt="GUI Tests" src="https://github.com/dnouri/pilish/actions/workflows/test-gui.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/nightly.yml"><img alt="Nightly" src="https://github.com/dnouri/pilish/actions/workflows/nightly.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/melpazoid.yml"><img alt="melpazoid" src="https://github.com/dnouri/pilish/actions/workflows/melpazoid.yml/badge.svg"/></a>

#+html: <p><em>Jump to:</em> <a href="#quick-start">Quick start</a> ·
#+html: <a href="#everyday-workflow">Everyday workflow</a>
#+html: (<a href="#composing-prompts">composing prompts</a>,
#+html: <a href="#reading-output">reading output</a>,
#+html: <a href="#sessions-and-context">sessions</a>,
#+html: <a href="#common-keys">shortcuts</a>) ·
#+html: <a href="#troubleshooting">Troubleshooting</a> ·
#+html: <a href="#how-it-compares">Comparison</a> ·
#+html: <a href="#advanced-features-and-configuration">Advanced</a> ·
#+html: <a href="#development">Development</a></p>

* What is Pilish?

#+html: <img align="right" src="assets/pilish-logo.svg" alt="Pilish Hornbridge logo" width="96" height="96">

Pilish is an Emacs frontend for [[https://pi.dev][Pi]], an open and
extensible coding agent.  A session runs in two windows: the
conversation renders as Markdown in the top window, and you compose
your next prompt in an ordinary Emacs buffer in the bottom one.

Because the prompt is a normal buffer, your editing habits apply
directly.  Write multi-line prompts, paste from other buffers, use the
kill ring, keyboard macros, registers, spell checking, Evil, and
anything else your setup gives you.  You can keep writing while the
answer streams in above, and scroll, search, and copy from previous
output with the keys you already use everywhere else.

Pi's own terminal UI and Pilish are two frontends to the same =pi=
CLI.  Pilish talks to Pi over its JSON-RPC mode, so Emacs receives
structured messages and tool events rather than terminal output.
Model providers, sessions, skills, and prompt templates carry over
unchanged, and Pi extensions work with basic UI support (see
=Extension support= under Advanced).

#+html: <a href="https://danielnouri.org/media/pi-coding-agent-demo.mp4"><img src="https://danielnouri.org/media/pi-coding-agent-preview.gif" alt="Narrated Pilish demo video (6:38) - click to play" width="600" height="600"></a>
#+html: <p><small>Video music: "Electrodoodle" by Kevin MacLeod
#+html: (<a href="https://incompetech.com">incompetech.com</a>),
#+html: licensed under <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.</small></p>

#+html: <a id="quick-start"></a>
* Quick start 🚀

If you already have Emacs 29.1 or later with tree-sitter support, this
is the shortest path to a working session.

** Requirements

- Emacs 29.1 or later, built with tree-sitter support
- Node.js 22.19 or later for the Pi CLI
- [[https://pi.dev][pi coding agent]] =@earendil-works/pi-coding-agent@0.85.0= or later,
  installed and in =PATH= on the host where Pi runs
- Pi CLI authentication: a provider API key, or a one-time =/login=
  run in a terminal (see below)
- A C compiler if Emacs needs to compile tree-sitter grammars

Upgrade older Pi installations before using Pilish, for example with
=npm install -g @earendil-works/pi-coding-agent@latest=.  Pi 0.85.0 or
later is required for run settlement, queue clearing, and compaction
abort handling; older protocol versions are not supported.

** Install pi and authenticate

#+begin_src bash
# Install the pi CLI
npm install -g @earendil-works/pi-coding-agent

# Or with mise
mise use -g npm:@earendil-works/pi-coding-agent@latest

# Authenticate the CLI: either set a provider API key (see below), or
# run `pi` interactively in a terminal and type `/login`.
#+end_src

Pi authenticates in one of two ways:

- *Subscription or API key* via =/login=: =/login= is interactive and
  unavailable from Emacs, so run plain =pi= once in a terminal and
  type =/login=; you can then pick a subscription such as ChatGPT
  Plus/Pro, or choose a provider and enter its API key.  Credentials
  are stored in =~/.pi/agent/auth.json= and auto-refresh, so later
  Emacs sessions pick them up automatically.

- *Provider API key, no terminal login:* export a provider
  environment variable, e.g. ~export ZAI_API_KEY=...~, or add an entry
  to =~/.pi/agent/auth.json=.  Pi supports many providers including
  DeepSeek, OpenAI, and Z.AI.  See the
  [[https://pi.dev/docs/latest/providers][providers documentation]] for the full set and its
  environment variables.

Either way, usage is billed as with any Pi frontend: per token by
your provider, or covered by your subscription.

** Install the Emacs package

Install from [[https://melpa.org/#/pilish][MELPA]].  MELPA alone does not
resolve every dependency right now: its =transient= build needs
=compat ≥ 31.0=, which is only published on
[[https://elpa.gnu.org/][GNU ELPA]], so add GNU ELPA as a second
archive before installing:

#+begin_src emacs-lisp
(require 'package)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(add-to-list 'package-archives '("gnu" . "https://elpa.gnu.org/packages/") t)
(package-refresh-contents)
#+end_src

Then install:

#+begin_src
M-x package-install RET pilish RET
#+end_src

You may also define a shorter command name:

#+begin_src emacs-lisp
(defalias 'pi 'pilish)
#+end_src

After that, =M-x pi= starts or focuses the current project's Pi
session.

** Your first exchange

Run =M-x pilish= in the directory of a project you want to work on.
Pilish opens the two windows, and on first start it offers to install
the tree-sitter grammars needed for Markdown rendering — a one-time
step that needs a C compiler (=gcc= or =cc=); say yes so the chat
buffer renders properly.

Type a prompt in the bottom window — =explain what this project does=,
for instance — and press =C-c C-c=.  Your text appears in the chat
under a =You= heading, and Pi's reply streams in under =Assistant=.
The header line above the input tracks the model, activity phase,
cost, and context usage while it works.

One default worth knowing up front: Pi never shows its project trust
prompt in RPC mode, so Pilish starts Pi with =--approve= and
project-local =.pi= resources (skills, prompts, extensions) are active
right away.  If you would rather rely on Pi's saved trust decisions,
see =pilish-project-trust-policy= under Troubleshooting.

If no models are available or Emacs cannot find the =pi= executable,
the Troubleshooting section below has the fixes.

#+html: <details>
#+html: <summary><strong>Upgrading from pi-coding-agent</strong></summary>

=pilish= 3.0 is the renamed =pi-coding-agent= package (now
[[https://github.com/dnouri/pilish][dnouri/pilish]] on GitHub).  The
rename is a clean break: old configurations need a one-time update,
and old-name settings do not carry over automatically.

1. Install =pilish=, then delete the old package:

   #+begin_src
   M-x package-install RET pilish RET
   M-x package-delete RET pi-coding-agent RET
   #+end_src

   Complete both steps before restarting Emacs: once the old package
   is deleted, an init file that still requires =pi-coding-agent=
   fails with "Cannot open load file" and skips the rest of your
   config.  (Skipping the =package-delete= breaks nothing, but both
   command sets stay installed and =M-x pi= runs whichever
   =defalias= form comes last.)

2. Rename =pi-coding-agent= to =pilish= in your init file: the
   =require= or =use-package= declaration, the =pi= alias, any
   =pi-coding-agent-*= variable names in =setq=, =setopt=, or
   =custom-set-variables=, and any =with-eval-after-load=, hook, or
   keybinding forms naming =pi-coding-agent= commands.  A missed old
   name raises no error at startup; it is silently ignored, so
   re-check your config after upgrading.

   #+begin_src emacs-lisp
   (require 'pilish)
   (defalias 'pi 'pilish)
   #+end_src

#+html: </details>

#+html: <a id="everyday-workflow"></a>
* Everyday workflow 🧭

A session has two windows:

- *Chat buffer* (top) — the rendered Markdown conversation, including
tool output and thinking blocks from previous turns.
- *Input buffer* (bottom) — a normal Emacs buffer where you write the
  next prompt.

The input header line shows the current model, thinking level,
activity phase, cost and context usage, session name, and extension
status when available, plus the basename and size of an attached
prompt image.  The model and thinking fields can be clicked to change
their values.  After context usage, =queued N= counts the queued
messages Pilish knows about and hides at zero; hover it for previews
of pending follow-ups and steering messages.  Pilish includes
backend queues only after Pi reports their contents, so the count
can lag, and follow-ups also include text waiting for prompt
acceptance.  A count that reaches zero does not by itself mean the
work has finished.

Type in the input buffer and press =C-c C-c= (=M-x pilish-send=) to
send.  If Pi is already working, the same key queues the text as a
follow-up and sends it when the current turn finishes.  Press
=C-c C-s= (=M-x pilish-queue-steering=) while Pi is busy to send a
steering message: it is delivered after the current tool call and
interrupts the remaining queued tools.  During compaction or while a
run is settling, =C-c C-s= queues a local follow-up instead, sent
when Pi is ready for another prompt.  Press =C-c C-k=
(=M-x pilish-abort=) to abort the current response or compaction.

Press =C-c C-p= for the transient menu.  It groups the remaining
commands — sessions, fork, compact, export, stats, model, thinking,
skills, and custom commands — and is the quickest way to discover
them.

If Pi works silently for five minutes, the input header says so, for
example =thinking (no output 5m)=.  That is a hint, not an error:
quiet tools and healthy subagents can produce no output at all.  See
Troubleshooting for what the warning counts and how to tune or
disable it.

#+html: <a id="composing-prompts"></a>
** Composing prompts

The input buffer is intentionally boring Emacs: every editing
feature your setup already gives you works there.  Your prompt stays
in the bottom window while the conversation streams above it.

Press =C-c C-p a i= to attach one image: select a file, or paste its
path into the file prompt.  Attaching another image replaces the
first, and =C-u= before the =i= clears it.  Images are recognized by
content rather than extension (PNG, JPEG, GIF, WebP), the source
limit is 3 MiB, and the header keeps the attached name and size
visible.  Send the image with a nonempty ordinary prompt while Pi is
idle and a vision-capable model is selected; the finer rules live
under =Images= in Advanced.

Slash commands work with completion: type =/= then =TAB= to complete
built-in commands and Pi commands such as prompt templates, skills,
and extension commands.  Prompt templates are discovered from places
such as =~/.pi/agent/prompts/=, and skills from
=~/.agents/skills/=.  File references also complete: use =TAB= on
paths such as =./=, =../=, and =~/=.  Absolute paths complete too,
except for a bare =/= at the start of the buffer, where slash-command
completion wins.

While composing, you can page through the chat without leaving the
input buffer.  Emacs's built-in =M-<prior>= and =M-<next>=
(=M+PageUp= / =M+PageDown=) scroll the other window, and Pilish
registers the chat as the input buffer's =other-window-scroll-buffer=,
so they scroll the linked chat.  If your terminal does not send those
keys, =C-M-v= and =C-M-S-v= (the classic other-window scrolling
commands) work for the same reason.

#+html: <a id="reading-output"></a>
** Reading output

The chat buffer renders Markdown with tree-sitter syntax highlighting
for code blocks and diffs.  Long tool output collapses to a preview
so the chat stays readable; press =TAB= on a tool block to expand or
collapse it.  Long-running commands stream output live, file
operations (=read=, =write=, =edit=) get syntax highlighting, and
edit diffs highlight what changed.  Wide Markdown pipe tables are
wrapped and redrawn to fit the window while the underlying text
stays plain Markdown.  Images sent with prompts and returned by
tools appear inline in graphical Emacs and as a type-and-size
placeholder in terminals; the limits are described under =Images= in
Advanced.

Hover completed replies, thinking, or tool blocks for timestamps and details:
reply provider/model and message-level token/cache usage, thinking line counts,
or short tool command/path hints.  Native =C-h .= shows full help at point,
including in terminals.  Stream durations, output rates, and tool timings are
approximate and live-only; reload or resume drops them.

Press =RET= on a file target to visit it: a file-content row in tool
output, a plain path reference, or the label of a local Markdown
link.  Locations are honored — =src/app.el:12:3= opens line 12,
column 3, and =src/app.el#L12-L20= opens line 12 — and =C-u RET=
flips the window choice for that one visit (see
=pilish-visit-file-other-window=).  Press =!= on a file target to run
a shell command on it, Dired-style: a single command word plus
options, like =wc -l= or =grep -n=, gets the file appended
automatically, while anything else needs an isolated =*= where the
path belongs, as in =grep -n TODO *=.  Press =w= to
copy the file's shell-local path.  The exact rules behind all three
live under =File and command actions= in Advanced.

Press =TAB= on a turn header (=You= or =Assistant=) to fold or unfold
that turn.  Use =n= and =p= in the chat buffer to jump between user
messages, and =f= to fork the conversation from the turn at point.

The chat header ends with a summary line showing the pi and Pilish
versions plus skill and prompt-template counts.  Press =TAB= on it to
expand sections listing context files, skills, prompt templates, and
extension commands, grouped by Project and User, with explicit paths
and unknown-scope resources kept separate; the extension section
lists registered commands.  Names link to their source files and
carry descriptions when available, so you can explore what is on
offer before running anything from =C-c C-p=.  =TAB= anywhere in the
details collapses the summary again.

#+html: <a id="sessions-and-context"></a>
** Sessions and context

Each project directory gets its own session automatically.  Running
=M-x pilish= again from a Pilish buffer restores missing windows, or
focuses the input window when both are visible.  =M-x pilish-toggle=
hides and shows the session windows in the current frame.

=C-c C-r=, =/resume=, or =M-x pilish-session-browser= opens the
session browser.  It reads Pi's session archive from disk —
=~/.pi/agent/sessions/= by default, or =$PI_CODING_AGENT_DIR/sessions/=
— so browsing, searching, renaming, and deleting work with no live
process at all.  Sessions with a currently live Pilish process in
this Emacs get a ● marker; the marker only sees Pilish processes in
this Emacs — sessions running in another Emacs or in a terminal are
not marked.
Press =g= to refresh markers.  Press =RET= to switch to the session
at point, and =?= for the browser menu.  =d= deletes a closed session
only after a confirmation that names its displayed session title and
project.  The configured =delete-by-moving-to-trash= action leads the
prompt; unsafe title, project, and child-name characters are sanitized
or replaced, while long metadata is ellipsized so the consequence stays
visible.  Deletion acts on the selected archive pathname: if that entry
is a symlink, Emacs removes or trashes the link, not its target, while
canonical alias identity is used for live-session checks and child-fork
matching.  If the full loaded archive snapshot contains direct child
forks, the prompt reports their count
(and up to three short titles): those files are /not/ deleted.  They
appear as roots if the parent no longer appears elsewhere in the
archive; their own descendants stay attached.  Deletion does not
cascade.  Pilish checks for a live session before and after the prompt,
but “live” means only a Pilish process in this Emacs, not a system-wide
process check.  After confirmation it also rejects an observed canonical
retarget, such as a selected symlink that now resolves to another target.
This is observation, not locking: an independent writer can replace a
path without changing its canonical spelling, or change it between the
final check and =delete-file=.  Cancelling or a deletion error leaves the
current browser snapshot in place; a successful deletion refreshes it.

The browser opens on this project's sessions, showing all names in
the Threaded (fork families) view; =t= toggles between this project
and All projects.  All-projects rows lead with a bounded project
token in a fixed-width field — the project directory's name,
prefixed with the host for remote sessions (=build-host:site=) —
followed by the live marker, and only then connectors and the
session title, so identity and live status stay visible on narrow
terminals no matter how deep the fork nesting or how long the
titles.  Projects sharing a directory name grow parent components
(=client-a/app= vs =client-b/app=) until distinct; labels that
cannot fit or distinguish fall back to an ordinal-front token
(=#ORD app=, with a compact base-36 ordinal); a session run in =/=
labels as =/=.  Windows drive
and UNC working directories are recognized lexically, and
this-project rows carry no token because the scope already fixes
the project.  =s= cycles the view:
- Threaded (fork families) — sessions grouped into fork families
  through the =parentSession= link that =/fork= and Pi's =/clone=
  write; each parent renders above its children, and families are
  ordered by the latest activity anywhere in the family, newest first.
- Recent activity — flat rows ordered by the session file's
  modification time, newest first, under calendar Future / Today /
  Yesterday / This Week / Older headings: a late-night session moves
  to Yesterday at midnight, a clock-skewed future mtime groups as
  Future at the top, and on Monday last week reads as Older (Sunday
  stays Yesterday).
- Most messages — flat rows ordered by the number of persisted
  message records, tool results included, highest first.

Threaded families and Recent time groups can be folded without turning
these flat rows into nested Magit sections.  =TAB= toggles the row itself
when it has displayed descendants; on a leaf it toggles the nearest
containing foldable row (a fork ancestor or Recent group).  =S-TAB= folds
all outer families/groups, and =C-u S-TAB= unfolds everything.  =^= goes
to the family root in Threaded or the group heading in Recent.  Most
messages and queried Threaded results have no fold target or parent unit;
Recent queries keep their time groups.  Folds survive rerenders and a
filter/search round trip; they are forgotten
when the corresponding session or group leaves the published snapshot.
=n= / =p= (and =M-n= / =M-p=) keep Magit's flat section motion and skip
rows hidden by a fold.

Search results are always flat — a query never draws family
connectors.  A queried Threaded (fork families) view orders the
rows newest-first; Most messages keeps its count ordering under a
query.  These starting states are customizable:
=pilish-session-browser-default-scope= (this or all projects),
=pilish-session-browser-default-view=,
=pilish-session-browser-default-named-only=, and — for the tree
browser — =pilish-tree-browser-default-filter=.  Each initializes a
browser buffer when it is created; a browser you have toggled keeps
its state when hidden with =q= and reopened.  Killing the buffer, or
explicitly re-running the browser major mode, re-initializes from the
current defaults.

Search matches session names, first messages, and all saved
user/assistant text, including inactive branches; thinking, tool
arguments and results, images, and summaries are not searched.
Tokens are whitespace-separated regexps that must all match.
Searching a very large archive can pause Emacs while results are
gathered; =q= hides the browser without cancelling its scan.  When
an empty listing or search result can be widened, it names up to two
of the most relevant keys: =t= switches scope, =f= clears named-only,
and submitting an empty =/= query clears the filter.  An empty
All-projects archive has no
wider scope and intentionally shows no action hint.

The tree browser (=C-c C-p w=, or Context =w= in the menu) shows the
conversation tree Pi persists for a session.  On its first load, point
starts at the current projected entry or its nearest visible active
ancestor, falling back to the first visible row when no active-path
row survives.  =@= marks the actual current entry and =*= marks its active
ancestors, so a filter that hides the current entry never mislabels an
ancestor as current.  Moving point yourself wins on later refreshes;
when a selected row disappears, point first tries its nearest visible
ancestor from the old snapshot, then the new active path.  =TAB= folds a
row with displayed descendants; on a leaf it folds the nearest displayed
ancestor that has descendants.  =S-TAB= folds all outer roots and
=C-u S-TAB= unfolds everything; =^= moves to the nearest visible parent.
Fold state survives rerenders and filter/search round trips, including
when an id temporarily disappears and returns, and is pruned when that id
leaves the published tree.  Point restoration never opens a user fold or
leaves point on an invisible row, and Magit's =n= / =p= and sibling motions
skip folded rows.  Switching
the linked chat to another session file resets this orientation even
when a fork shares node IDs with the old file.

Press =RET= to continue from the selected turn.  Its behavior depends
on the selected projected node:

| Selected node | What =RET= does |
|---------------+-----------------|
| The =@= current projected position | Strict no-op before live-process, busy/transition, disk, or draft checks.  Trailing projected-away bookkeeping does not change its identity. |
| Historical user prompt or user-like =custom_message= with a parent | Continues from the parent and prefills the selected prompt text when available.  If that parent is already current, this is prefill-only; otherwise Pilish rewrites the active path and resumes the same session file. |
| Historical root user prompt or root user-like =custom_message= | Has no parent to continue from, so Pilish refuses and points to the chat's fork command.  A root prompt that is itself =@= remains the strict no-op above. |
| Non-current assistant, tool result, branch or compaction summary, model/thinking change, or other node | Continues from that node itself by rewriting the active path and resuming the same session file.  Its prefill is applied when the navigation target provides one; most such nodes provide none. |

A continuation that would replace the input draft first checks the
existing input-buffer draft state: either nonblank text or an attached
prompt image triggers =Replace the unsent draft ...?=.  Declining keeps
both text and image and occurs before any session-file rewrite or resume
is scheduled.  Acceptance stays bound to the browser, linked chat, and
session file that opened the question, then re-runs the live-session and
disk/target checks.  If that browser dies or its chat/file changes,
navigation is cancelled rather than reinterpreting the node ID in a new
tree; a same-file refresh remains valid.  If the draft changes while the
question is open, Pilish asks about the newer draft instead.  If a newer
nonempty draft appears during later yielding file-handler work, Pilish
keeps it and skips the prefill.  Blank text with no image proceeds
without a prompt, and the =@= no-op never prompts.  This is a targeted
loss-prevention check, not a blanket confirmation on every =RET=.

For a path-changing continuation, Pilish preserves complete raw JSONL
lines, writes the new order to a sibling temporary file, and replaces an
ordinary local session file with one atomic rename before asking the
normal resume flow to reload it.  This is not cross-process locking: an
independent Pi instance or external writer can still race the final
validated read and rename.  A TRAMP handler may yield and may implement
rename as copy-and-delete, so remote replacement is not guaranteed to be
atomic.

Pi's TUI can optionally ask the model for a branch summary while its
own tree-navigation API changes branches.  Pilish deliberately cannot
offer that option over Pi's current RPC surface: RPC has observation
commands such as =get_tree= and =get_entries=, but no in-place
=navigate= command; its =fork= command creates a different session file.
Pilish fully displays =branch_summary= entries already persisted in the
session file, but invents neither an LLM call nor a hidden summary
workflow during navigation.

Legacy rows without IDs are visible but cannot be continuation targets;
open the file with Pi once to migrate it.  Historical files with exact
repeats of the same nonempty-ID entry display that entry once.  If the
same ID instead names different entries, the later entry is the one
shown, unrelated unique history remains usable, and the browser shows a
duplicate-ID warning.  That ambiguous row has no =@= or =*= marker and
cannot be labeled, used as a continuation target, or made a fold target.

Tree filtering runs after display projection.  Projection first removes
raw =label=, =session_info=, and =custom= bookkeeping entries and
promotes their children to the nearest displayable ancestor.  The five
filter contracts are:
- =default= — projected entries except model and thinking-level changes;
- =no-tools= — =default= without tool results;
- =user-only= — user messages only;
- =labeled-only= — labeled nodes only;
- =all= — all /projected, displayable/ content.  It does not restore the
  raw bookkeeping records removed by projection.

Every filter also hides empty tool-dispatch assistant messages unless
aborted or carrying an error message.  The shipped value of
=pilish-tree-browser-default-filter= is =no-tools=; changing that option
changes the initial filter for new (or explicitly reinitialized) tree
browser buffers.  =f= cycles the filters.  The =?= transient lists all
five as direct choices (=d=, =n=, =u=, =L=, and =a= inside the menu), so
they do not depend on discovering the cycle order.

=/= searches the semantic text of each projected node.  Query tokens are
whitespace-separated Emacs regexps and every token must match the same
node.  The corpus includes preview/message text, the label, role and
type, full branch and compaction summaries, tool name (plus selected
fields in built-in tool previews), and provider/model or thinking-level
metadata.  It never serializes raw tool arguments or custom-tool JSON,
and binary/image and hidden thinking payloads never enter the projected
preview.  The final filter-and-query result gets a newly derived visible
tree: hidden intermediates are bypassed and roots, nearest visible
parents, sibling connectors, indentation, and gutters all describe only
the rows on screen.

=l= sets or clears a label on a node.  Labels appear in the node line for
keyboard discovery and search, and remain duplicated in the right
margin for quick scanning.  Press =g= to refresh the last persisted
state.  Display and refresh read the JSONL file from disk and still work
offline; changing position by continuing from a historical turn requires
the linked live, idle Pilish process.

The tree browser and forking answer different questions.  Example:
after two turns you want to try a second approach.  The tree browser
(Pi's =/tree=) keeps both approaches as branches of the /same/
session file; =RET= continues the live conversation from the selected
turn on that branch.  =C-c C-p f= (=/fork=, or Pi's =/clone=) instead writes
a /new/ session file whose header points back at the original, so
you get two independent sessions — and the session browser's
Threaded (fork families) view shows that file relationship as a
family.

For multiple live sessions in the same directory, use
=C-u M-x pilish= and enter a name; this names the Emacs buffer pair,
not the persisted session.  Rename the persisted session with =r= in the
session browser, =C-c C-p N=, or =/name=.  Fork with =C-c C-p f=.
When the context window fills, compacting with =C-c C-p c=
summarizes the older conversation so the session can continue; Pi
can also compact automatically, and the header line changes face as
usage crosses the configured warning and error thresholds.

#+html: <a id="common-keys"></a>
** Common keys

The main buffers:

| Key                       | Context      | Description                                 |
|---------------------------+--------------+---------------------------------------------|
| =C-c C-c=                 | input        | Send prompt, or queue follow-up if busy     |
| =C-c C-s=                 | input        | Send steering message while Pi is busy      |
| =C-c C-k=                 | input, chat  | Abort current response or compaction        |
| =C-c C-p=                 | input, chat  | Open the transient menu                     |
| =C-c C-r=                 | input, chat  | Browse sessions                             |
| =C-c C-p a i=             | menu         | Attach or replace one prompt image          |
| =C-u C-c C-p a i=         | menu         | Clear the attached prompt image             |
| =M-p= / =M-n=             | input        | Prompt history (=C-↑= / =C-↓= also work)    |
| =C-r=                     | input        | Incremental prompt-history search           |
| =TAB=                     | input        | Complete paths and =/= commands             |
| =M-<prior>= / =M-<next>=  | input        | Scroll the linked chat window               |
| =TAB=                     | chat         | Toggle summary line, thinking, tool, or turn |
| =RET=                     | chat         | Visit file target at point                  |
| =!=                       | chat         | Run shell command on file at point          |
| =w=                       | chat         | Copy the shell-local file path              |
| =n= / =p=                 | chat         | Navigate user messages                      |
| =f=                       | chat         | Fork from the turn at point                 |
| =q=                       | chat         | Quit session                                |
| =C-c C-n=                 | chat         | New session                                 |
| =C-c C-e=                 | chat         | Export session to HTML                      |
| =C-c C-c=                 | chat         | Compact conversation context                |
| =C-c C-m=                 | chat         | Select model                                |
| =C-c C-t=                 | chat         | Cycle thinking level                        |
| =C-c C-y=                 | chat         | Copy last message                           |

The browsers:

| Key       | Context        | Description                                                             |
|-----------+----------------+-------------------------------------------------------------------------|
| =RET=     | sessions       | Switch to selected session                                              |
| =RET=     | tree           | Continue from selected turn                                             |
| =TAB=     | sessions, tree | Toggle this row's fold, or its nearest containing fold                  |
| =S-TAB=   | sessions, tree | Fold all outer units (=C-u S-TAB= unfolds all)                           |
| =^=       | sessions, tree | Family root / group heading / nearest visible tree parent               |
| =n= / =p= | sessions, tree | Next / previous visible flat section (skips folded rows)                 |
| =/=       | sessions, tree | Search with regexp tokens                                               |
| =s=       | sessions       | Cycle view: Threaded (fork families), Recent activity, or Most messages |
| =t=       | sessions       | Toggle This project / All projects scope                                |
| =f=       | sessions       | Named sessions only                                                     |
| =f=       | tree           | Cycle filters; =?= offers all five directly                             |
| =r= / =d= | sessions       | Rename a session / delete a closed one                                  |
| =l=       | tree           | Set or clear a label on a node                                          |
| =g=       | sessions, tree | Refresh                                                                 |
| =?=       | sessions, tree | Browser menu                                                            |

The browser rows stay flat, so Magit's recursive cycle bindings
(=C-c TAB=, =C-<tab>=, =M-<tab>=) and level keys (=1= through =4= and
their Meta variants) are intentionally unbound.

The transient menu (=C-c C-p=) collects the full command set — new
session, reload, name, export, quit, compact, fork, tree, model,
thinking, stats, and copy last — plus submenus for skills, prompt
templates, and extension commands.  =M-x pilish-toggle= hides or
shows the session windows.

#+html: <a id="troubleshooting"></a>
* Troubleshooting and first-run notes 🩺

#+html: <details>
#+html: <summary><strong>No models are available: authenticate the Pi CLI</strong></summary>

Make sure the [[https://pi.dev/docs/latest/providers][Pi CLI is authenticated]] before expecting models to
appear: set a provider API key, or run =pi= in a terminal once and use
=/login=.  Then make the running session pick up the new credentials:
press =C-c C-p R= (or =M-x pilish-reload=) to restart the Pi
process without losing the conversation.

#+html: </details>

#+html: <details>
#+html: <summary><strong>Emacs cannot find the <code>pi</code> executable</strong></summary>

Pilish runs the command in =pilish-executable=,
which defaults to =("pi")=.  If Emacs cannot find =pi=, install the
CLI with the npm command from Quick Start on the host where Pi runs,
adjust the relevant search path, or customize
=pilish-executable=.  An example:

#+begin_src emacs-lisp
;; npx users:
(setopt pilish-executable
        '("npx" "-y" "@earendil-works/pi-coding-agent@latest"))
#+end_src

If startup says something like =env: node: No such file or directory=,
Emacs found the Pi launcher, but that launcher uses =/usr/bin/env node=.
=env= searches the subprocess =PATH=, not only Emacs =exec-path=.

If you configure Node from init.el, update both:

#+begin_src emacs-lisp
(let ((node-bin "/home/you/.local/share/pi-node/node-v22.23.1-linux-x64/bin"))
  (add-to-list 'exec-path node-bin)
  (setenv "PATH" (concat node-bin path-separator (or (getenv "PATH") ""))))
#+end_src

#+html: </details>

#+html: <details>
#+html: <summary><strong>Project-local <code>.pi</code> resources are not active</strong></summary>

Pi does not show its project trust prompt in RPC mode.  To make Emacs
sessions behave like the usual trusted project workflow,
Pilish passes =--approve= by default so project-local
=.pi= prompts, skills, settings, themes, and extensions are active.

Set =pilish-project-trust-policy= to =default= to pass no
trust flag and let Pi use its saved trust decisions and
=defaultProjectTrust=:

#+begin_src emacs-lisp
;; Let Pi decide project trust from ~/.pi/agent/trust.json
;; and its global defaultProjectTrust setting.
(setopt pilish-project-trust-policy 'default)
#+end_src

Set it to =no-approve= to pass =--no-approve= and ignore
project-local Pi files for Emacs sessions.

#+html: </details>

#+html: <details>
#+html: <summary><strong>The header shows <code>thinking (no output 5m)</code></strong></summary>

While streaming or compacting, five minutes without output from Pi
changes only the existing input activity status, using Emacs's
=warning= face; the phase stays visible.  Idle and sending sessions
are not monitored, and hiding the input also hides the warning.  The
warning never aborts, polls Pi, or changes session state.

Any nonempty parent stdout counts — ordinary replies, repeated tool
progress, even partial or malformed output; stderr does not.  Silence
is not proof of a hang: quiet tools and healthy subagents may produce
no parent output, while one sibling's output keeps the shared clock
fresh.  Timing is approximate wall time, not an awake-time deadline —
suspend, clock changes, or a blocked Emacs can shift it.  Active
sessions refresh the input header once a second when Emacs can
service its timers.

=pilish-session-inactivity-timeout= defaults to =300= seconds.  Use a
positive number to change the threshold, or =nil= to disable the
warning:

#+begin_src emacs-lisp
(setopt pilish-session-inactivity-timeout nil)
#+end_src

Changes apply at the next header refresh; reenabling retains the
existing output age.  Hover the status for help.  To stop the
current operation and discard queued continuations, use =C-c C-k=
(=M-x pilish-abort=).

#+html: </details>

#+html: <details>
#+html: <summary><strong>Tree-sitter grammar installation fails</strong></summary>

Grammar installation needs a working C compiler.  Install =gcc= or
=cc=, then run:

#+begin_src
M-x pilish-install-grammars
#+end_src

If an old system Markdown grammar is loaded and tables render
incorrectly, remove the old =libtree-sitter-markdown= from
=treesit-extra-load-path=, your Emacs tree-sitter directory, or your
system packages, then restart Emacs or run
=M-x pilish-install-grammars=.

#+html: </details>

#+html: <details>
#+html: <summary><strong>TRAMP projects run Pi on the remote host</strong></summary>

When the current project is a TRAMP directory, Pilish
starts the Pi CLI on the remote host.  Install =pi= there.

Authentication and configuration are remote too: run =pi= and =/login=
on that host, or set provider environment variables for the remote
process.  Paths such as =~/.pi/agent/auth.json= and absolute entries in
=pilish-executable= are interpreted on the host where Pi runs.

For Emacs to find the command on the remote host, its directory must be
in TRAMP's remote search path; Emacs's local =exec-path= is not used.
Use an absolute path in =pilish-executable=, or add the
executable's absolute remote directory to =tramp-remote-path=:

#+begin_src emacs-lisp
(with-eval-after-load 'tramp
  (add-to-list 'tramp-remote-path "/home/you/bin"))
#+end_src

#+html: </details>

#+html: <details>
#+html: <summary><strong>Emacs loads an old <code>transient</code> package</strong></summary>

Emacs may load its bundled =transient= before the newer MELPA package.
If the menu complains about =transient=, set
=package-install-upgrade-built-in= to =t=, install or upgrade
=transient= (needs the GNU ELPA archive for its =compat=
dependency), and restart Emacs.

#+html: </details>

#+html: <a id="how-it-compares"></a>
* How does it compare to other Emacs LLM helpers? 🆚

Pilish is a frontend for one agent: Pi.  Pi is open source and
extensible — many model providers including local and custom models,
reusable skills, prompt templates, TypeScript extensions and custom
tools, and an SDK.  Pilish communicates with Pi over its JSON-RPC
mode, receiving structured messages and tool events instead of
driving a terminal UI; that is what makes the workflow above
possible without embedding Pi's terminal UI.

Pilish has real trade-offs: it is a young package, it needs the =pi=
CLI (and Node.js) on the host where sessions run, and it does not
talk to any other backend.  Here is where each option fits:

- =pilish=: best when you want the Pi runtime (providers,
  sessions, extensions, skills, templates, custom tools, and custom
  providers) with an Emacs-native user interface.  It is not a general
  Emacs LLM library and not a universal shell for every agent; it is a
  focused frontend for Pi.

- [[https://github.com/karthink/gptel][gptel]] and [[https://github.com/karthink/gptel-agent][gptel-agent]]: =gptel= is reusable Emacs LLM
  infrastructure: any-buffer chat and rewrites, file-backed chats,
  context attachment, multiple backends, tool use, MCP through
  =mcp.el=, and APIs for custom workflows.  =gptel-agent= is the
  agentic harness built on top of it, with local file tools, Bash,
  web tools, Emacs tools, confirmations, prompts, and sub-agents.

- [[https://github.com/MatthewZMD/aidermacs][Aidermacs]]: an Emacs frontend for Aider.  It runs Aider through
  =comint= or =vterm= and adds Emacs commands for Aider sessions,
  explicit editable/read-only file context, repo-map refresh, code,
  ask, architect, and help modes, =.aider.conf.yml= configuration,
  and Ediff review of AI-generated changes.

- [[https://github.com/manzaltu/claude-code-ide.el][claude-code-ide.el]]: an Emacs integration for Claude Code CLI
  through MCP.  It starts Claude Code for the current project using
  =vterm=, =eat=, or =ghostel=, manages project sessions, tracks
  the active file and selection, exposes diagnostics and Emacs tools such
  as xref, tree-sitter, imenu, project, and custom Elisp functions,
  and shows proposed edits through Ediff.

- [[https://github.com/xenodium/agent-shell][agent-shell]]: a native Emacs shell for ACP-driven agents.  It
  relies on =acp.el= and works with agents such as Gemini CLI, Claude
  Agent, Auggie, Mistral Vibe, GitHub Copilot, and Pi through the
  =pi-acp= adapter.  It is the broader choice when one Emacs UI should
  talk to several ACP agents.

- [[https://github.com/editor-code-assistant/eca-emacs][eca-emacs]]: the Emacs client for the ECA server.  It connects to
  an external =eca= process and provides dedicated chat buffers,
  =@=-context, chat/tool approval commands, model and agent selection,
  MCP settings, inline completion, rewrite overlays, and diff or Ediff
  review.

#+html: <a id="advanced-features-and-configuration"></a>
* Advanced features and configuration ⚙️

This section is reference material: changing defaults, managing
grammars yourself, using extension features, the exact rules behind
chat file actions, and installing from source.

** Configuration

Here are some common non-default preferences:

#+begin_src emacs-lisp
;; Collapse completed thinking in new chats; live thinking still streams:
(setopt pilish-thinking-display 'hidden)

;; Make the input window 25% of the session's window pair;
;; rebalanced on frame resize:
(setopt pilish-input-window-height 0.25)

;; Copy source Markdown from the chat buffer instead of only visible text:
(setopt pilish-copy-raw-markdown t)

;; Request the native same-window opener for chat file targets;
;; use C-u RET to invert this request for one visit.  Emacs display policy
;; may redirect final placement:
(setopt pilish-visit-file-other-window nil)
#+end_src

The input window has three display styles — pick one:

#+begin_src emacs-lisp
;; Keep both windows visible whenever the session is shown (the default):
;; (setopt pilish-input-window-display 'always)

;; Show the input pane when a session launches and while composing;
;; hide it after each send and reopen it with M-x pilish-open-input:
(setopt pilish-input-window-display 'on-demand)

;; Start sessions with only the chat window visible; open the input on
;; demand (M-x pilish-open-input, or `i'/`a' under Evil) and have it
;; hide again after each send:
;; (setopt pilish-input-window-display 'hidden)
#+end_src

Less common tuning knobs:

#+begin_src emacs-lisp
;; New input buffers use plain text instead of Markdown highlighting:
;; (setopt pilish-input-markdown-highlighting nil)

;; Show more tool output before it collapses, counted in visual lines:
;; (setopt pilish-tool-preview-lines 20)
;; (setopt pilish-bash-preview-lines 10)

;; Lower the 3 MiB source limit for an outgoing prompt image:
;; (setopt pilish-prompt-image-max-bytes (* 2 1024 1024))

;; Cap inline image previews to 640 pixels as well as the chat window width;
;; lower the 10 MiB per-image preview-source limit if desired:
;; (setopt pilish-image-preview-max-width 640)
;; (setopt pilish-image-preview-max-bytes (* 5 1024 1024))

;; Lower context warning/error colors in the header line:
;; (setopt pilish-context-warning-threshold 40)
;; (setopt pilish-context-error-threshold 60)

;; Keep more recent turns live for table rewrapping and tool overlays;
;; older history is cooled to keep long sessions fast:
;; (setopt pilish-hot-tail-turn-count 5)

;; Let Pi's saved project trust decisions decide whether .pi resources load:
;; (setopt pilish-project-trust-policy 'default)

;; Hidden thinking uses generic line-count stubs instead of first-line previews:
;; (setopt pilish-thinking-hidden-preview nil)

;; New browser buffers can start elsewhere; existing browsers keep
;; their toggled state (see the Sessions and context section):
;; (setopt pilish-session-browser-default-scope 'all)
;; (setopt pilish-session-browser-default-view 'recent)
;; (setopt pilish-session-browser-default-named-only t)
;; (setopt pilish-tree-browser-default-filter 'default)
#+end_src

You can also inspect the whole customization group with:

#+begin_src
M-x customize-group RET pilish RET
#+end_src

** Evil integration

Optional Evil keybindings ship in =pilish-evil.el=, modeled on
how Evil and Magit cooperate: the read-only chat buffer starts in
motion state so navigation keys just work, the input buffer starts in
insert state, and =?= opens the transient menu.  It loads
automatically when a session is set up while Evil is in use; to opt
out, set this before loading the package:

#+begin_src emacs-lisp
(setq pilish-evil-integration nil)
#+end_src

Chat buffer (motion state):

| Key       | Action                                  |
|-----------+-----------------------------------------|
| =n= / =p= | next / previous message                 |
| =f=       | fork session at point                   |
| =w=       | copy shell-local file path at point     |
| =TAB=     | toggle tool/thinking section            |
| =RET=     | visit file at point                     |
| =i= / =a= | focus input (=a= goes to end of input)  |
| =?=       | transient menu                          |
| =q=       | quit session                            |

Input buffer (normal state):

| Key   | Action             |
|-------+--------------------|
| =RET= | send               |
| =q=   | close input window |
| =?=   | transient menu     |

Session and tree browser buffers start in motion state as well.  Evil's
=j= / =k= move line-wise; Magit's section motions remain on =n= / =p=
(and =M-n= / =M-p=) and skip folded rows.  =TAB= / =S-TAB= and =^= use
the browser folding and parent commands described above.  In the session
browser =RET= switches to the selected session; in the tree browser it
continues from the selected turn.  Every documented browser key — views,
filters, folding, parent motion, search, scope, rename, delete, refresh,
dispatch, =RET= — is rebound in motion state so the Evil and evil-collection
keymap stack never swallows them (evil's
motion state owns =/=, =?= and the =g= prefix; evil-snipe's =f= / =t=
are disabled in these buffers, mirroring the chat buffer's =f= fork
binding).

Setup also sets =pilish-copy-raw-markdown= buffer-locally in
chat buffers, so that yanking preserves code fences and markup without
changing the global default.  Each part can be tuned independently
before loading:

#+begin_src emacs-lisp
;; Different initial states:
(setq pilish-evil-chat-state 'normal)

;; Open and focus the input window in normal state instead of insert
;; (press i to start composing, RET sends, q closes the window):
(setq pilish-evil-input-state 'normal)

;; Run the browsers in emacs state instead of motion:
(setq pilish-evil-browse-state 'emacs)

;; Keep copying only visible text instead of raw Markdown:
(setq pilish-evil-copy-raw-markdown nil)
#+end_src

User bindings made after =pilish-evil-setup= runs (e.g. with
=evil-define-key= or Doom's =map!=) take precedence over these
defaults, and =M-x pilish-evil-setup= re-applies them after
changing options.

With =evil-snipe= (enabled by default in Doom): its minor-mode keymaps
shadow the chat buffer's =f= binding, so setup turns the snipe minor
modes off in chat buffers via evil-snipe's mode hooks — the same
outcome =magit-mode= gets from its entry in
=evil-snipe-disabled-modes=, and effective regardless of load order.
Fork stays on =f=, char-finding remains available on =F=, =t=, and
=T=, and snipe stays active in the input buffer.  Set
=pilish-evil-disable-snipe= to nil to keep snipe active in
chat buffers.

** Display of thinking

New chat buffers inherit =pilish-thinking-display=.  The
default is =visible=: live thinking streams while the assistant is
working and remains expanded when that thinking block finishes.  Use
=C-c C-p h= to change the current chat, or =C-c C-p H= to change the
default for future chat buffers in the current Emacs session.  To make
that default persist across restarts, set
=pilish-thinking-display= in your init file or via
=M-x customize-option=.

Press =TAB= inside completed thinking to toggle that block locally.

** Markdown tables

Pipe tables in the chat buffer are beautified as a display-only view.
Recent tables re-wrap automatically when the chat window width
changes.  Older history stays frozen at its previous width to avoid
expensive whole-buffer redisplay on every resize.  Tool blocks in
older history also lose their expand/collapse buttons and syntax
highlighting, keeping long sessions fast.  Customize
=pilish-hot-tail-turn-count= to choose how many recent
=You= and =Assistant= turns stay live.

** Images

*Prompt images* are read and materialized when attached, and sent as
stored bytes without resizing or format conversion.  Image-bearing
drafts can be sent only as direct prompts while Pi is idle: they are
not queued while Pi is busy, not used as steering messages, and not
combined with slash commands; a refusal preserves both the prompt
text and the image.  The default source limit is 3 MiB
(=pilish-prompt-image-max-bytes=).  Files are read by Emacs, so an
Emacs-readable path may use a file-name handler such as TRAMP — Pi
receives the encoded bytes, never the path (remote reads depend on
the configured handler and are not broadly tested).  Clipboard image
extraction, automatic detection of paths typed into the prompt, and
multiple attachments are deferred.

*Inline display:* image content in sent user turns and completed tool
results renders inline in graphical Emacs and as a type-and-size
placeholder in terminals; sent and returned images share the same
bounded renderer.  Pi's built-in =read= already returns raster images
as image content, resized to at most 2000x2000.  For SVG, a returned
preview is made only from complete, standalone SVG text supplied by
=read=;
Pilish never reopens the argument path, and text with obvious scripts
or external resources is left as text.  Images in partial tool updates
appear when the final result arrives.  Unresized prompt images and
custom tools can supply animated or highly compressed data whose
decoder cost is not bounded by the source-byte cap; the preview
settings in Configuration bound what renders.  Moving a
terminal-rendered chat to a GUI, resizing previews, or applying a
later extension replacement may require a toggle or history reload.

** Sessions, transcripts, and exports

To open an existing Pi JSONL session file directly, run:

#+begin_src
M-x pilish-open-session-file
#+end_src

This opens the file as a live Pi session in the normal chat/input UI,
not as a static viewer.  In Dired, it defaults the prompt to the
regular file at point.  From a buffer visiting a local readable
=.jsonl= file, it defaults to that file.

You can save the chat buffer like any other buffer to keep a Markdown
transcript on disk.  Saving does not interrupt or replace the live Pi
session.  For a shareable export, use HTML export from the menu
(=C-c C-p e=) or run =/export=.

** File and command actions

These are the exact rules behind =RET=, =!=, and =w= in the chat
buffer.

=RET= opens one strict file target at point: a file-content row in
tool output, a plain path reference, or the label of a local Markdown
link.  Tool headers, fences, preview hints, and other non-content rows
do not open files.  Plain locations such as =src/app.el:12:3= visit
their one-based physical file line and optional one-based column; a
range such as =src/app.el#L12-L20= visits its first line only.  If an
explicit location is outside an existing narrowing, the default
=widen-automatically= policy widens the file.  Coordinates apply only
to file-visiting buffers; directory targets retain native Dired point
and marks.  A target without a location keeps the point, mark, and
narrowing chosen by normal Emacs file visiting.  By default Pilish
requests the native other-window opener; =C-u RET= inverts
=pilish-visit-file-other-window= for that visit, and Emacs display
policy may redirect the final placement.

=!= runs one strict file target through a shell command,
Dired-inspired.  One command word followed only by
whitespace-delimited options beginning with =-= receives the quoted
target appended automatically, so =wc -l= and =grep -n= work as-is.
All other command text — ordinary arguments, compound/control syntax,
multiple lines — must place a textual isolated =*= (bounded by a
space, tab, or string edge) where the target goes: =grep -n TODO *=,
=head -20 * | less=.  A final whitespace-delimited ~&~ uses the usual
asynchronous shell output.  Prompting and execution use a snapshot of
the target session's local or TRAMP execution environment.

=w= copies the same shell-local path that =!= operates on.  Local
paths are absolute; paths in remote sessions use the remote shell's
namespace without an Emacs TRAMP prefix.  Line, column, range, and
link-fragment metadata are excluded, and the kill-ring value is the
literal, unquoted pathname, so spaces remain spaces and no =@= prompt
marker is added.

** Extension support

Pilish has basic support for Pi
extensions.  Extension commands show up in slash completion and the
transient menu, extension tools run normally, and extensions can use
notifications, confirm/select/input prompts, prefill the input buffer,
and show status text.

Rich TUI-specific extension UI is not supported in Emacs yet: custom
widgets, custom editor components, custom headers/footers, and other
component-based views are unavailable or fall back.  Hover extension
status text in the header line to see the exact =statusKey=, which you
can use to change the font for that extension's status text using
=pilish-extension-status-faces=.

** Tree-sitter grammar management

Pilish uses Emacs's built-in tree-sitter support to render
Markdown and highlight code blocks in the chat and input buffers.  The
two essential grammars are =markdown= and =markdown-inline=.  Optional
grammars such as =python=, =javascript=, =rust=, and =go= improve
syntax highlighting inside code blocks.

On first session start, Pilish prompts to install the
essential grammars.  If you decline that prompt, it appears again next
time until the grammars are installed or you change
=pilish-essential-grammar-action=.  A separate optional
prompt offers additional grammars for syntax highlighting inside code
blocks.  Declining the optional prompt is remembered and it appears
again only if new grammar recipes are added or you clear
=pilish-grammar-declined-set=.

To check which grammars are installed or install them later:

#+begin_src
M-x pilish-install-grammars
#+end_src

If you manage tree-sitter grammars outside of Emacs, for example
through a system package manager, set
=pilish-essential-grammar-action= to =warn= to suppress the
essential grammar prompt:

#+begin_src
(setopt pilish-essential-grammar-action 'warn)
#+end_src

** Activity phase hooks

For custom UI changes tied to activity, add functions to
=pilish-activity-phase-functions=.  Each function receives:

#+begin_src emacs-lisp
(CHAT-BUFFER INPUT-BUFFER OLD-PHASE NEW-PHASE REASON)
#+end_src

=NEW-PHASE= is one of =thinking=, =replying=, =running=, =compact=, or
=idle=.  =REASON= explains why the phase was applied:

| Reason         | Meaning                                       |
|----------------+-----------------------------------------------|
| =phase-change= | The session activity phase changed.           |
| =reset=        | A session reset forced =idle=.                |
| =teardown=     | Session teardown forced =idle=.               |
| =input-link=   | A newly linked input should apply the phase.  |
| =input-unlink= | An old input should clean up local UI state.  |

Handlers should be idempotent.  Pilish may reapply the same
phase when buffers are relinked, reset, or torn down.  =INPUT-BUFFER=
may also be nil or dead during teardown.

As an example, consider tinting the input buffer while the session is
busy:

#+begin_src emacs-lisp
(defvar-local my-pi-input-tint-cookie nil)

(defun my-pi-tint-input-while-busy (_chat input _old new _reason)
  (when (buffer-live-p input)
    (with-current-buffer input
      (when my-pi-input-tint-cookie
        (face-remap-remove-relative my-pi-input-tint-cookie)
        (setq my-pi-input-tint-cookie nil))
      (unless (string= new "idle")
        (setq my-pi-input-tint-cookie
              (face-remap-add-relative
               'default :background "gray20"))))))

(add-hook 'pilish-activity-phase-functions
          #'my-pi-tint-input-while-busy)
#+end_src

Another example: Notify when a real session turn finishes.  The
=REASON= check matters: input relink cleanup also applies =idle= to
the old input, but that does not mean Pi finished working.

#+begin_src emacs-lisp
(defun my-pi-message-when-done (chat _input old new reason)
  (when (and (eq reason 'phase-change)
             (not (string= old "idle"))
             (string= new "idle"))
    (message "Pi finished in %s" (buffer-name chat))))

(add-hook 'pilish-activity-phase-functions
          #'my-pi-message-when-done)
#+end_src

Track busy sessions for your own mode-line or tab display:

#+begin_src emacs-lisp
(defvar my-pi-busy-sessions nil)

(defun my-pi-track-busy-sessions (chat _input _old new reason)
  (when (memq reason '(phase-change reset teardown))
    (setq my-pi-busy-sessions (delq chat my-pi-busy-sessions))
    (unless (string= new "idle")
      (push chat my-pi-busy-sessions))
    (force-mode-line-update t)))

(add-hook 'pilish-activity-phase-functions
          #'my-pi-track-busy-sessions)
#+end_src

** Installation details

MELPA (with [[https://elpa.gnu.org/][GNU ELPA]] added for =compat=)
installs =transient=, =magit-section=, =md-ts-mode=, and
=markdown-table-wrap= automatically.  Pilish uses =md-ts-mode= only for its own
chat and input buffers, so installing or loading this package does not
change how unrelated =.md= files open.  If you want tree-sitter
Markdown globally, configure =md-ts-mode= separately.

With =use-package= (after the archive setup from Quick start):

#+begin_src emacs-lisp
(use-package pilish
  :ensure t
  :init (defalias 'pi 'pilish))
#+end_src

On a fresh Emacs installation, run =M-x package-refresh-contents=
once before restarting: =use-package='s =:ensure t= can only install
=pilish= after the archive index has been downloaded.

For a plain Git checkout, configure the MELPA and GNU ELPA archives
as shown in Quick start, then evaluate this once to install the
external dependencies:

#+begin_src emacs-lisp
;; Must be set before installing/upgrading Emacs's bundled transient.
(setq package-install-upgrade-built-in t)

(package-install 'transient)
(package-install 'magit-section)
(package-install 'md-ts-mode)
(package-install 'markdown-table-wrap)
#+end_src

Then clone the repository and add it to your load path:

#+begin_src bash
git clone https://github.com/dnouri/pilish ~/.emacs.d/site-lisp/pilish
#+end_src

#+begin_src emacs-lisp
(require 'package)
(package-initialize)
(add-to-list 'load-path "~/.emacs.d/site-lisp/pilish")
(require 'pilish)
#+end_src

Or with =use-package= after installing the dependencies above:

#+begin_src emacs-lisp
(use-package pilish
  :load-path "~/.emacs.d/site-lisp/pilish"
  :init (defalias 'pi 'pilish))
#+end_src

If you prefer not to install the Pi CLI globally, point
=pilish-executable= at npx instead; see the example in the
Troubleshooting entry about finding the =pi= executable.

#+html: <a id="development"></a>
* Development 🛠️

Most users can skip this section.  It is for contributors and local
package development.

** Running tests locally

The shared integration tests have two lanes:

- a fast fake-pi lane for deterministic RPC-boundary checks
- a real pi lane for backend compatibility coverage

The default local integration target runs the fake lane first and the
real lane second, so it still needs Docker for the real lane.  The GUI
suite is fully fake-backed and does not need Docker or a local pi
install.

#+begin_src bash
# Byte-compile, lint, and unit tests
make check

# Shared integration contract: fake first, then real
make test-integration

# Fast integration lane against the fake-pi harness only;
# needs neither Docker nor a pi install
make test-integration-fake

# Real pi lane only; starts the project-local Ollama Docker container
make test-integration-real

# Run a single integration contract by selector
make test-integration-fake SELECTOR=rpc-smoke
make test-integration-real SELECTOR=steering-contract

# Deterministic GUI tests (fake-backed, no Docker or local pi install)
make test-gui

# Run one GUI regression by selector
make test-gui SELECTOR=tool-overlay-bounded

# All tests
make test-all
#+end_src

** Running fake-pi manually

Run the harness directly when debugging the subprocess contract
itself.  These commands start an interactive JSONL peer on
stdin/stdout.

#+begin_src bash
# Basic prompt lifecycle scenario
uv run --script test/support/fake_pi.py --scenario prompt-lifecycle

# Extension dialog scenario with a longer manual timeout
./test/support/fake_pi.py --scenario extension-confirm --extension-timeout-ms 10000
#+end_src

Scenario fixtures live under =test/fixtures/fake-pi/=.

** GUI tests with visible window

The GUI suite is deterministic and fake-backed.  By default it
auto-detects whether to show a window or run headless.

#+begin_src bash
# With a display available, runs with visible window
./test/run-gui-tests.sh

# Run one visible regression directly
./test/run-gui-tests.sh pilish-gui-test-scroll-auto-when-at-end

# Force headless even with display available
./test/run-gui-tests.sh --headless
#+end_src

** CI setup

GitHub Actions runs on every push:

- =test-unit.yml= - Unit tests across Emacs 29.4, 30.1, and snapshot (31)
- =lint.yml= - Byte-compile, checkdoc, and package-lint across Emacs 29.4, 30.1, and snapshot
- =test-integration.yml= - Split fake/real integration jobs; fake stays fast, real keeps Ollama compatibility coverage
- =test-gui.yml= - Deterministic fake-backed GUI tests with xvfb virtual framebuffer

The real integration workflows use Node 24.  Nightly builds keep real
integration coverage against the pinned pi version (from Makefile) and
latest, while the fake-backed GUI suite runs once.

* Links 🔗

- [[https://pi.dev][pi.dev]]: pi coding agent home page

* License ⚖️

GPL-3.0-or-later. See [[file:LICENSE][LICENSE]].

Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.

agentic-coding
emacs
harness-engineering
pi

Contributors

dnouri

269 commits

SayreBlades

8 commits

leo-ar

2 commits

bobrowadam

2 commits

dnouri/pi-coding-agent

The ergonomic agent harness for Emacs.

Emacs Lisp

293

291 commits

updated Sep 19, 2026

See the code

README

#+title: Pilish
#+author: Daniel Nouri
#+options: toc:nil num:nil

#+html: <a href="https://melpa.org/#/pilish"><img alt="MELPA" src="https://melpa.org/packages/pilish-badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/test-unit.yml"><img alt="Unit Tests" src="https://github.com/dnouri/pilish/actions/workflows/test-unit.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/test-integration.yml"><img alt="Integration Tests" src="https://github.com/dnouri/pilish/actions/workflows/test-integration.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/test-gui.yml"><img alt="GUI Tests" src="https://github.com/dnouri/pilish/actions/workflows/test-gui.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/nightly.yml"><img alt="Nightly" src="https://github.com/dnouri/pilish/actions/workflows/nightly.yml/badge.svg"/></a>
#+html: <a href="https://github.com/dnouri/pilish/actions/workflows/melpazoid.yml"><img alt="melpazoid" src="https://github.com/dnouri/pilish/actions/workflows/melpazoid.yml/badge.svg"/></a>

#+html: <p><em>Jump to:</em> <a href="#quick-start">Quick start</a> ·
#+html: <a href="#everyday-workflow">Everyday workflow</a>
#+html: (<a href="#composing-prompts">composing prompts</a>,
#+html: <a href="#reading-output">reading output</a>,
#+html: <a href="#sessions-and-context">sessions</a>,
#+html: <a href="#common-keys">shortcuts</a>) ·
#+html: <a href="#troubleshooting">Troubleshooting</a> ·
#+html: <a href="#how-it-compares">Comparison</a> ·
#+html: <a href="#advanced-features-and-configuration">Advanced</a> ·
#+html: <a href="#development">Development</a></p>

* What is Pilish?

#+html: <img align="right" src="assets/pilish-logo.svg" alt="Pilish Hornbridge logo" width="96" height="96">

Pilish is an Emacs frontend for [[https://pi.dev][Pi]], an open and
extensible coding agent.  A session runs in two windows: the
conversation renders as Markdown in the top window, and you compose
your next prompt in an ordinary Emacs buffer in the bottom one.

Because the prompt is a normal buffer, your editing habits apply
directly.  Write multi-line prompts, paste from other buffers, use the
kill ring, keyboard macros, registers, spell checking, Evil, and
anything else your setup gives you.  You can keep writing while the
answer streams in above, and scroll, search, and copy from previous
output with the keys you already use everywhere else.

Pi's own terminal UI and Pilish are two frontends to the same =pi=
CLI.  Pilish talks to Pi over its JSON-RPC mode, so Emacs receives
structured messages and tool events rather than terminal output.
Model providers, sessions, skills, and prompt templates carry over
unchanged, and Pi extensions work with basic UI support (see
=Extension support= under Advanced).

#+html: <a href="https://danielnouri.org/media/pi-coding-agent-demo.mp4"><img src="https://danielnouri.org/media/pi-coding-agent-preview.gif" alt="Narrated Pilish demo video (6:38) - click to play" width="600" height="600"></a>
#+html: <p><small>Video music: "Electrodoodle" by Kevin MacLeod
#+html: (<a href="https://incompetech.com">incompetech.com</a>),
#+html: licensed under <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.</small></p>

#+html: <a id="quick-start"></a>
* Quick start 🚀

If you already have Emacs 29.1 or later with tree-sitter support, this
is the shortest path to a working session.

** Requirements

- Emacs 29.1 or later, built with tree-sitter support
- Node.js 22.19 or later for the Pi CLI
- [[https://pi.dev][pi coding agent]] =@earendil-works/pi-coding-agent@0.85.0= or later,
  installed and in =PATH= on the host where Pi runs
- Pi CLI authentication: a provider API key, or a one-time =/login=
  run in a terminal (see below)
- A C compiler if Emacs needs to compile tree-sitter grammars

Upgrade older Pi installations before using Pilish, for example with
=npm install -g @earendil-works/pi-coding-agent@latest=.  Pi 0.85.0 or
later is required for run settlement, queue clearing, and compaction
abort handling; older protocol versions are not supported.

** Install pi and authenticate

#+begin_src bash
# Install the pi CLI
npm install -g @earendil-works/pi-coding-agent

# Or with mise
mise use -g npm:@earendil-works/pi-coding-agent@latest

# Authenticate the CLI: either set a provider API key (see below), or
# run `pi` interactively in a terminal and type `/login`.
#+end_src

Pi authenticates in one of two ways:

- *Subscription or API key* via =/login=: =/login= is interactive and
  unavailable from Emacs, so run plain =pi= once in a terminal and
  type =/login=; you can then pick a subscription such as ChatGPT
  Plus/Pro, or choose a provider and enter its API key.  Credentials
  are stored in =~/.pi/agent/auth.json= and auto-refresh, so later
  Emacs sessions pick them up automatically.

- *Provider API key, no terminal login:* export a provider
  environment variable, e.g. ~export ZAI_API_KEY=...~, or add an entry
  to =~/.pi/agent/auth.json=.  Pi supports many providers including
  DeepSeek, OpenAI, and Z.AI.  See the
  [[https://pi.dev/docs/latest/providers][providers documentation]] for the full set and its
  environment variables.

Either way, usage is billed as with any Pi frontend: per token by
your provider, or covered by your subscription.

** Install the Emacs package

Install from [[https://melpa.org/#/pilish][MELPA]].  MELPA alone does not
resolve every dependency right now: its =transient= build needs
=compat ≥ 31.0=, which is only published on
[[https://elpa.gnu.org/][GNU ELPA]], so add GNU ELPA as a second
archive before installing:

#+begin_src emacs-lisp
(require 'package)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(add-to-list 'package-archives '("gnu" . "https://elpa.gnu.org/packages/") t)
(package-refresh-contents)
#+end_src

Then install:

#+begin_src
M-x package-install RET pilish RET
#+end_src

You may also define a shorter command name:

#+begin_src emacs-lisp
(defalias 'pi 'pilish)
#+end_src

After that, =M-x pi= starts or focuses the current project's Pi
session.

** Your first exchange

Run =M-x pilish= in the directory of a project you want to work on.
Pilish opens the two windows, and on first start it offers to install
the tree-sitter grammars needed for Markdown rendering — a one-time
step that needs a C compiler (=gcc= or =cc=); say yes so the chat
buffer renders properly.

Type a prompt in the bottom window — =explain what this project does=,
for instance — and press =C-c C-c=.  Your text appears in the chat
under a =You= heading, and Pi's reply streams in under =Assistant=.
The header line above the input tracks the model, activity phase,
cost, and context usage while it works.

One default worth knowing up front: Pi never shows its project trust
prompt in RPC mode, so Pilish starts Pi with =--approve= and
project-local =.pi= resources (skills, prompts, extensions) are active
right away.  If you would rather rely on Pi's saved trust decisions,
see =pilish-project-trust-policy= under Troubleshooting.

If no models are available or Emacs cannot find the =pi= executable,
the Troubleshooting section below has the fixes.

#+html: <details>
#+html: <summary><strong>Upgrading from pi-coding-agent</strong></summary>

=pilish= 3.0 is the renamed =pi-coding-agent= package (now
[[https://github.com/dnouri/pilish][dnouri/pilish]] on GitHub).  The
rename is a clean break: old configurations need a one-time update,
and old-name settings do not carry over automatically.

1. Install =pilish=, then delete the old package:

   #+begin_src
   M-x package-install RET pilish RET
   M-x package-delete RET pi-coding-agent RET
   #+end_src

   Complete both steps before restarting Emacs: once the old package
   is deleted, an init file that still requires =pi-coding-agent=
   fails with "Cannot open load file" and skips the rest of your
   config.  (Skipping the =package-delete= breaks nothing, but both
   command sets stay installed and =M-x pi= runs whichever
   =defalias= form comes last.)

2. Rename =pi-coding-agent= to =pilish= in your init file: the
   =require= or =use-package= declaration, the =pi= alias, any
   =pi-coding-agent-*= variable names in =setq=, =setopt=, or
   =custom-set-variables=, and any =with-eval-after-load=, hook, or
   keybinding forms naming =pi-coding-agent= commands.  A missed old
   name raises no error at startup; it is silently ignored, so
   re-check your config after upgrading.

   #+begin_src emacs-lisp
   (require 'pilish)
   (defalias 'pi 'pilish)
   #+end_src

#+html: </details>

#+html: <a id="everyday-workflow"></a>
* Everyday workflow 🧭

A session has two windows:

- *Chat buffer* (top) — the rendered Markdown conversation, including
tool output and thinking blocks from previous turns.
- *Input buffer* (bottom) — a normal Emacs buffer where you write the
  next prompt.

The input header line shows the current model, thinking level,
activity phase, cost and context usage, session name, and extension
status when available, plus the basename and size of an attached
prompt image.  The model and thinking fields can be clicked to change
their values.  After context usage, =queued N= counts the queued
messages Pilish knows about and hides at zero; hover it for previews
of pending follow-ups and steering messages.  Pilish includes
backend queues only after Pi reports their contents, so the count
can lag, and follow-ups also include text waiting for prompt
acceptance.  A count that reaches zero does not by itself mean the
work has finished.

Type in the input buffer and press =C-c C-c= (=M-x pilish-send=) to
send.  If Pi is already working, the same key queues the text as a
follow-up and sends it when the current turn finishes.  Press
=C-c C-s= (=M-x pilish-queue-steering=) while Pi is busy to send a
steering message: it is delivered after the current tool call and
interrupts the remaining queued tools.  During compaction or while a
run is settling, =C-c C-s= queues a local follow-up instead, sent
when Pi is ready for another prompt.  Press =C-c C-k=
(=M-x pilish-abort=) to abort the current response or compaction.

Press =C-c C-p= for the transient menu.  It groups the remaining
commands — sessions, fork, compact, export, stats, model, thinking,
skills, and custom commands — and is the quickest way to discover
them.

If Pi works silently for five minutes, the input header says so, for
example =thinking (no output 5m)=.  That is a hint, not an error:
quiet tools and healthy subagents can produce no output at all.  See
Troubleshooting for what the warning counts and how to tune or
disable it.

#+html: <a id="composing-prompts"></a>
** Composing prompts

The input buffer is intentionally boring Emacs: every editing
feature your setup already gives you works there.  Your prompt stays
in the bottom window while the conversation streams above it.

Press =C-c C-p a i= to attach one image: select a file, or paste its
path into the file prompt.  Attaching another image replaces the
first, and =C-u= before the =i= clears it.  Images are recognized by
content rather than extension (PNG, JPEG, GIF, WebP), the source
limit is 3 MiB, and the header keeps the attached name and size
visible.  Send the image with a nonempty ordinary prompt while Pi is
idle and a vision-capable model is selected; the finer rules live
under =Images= in Advanced.

Slash commands work with completion: type =/= then =TAB= to complete
built-in commands and Pi commands such as prompt templates, skills,
and extension commands.  Prompt templates are discovered from places
such as =~/.pi/agent/prompts/=, and skills from
=~/.agents/skills/=.  File references also complete: use =TAB= on
paths such as =./=, =../=, and =~/=.  Absolute paths complete too,
except for a bare =/= at the start of the buffer, where slash-command
completion wins.

While composing, you can page through the chat without leaving the
input buffer.  Emacs's built-in =M-<prior>= and =M-<next>=
(=M+PageUp= / =M+PageDown=) scroll the other window, and Pilish
registers the chat as the input buffer's =other-window-scroll-buffer=,
so they scroll the linked chat.  If your terminal does not send those
keys, =C-M-v= and =C-M-S-v= (the classic other-window scrolling
commands) work for the same reason.

#+html: <a id="reading-output"></a>
** Reading output

The chat buffer renders Markdown with tree-sitter syntax highlighting
for code blocks and diffs.  Long tool output collapses to a preview
so the chat stays readable; press =TAB= on a tool block to expand or
collapse it.  Long-running commands stream output live, file
operations (=read=, =write=, =edit=) get syntax highlighting, and
edit diffs highlight what changed.  Wide Markdown pipe tables are
wrapped and redrawn to fit the window while the underlying text
stays plain Markdown.  Images sent with prompts and returned by
tools appear inline in graphical Emacs and as a type-and-size
placeholder in terminals; the limits are described under =Images= in
Advanced.

Hover completed replies, thinking, or tool blocks for timestamps and details:
reply provider/model and message-level token/cache usage, thinking line counts,
or short tool command/path hints.  Native =C-h .= shows full help at point,
including in terminals.  Stream durations, output rates, and tool timings are
approximate and live-only; reload or resume drops them.

Press =RET= on a file target to visit it: a file-content row in tool
output, a plain path reference, or the label of a local Markdown
link.  Locations are honored — =src/app.el:12:3= opens line 12,
column 3, and =src/app.el#L12-L20= opens line 12 — and =C-u RET=
flips the window choice for that one visit (see
=pilish-visit-file-other-window=).  Press =!= on a file target to run
a shell command on it, Dired-style: a single command word plus
options, like =wc -l= or =grep -n=, gets the file appended
automatically, while anything else needs an isolated =*= where the
path belongs, as in =grep -n TODO *=.  Press =w= to
copy the file's shell-local path.  The exact rules behind all three
live under =File and command actions= in Advanced.

Press =TAB= on a turn header (=You= or =Assistant=) to fold or unfold
that turn.  Use =n= and =p= in the chat buffer to jump between user
messages, and =f= to fork the conversation from the turn at point.

The chat header ends with a summary line showing the pi and Pilish
versions plus skill and prompt-template counts.  Press =TAB= on it to
expand sections listing context files, skills, prompt templates, and
extension commands, grouped by Project and User, with explicit paths
and unknown-scope resources kept separate; the extension section
lists registered commands.  Names link to their source files and
carry descriptions when available, so you can explore what is on
offer before running anything from =C-c C-p=.  =TAB= anywhere in the
details collapses the summary again.

#+html: <a id="sessions-and-context"></a>
** Sessions and context

Each project directory gets its own session automatically.  Running
=M-x pilish= again from a Pilish buffer restores missing windows, or
focuses the input window when both are visible.  =M-x pilish-toggle=
hides and shows the session windows in the current frame.

=C-c C-r=, =/resume=, or =M-x pilish-session-browser= opens the
session browser.  It reads Pi's session archive from disk —
=~/.pi/agent/sessions/= by default, or =$PI_CODING_AGENT_DIR/sessions/=
— so browsing, searching, renaming, and deleting work with no live
process at all.  Sessions with a currently live Pilish process in
this Emacs get a ● marker; the marker only sees Pilish processes in
this Emacs — sessions running in another Emacs or in a terminal are
not marked.
Press =g= to refresh markers.  Press =RET= to switch to the session
at point, and =?= for the browser menu.  =d= deletes a closed session
only after a confirmation that names its displayed session title and
project.  The configured =delete-by-moving-to-trash= action leads the
prompt; unsafe title, project, and child-name characters are sanitized
or replaced, while long metadata is ellipsized so the consequence stays
visible.  Deletion acts on the selected archive pathname: if that entry
is a symlink, Emacs removes or trashes the link, not its target, while
canonical alias identity is used for live-session checks and child-fork
matching.  If the full loaded archive snapshot contains direct child
forks, the prompt reports their count
(and up to three short titles): those files are /not/ deleted.  They
appear as roots if the parent no longer appears elsewhere in the
archive; their own descendants stay attached.  Deletion does not
cascade.  Pilish checks for a live session before and after the prompt,
but “live” means only a Pilish process in this Emacs, not a system-wide
process check.  After confirmation it also rejects an observed canonical
retarget, such as a selected symlink that now resolves to another target.
This is observation, not locking: an independent writer can replace a
path without changing its canonical spelling, or change it between the
final check and =delete-file=.  Cancelling or a deletion error leaves the
current browser snapshot in place; a successful deletion refreshes it.

The browser opens on this project's sessions, showing all names in
the Threaded (fork families) view; =t= toggles between this project
and All projects.  All-projects rows lead with a bounded project
token in a fixed-width field — the project directory's name,
prefixed with the host for remote sessions (=build-host:site=) —
followed by the live marker, and only then connectors and the
session title, so identity and live status stay visible on narrow
terminals no matter how deep the fork nesting or how long the
titles.  Projects sharing a directory name grow parent components
(=client-a/app= vs =client-b/app=) until distinct; labels that
cannot fit or distinguish fall back to an ordinal-front token
(=#ORD app=, with a compact base-36 ordinal); a session run in =/=
labels as =/=.  Windows drive
and UNC working directories are recognized lexically, and
this-project rows carry no token because the scope already fixes
the project.  =s= cycles the view:
- Threaded (fork families) — sessions grouped into fork families
  through the =parentSession= link that =/fork= and Pi's =/clone=
  write; each parent renders above its children, and families are
  ordered by the latest activity anywhere in the family, newest first.
- Recent activity — flat rows ordered by the session file's
  modification time, newest first, under calendar Future / Today /
  Yesterday / This Week / Older headings: a late-night session moves
  to Yesterday at midnight, a clock-skewed future mtime groups as
  Future at the top, and on Monday last week reads as Older (Sunday
  stays Yesterday).
- Most messages — flat rows ordered by the number of persisted
  message records, tool results included, highest first.

Threaded families and Recent time groups can be folded without turning
these flat rows into nested Magit sections.  =TAB= toggles the row itself
when it has displayed descendants; on a leaf it toggles the nearest
containing foldable row (a fork ancestor or Recent group).  =S-TAB= folds
all outer families/groups, and =C-u S-TAB= unfolds everything.  =^= goes
to the family root in Threaded or the group heading in Recent.  Most
messages and queried Threaded results have no fold target or parent unit;
Recent queries keep their time groups.  Folds survive rerenders and a
filter/search round trip; they are forgotten
when the corresponding session or group leaves the published snapshot.
=n= / =p= (and =M-n= / =M-p=) keep Magit's flat section motion and skip
rows hidden by a fold.

Search results are always flat — a query never draws family
connectors.  A queried Threaded (fork families) view orders the
rows newest-first; Most messages keeps its count ordering under a
query.  These starting states are customizable:
=pilish-session-browser-default-scope= (this or all projects),
=pilish-session-browser-default-view=,
=pilish-session-browser-default-named-only=, and — for the tree
browser — =pilish-tree-browser-default-filter=.  Each initializes a
browser buffer when it is created; a browser you have toggled keeps
its state when hidden with =q= and reopened.  Killing the buffer, or
explicitly re-running the browser major mode, re-initializes from the
current defaults.

Search matches session names, first messages, and all saved
user/assistant text, including inactive branches; thinking, tool
arguments and results, images, and summaries are not searched.
Tokens are whitespace-separated regexps that must all match.
Searching a very large archive can pause Emacs while results are
gathered; =q= hides the browser without cancelling its scan.  When
an empty listing or search result can be widened, it names up to two
of the most relevant keys: =t= switches scope, =f= clears named-only,
and submitting an empty =/= query clears the filter.  An empty
All-projects archive has no
wider scope and intentionally shows no action hint.

The tree browser (=C-c C-p w=, or Context =w= in the menu) shows the
conversation tree Pi persists for a session.  On its first load, point
starts at the current projected entry or its nearest visible active
ancestor, falling back to the first visible row when no active-path
row survives.  =@= marks the actual current entry and =*= marks its active
ancestors, so a filter that hides the current entry never mislabels an
ancestor as current.  Moving point yourself wins on later refreshes;
when a selected row disappears, point first tries its nearest visible
ancestor from the old snapshot, then the new active path.  =TAB= folds a
row with displayed descendants; on a leaf it folds the nearest displayed
ancestor that has descendants.  =S-TAB= folds all outer roots and
=C-u S-TAB= unfolds everything; =^= moves to the nearest visible parent.
Fold state survives rerenders and filter/search round trips, including
when an id temporarily disappears and returns, and is pruned when that id
leaves the published tree.  Point restoration never opens a user fold or
leaves point on an invisible row, and Magit's =n= / =p= and sibling motions
skip folded rows.  Switching
the linked chat to another session file resets this orientation even
when a fork shares node IDs with the old file.

Press =RET= to continue from the selected turn.  Its behavior depends
on the selected projected node:

| Selected node | What =RET= does |
|---------------+-----------------|
| The =@= current projected position | Strict no-op before live-process, busy/transition, disk, or draft checks.  Trailing projected-away bookkeeping does not change its identity. |
| Historical user prompt or user-like =custom_message= with a parent | Continues from the parent and prefills the selected prompt text when available.  If that parent is already current, this is prefill-only; otherwise Pilish rewrites the active path and resumes the same session file. |
| Historical root user prompt or root user-like =custom_message= | Has no parent to continue from, so Pilish refuses and points to the chat's fork command.  A root prompt that is itself =@= remains the strict no-op above. |
| Non-current assistant, tool result, branch or compaction summary, model/thinking change, or other node | Continues from that node itself by rewriting the active path and resuming the same session file.  Its prefill is applied when the navigation target provides one; most such nodes provide none. |

A continuation that would replace the input draft first checks the
existing input-buffer draft state: either nonblank text or an attached
prompt image triggers =Replace the unsent draft ...?=.  Declining keeps
both text and image and occurs before any session-file rewrite or resume
is scheduled.  Acceptance stays bound to the browser, linked chat, and
session file that opened the question, then re-runs the live-session and
disk/target checks.  If that browser dies or its chat/file changes,
navigation is cancelled rather than reinterpreting the node ID in a new
tree; a same-file refresh remains valid.  If the draft changes while the
question is open, Pilish asks about the newer draft instead.  If a newer
nonempty draft appears during later yielding file-handler work, Pilish
keeps it and skips the prefill.  Blank text with no image proceeds
without a prompt, and the =@= no-op never prompts.  This is a targeted
loss-prevention check, not a blanket confirmation on every =RET=.

For a path-changing continuation, Pilish preserves complete raw JSONL
lines, writes the new order to a sibling temporary file, and replaces an
ordinary local session file with one atomic rename before asking the
normal resume flow to reload it.  This is not cross-process locking: an
independent Pi instance or external writer can still race the final
validated read and rename.  A TRAMP handler may yield and may implement
rename as copy-and-delete, so remote replacement is not guaranteed to be
atomic.

Pi's TUI can optionally ask the model for a branch summary while its
own tree-navigation API changes branches.  Pilish deliberately cannot
offer that option over Pi's current RPC surface: RPC has observation
commands such as =get_tree= and =get_entries=, but no in-place
=navigate= command; its =fork= command creates a different session file.
Pilish fully displays =branch_summary= entries already persisted in the
session file, but invents neither an LLM call nor a hidden summary
workflow during navigation.

Legacy rows without IDs are visible but cannot be continuation targets;
open the file with Pi once to migrate it.  Historical files with exact
repeats of the same nonempty-ID entry display that entry once.  If the
same ID instead names different entries, the later entry is the one
shown, unrelated unique history remains usable, and the browser shows a
duplicate-ID warning.  That ambiguous row has no =@= or =*= marker and
cannot be labeled, used as a continuation target, or made a fold target.

Tree filtering runs after display projection.  Projection first removes
raw =label=, =session_info=, and =custom= bookkeeping entries and
promotes their children to the nearest displayable ancestor.  The five
filter contracts are:
- =default= — projected entries except model and thinking-level changes;
- =no-tools= — =default= without tool results;
- =user-only= — user messages only;
- =labeled-only= — labeled nodes only;
- =all= — all /projected, displayable/ content.  It does not restore the
  raw bookkeeping records removed by projection.

Every filter also hides empty tool-dispatch assistant messages unless
aborted or carrying an error message.  The shipped value of
=pilish-tree-browser-default-filter= is =no-tools=; changing that option
changes the initial filter for new (or explicitly reinitialized) tree
browser buffers.  =f= cycles the filters.  The =?= transient lists all
five as direct choices (=d=, =n=, =u=, =L=, and =a= inside the menu), so
they do not depend on discovering the cycle order.

=/= searches the semantic text of each projected node.  Query tokens are
whitespace-separated Emacs regexps and every token must match the same
node.  The corpus includes preview/message text, the label, role and
type, full branch and compaction summaries, tool name (plus selected
fields in built-in tool previews), and provider/model or thinking-level
metadata.  It never serializes raw tool arguments or custom-tool JSON,
and binary/image and hidden thinking payloads never enter the projected
preview.  The final filter-and-query result gets a newly derived visible
tree: hidden intermediates are bypassed and roots, nearest visible
parents, sibling connectors, indentation, and gutters all describe only
the rows on screen.

=l= sets or clears a label on a node.  Labels appear in the node line for
keyboard discovery and search, and remain duplicated in the right
margin for quick scanning.  Press =g= to refresh the last persisted
state.  Display and refresh read the JSONL file from disk and still work
offline; changing position by continuing from a historical turn requires
the linked live, idle Pilish process.

The tree browser and forking answer different questions.  Example:
after two turns you want to try a second approach.  The tree browser
(Pi's =/tree=) keeps both approaches as branches of the /same/
session file; =RET= continues the live conversation from the selected
turn on that branch.  =C-c C-p f= (=/fork=, or Pi's =/clone=) instead writes
a /new/ session file whose header points back at the original, so
you get two independent sessions — and the session browser's
Threaded (fork families) view shows that file relationship as a
family.

For multiple live sessions in the same directory, use
=C-u M-x pilish= and enter a name; this names the Emacs buffer pair,
not the persisted session.  Rename the persisted session with =r= in the
session browser, =C-c C-p N=, or =/name=.  Fork with =C-c C-p f=.
When the context window fills, compacting with =C-c C-p c=
summarizes the older conversation so the session can continue; Pi
can also compact automatically, and the header line changes face as
usage crosses the configured warning and error thresholds.

#+html: <a id="common-keys"></a>
** Common keys

The main buffers:

| Key                       | Context      | Description                                 |
|---------------------------+--------------+---------------------------------------------|
| =C-c C-c=                 | input        | Send prompt, or queue follow-up if busy     |
| =C-c C-s=                 | input        | Send steering message while Pi is busy      |
| =C-c C-k=                 | input, chat  | Abort current response or compaction        |
| =C-c C-p=                 | input, chat  | Open the transient menu                     |
| =C-c C-r=                 | input, chat  | Browse sessions                             |
| =C-c C-p a i=             | menu         | Attach or replace one prompt image          |
| =C-u C-c C-p a i=         | menu         | Clear the attached prompt image             |
| =M-p= / =M-n=             | input        | Prompt history (=C-↑= / =C-↓= also work)    |
| =C-r=                     | input        | Incremental prompt-history search           |
| =TAB=                     | input        | Complete paths and =/= commands             |
| =M-<prior>= / =M-<next>=  | input        | Scroll the linked chat window               |
| =TAB=                     | chat         | Toggle summary line, thinking, tool, or turn |
| =RET=                     | chat         | Visit file target at point                  |
| =!=                       | chat         | Run shell command on file at point          |
| =w=                       | chat         | Copy the shell-local file path              |
| =n= / =p=                 | chat         | Navigate user messages                      |
| =f=                       | chat         | Fork from the turn at point                 |
| =q=                       | chat         | Quit session                                |
| =C-c C-n=                 | chat         | New session                                 |
| =C-c C-e=                 | chat         | Export session to HTML                      |
| =C-c C-c=                 | chat         | Compact conversation context                |
| =C-c C-m=                 | chat         | Select model                                |
| =C-c C-t=                 | chat         | Cycle thinking level                        |
| =C-c C-y=                 | chat         | Copy last message                           |

The browsers:

| Key       | Context        | Description                                                             |
|-----------+----------------+-------------------------------------------------------------------------|
| =RET=     | sessions       | Switch to selected session                                              |
| =RET=     | tree           | Continue from selected turn                                             |
| =TAB=     | sessions, tree | Toggle this row's fold, or its nearest containing fold                  |
| =S-TAB=   | sessions, tree | Fold all outer units (=C-u S-TAB= unfolds all)                           |
| =^=       | sessions, tree | Family root / group heading / nearest visible tree parent               |
| =n= / =p= | sessions, tree | Next / previous visible flat section (skips folded rows)                 |
| =/=       | sessions, tree | Search with regexp tokens                                               |
| =s=       | sessions       | Cycle view: Threaded (fork families), Recent activity, or Most messages |
| =t=       | sessions       | Toggle This project / All projects scope                                |
| =f=       | sessions       | Named sessions only                                                     |
| =f=       | tree           | Cycle filters; =?= offers all five directly                             |
| =r= / =d= | sessions       | Rename a session / delete a closed one                                  |
| =l=       | tree           | Set or clear a label on a node                                          |
| =g=       | sessions, tree | Refresh                                                                 |
| =?=       | sessions, tree | Browser menu                                                            |

The browser rows stay flat, so Magit's recursive cycle bindings
(=C-c TAB=, =C-<tab>=, =M-<tab>=) and level keys (=1= through =4= and
their Meta variants) are intentionally unbound.

The transient menu (=C-c C-p=) collects the full command set — new
session, reload, name, export, quit, compact, fork, tree, model,
thinking, stats, and copy last — plus submenus for skills, prompt
templates, and extension commands.  =M-x pilish-toggle= hides or
shows the session windows.

#+html: <a id="troubleshooting"></a>
* Troubleshooting and first-run notes 🩺

#+html: <details>
#+html: <summary><strong>No models are available: authenticate the Pi CLI</strong></summary>

Make sure the [[https://pi.dev/docs/latest/providers][Pi CLI is authenticated]] before expecting models to
appear: set a provider API key, or run =pi= in a terminal once and use
=/login=.  Then make the running session pick up the new credentials:
press =C-c C-p R= (or =M-x pilish-reload=) to restart the Pi
process without losing the conversation.

#+html: </details>

#+html: <details>
#+html: <summary><strong>Emacs cannot find the <code>pi</code> executable</strong></summary>

Pilish runs the command in =pilish-executable=,
which defaults to =("pi")=.  If Emacs cannot find =pi=, install the
CLI with the npm command from Quick Start on the host where Pi runs,
adjust the relevant search path, or customize
=pilish-executable=.  An example:

#+begin_src emacs-lisp
;; npx users:
(setopt pilish-executable
        '("npx" "-y" "@earendil-works/pi-coding-agent@latest"))
#+end_src

If startup says something like =env: node: No such file or directory=,
Emacs found the Pi launcher, but that launcher uses =/usr/bin/env node=.
=env= searches the subprocess =PATH=, not only Emacs =exec-path=.

If you configure Node from init.el, update both:

#+begin_src emacs-lisp
(let ((node-bin "/home/you/.local/share/pi-node/node-v22.23.1-linux-x64/bin"))
  (add-to-list 'exec-path node-bin)
  (setenv "PATH" (concat node-bin path-separator (or (getenv "PATH") ""))))
#+end_src

#+html: </details>

#+html: <details>
#+html: <summary><strong>Project-local <code>.pi</code> resources are not active</strong></summary>

Pi does not show its project trust prompt in RPC mode.  To make Emacs
sessions behave like the usual trusted project workflow,
Pilish passes =--approve= by default so project-local
=.pi= prompts, skills, settings, themes, and extensions are active.

Set =pilish-project-trust-policy= to =default= to pass no
trust flag and let Pi use its saved trust decisions and
=defaultProjectTrust=:

#+begin_src emacs-lisp
;; Let Pi decide project trust from ~/.pi/agent/trust.json
;; and its global defaultProjectTrust setting.
(setopt pilish-project-trust-policy 'default)
#+end_src

Set it to =no-approve= to pass =--no-approve= and ignore
project-local Pi files for Emacs sessions.

#+html: </details>

#+html: <details>
#+html: <summary><strong>The header shows <code>thinking (no output 5m)</code></strong></summary>

While streaming or compacting, five minutes without output from Pi
changes only the existing input activity status, using Emacs's
=warning= face; the phase stays visible.  Idle and sending sessions
are not monitored, and hiding the input also hides the warning.  The
warning never aborts, polls Pi, or changes session state.

Any nonempty parent stdout counts — ordinary replies, repeated tool
progress, even partial or malformed output; stderr does not.  Silence
is not proof of a hang: quiet tools and healthy subagents may produce
no parent output, while one sibling's output keeps the shared clock
fresh.  Timing is approximate wall time, not an awake-time deadline —
suspend, clock changes, or a blocked Emacs can shift it.  Active
sessions refresh the input header once a second when Emacs can
service its timers.

=pilish-session-inactivity-timeout= defaults to =300= seconds.  Use a
positive number to change the threshold, or =nil= to disable the
warning:

#+begin_src emacs-lisp
(setopt pilish-session-inactivity-timeout nil)
#+end_src

Changes apply at the next header refresh; reenabling retains the
existing output age.  Hover the status for help.  To stop the
current operation and discard queued continuations, use =C-c C-k=
(=M-x pilish-abort=).

#+html: </details>

#+html: <details>
#+html: <summary><strong>Tree-sitter grammar installation fails</strong></summary>

Grammar installation needs a working C compiler.  Install =gcc= or
=cc=, then run:

#+begin_src
M-x pilish-install-grammars
#+end_src

If an old system Markdown grammar is loaded and tables render
incorrectly, remove the old =libtree-sitter-markdown= from
=treesit-extra-load-path=, your Emacs tree-sitter directory, or your
system packages, then restart Emacs or run
=M-x pilish-install-grammars=.

#+html: </details>

#+html: <details>
#+html: <summary><strong>TRAMP projects run Pi on the remote host</strong></summary>

When the current project is a TRAMP directory, Pilish
starts the Pi CLI on the remote host.  Install =pi= there.

Authentication and configuration are remote too: run =pi= and =/login=
on that host, or set provider environment variables for the remote
process.  Paths such as =~/.pi/agent/auth.json= and absolute entries in
=pilish-executable= are interpreted on the host where Pi runs.

For Emacs to find the command on the remote host, its directory must be
in TRAMP's remote search path; Emacs's local =exec-path= is not used.
Use an absolute path in =pilish-executable=, or add the
executable's absolute remote directory to =tramp-remote-path=:

#+begin_src emacs-lisp
(with-eval-after-load 'tramp
  (add-to-list 'tramp-remote-path "/home/you/bin"))
#+end_src

#+html: </details>

#+html: <details>
#+html: <summary><strong>Emacs loads an old <code>transient</code> package</strong></summary>

Emacs may load its bundled =transient= before the newer MELPA package.
If the menu complains about =transient=, set
=package-install-upgrade-built-in= to =t=, install or upgrade
=transient= (needs the GNU ELPA archive for its =compat=
dependency), and restart Emacs.

#+html: </details>

#+html: <a id="how-it-compares"></a>
* How does it compare to other Emacs LLM helpers? 🆚

Pilish is a frontend for one agent: Pi.  Pi is open source and
extensible — many model providers including local and custom models,
reusable skills, prompt templates, TypeScript extensions and custom
tools, and an SDK.  Pilish communicates with Pi over its JSON-RPC
mode, receiving structured messages and tool events instead of
driving a terminal UI; that is what makes the workflow above
possible without embedding Pi's terminal UI.

Pilish has real trade-offs: it is a young package, it needs the =pi=
CLI (and Node.js) on the host where sessions run, and it does not
talk to any other backend.  Here is where each option fits:

- =pilish=: best when you want the Pi runtime (providers,
  sessions, extensions, skills, templates, custom tools, and custom
  providers) with an Emacs-native user interface.  It is not a general
  Emacs LLM library and not a universal shell for every agent; it is a
  focused frontend for Pi.

- [[https://github.com/karthink/gptel][gptel]] and [[https://github.com/karthink/gptel-agent][gptel-agent]]: =gptel= is reusable Emacs LLM
  infrastructure: any-buffer chat and rewrites, file-backed chats,
  context attachment, multiple backends, tool use, MCP through
  =mcp.el=, and APIs for custom workflows.  =gptel-agent= is the
  agentic harness built on top of it, with local file tools, Bash,
  web tools, Emacs tools, confirmations, prompts, and sub-agents.

- [[https://github.com/MatthewZMD/aidermacs][Aidermacs]]: an Emacs frontend for Aider.  It runs Aider through
  =comint= or =vterm= and adds Emacs commands for Aider sessions,
  explicit editable/read-only file context, repo-map refresh, code,
  ask, architect, and help modes, =.aider.conf.yml= configuration,
  and Ediff review of AI-generated changes.

- [[https://github.com/manzaltu/claude-code-ide.el][claude-code-ide.el]]: an Emacs integration for Claude Code CLI
  through MCP.  It starts Claude Code for the current project using
  =vterm=, =eat=, or =ghostel=, manages project sessions, tracks
  the active file and selection, exposes diagnostics and Emacs tools such
  as xref, tree-sitter, imenu, project, and custom Elisp functions,
  and shows proposed edits through Ediff.

- [[https://github.com/xenodium/agent-shell][agent-shell]]: a native Emacs shell for ACP-driven agents.  It
  relies on =acp.el= and works with agents such as Gemini CLI, Claude
  Agent, Auggie, Mistral Vibe, GitHub Copilot, and Pi through the
  =pi-acp= adapter.  It is the broader choice when one Emacs UI should
  talk to several ACP agents.

- [[https://github.com/editor-code-assistant/eca-emacs][eca-emacs]]: the Emacs client for the ECA server.  It connects to
  an external =eca= process and provides dedicated chat buffers,
  =@=-context, chat/tool approval commands, model and agent selection,
  MCP settings, inline completion, rewrite overlays, and diff or Ediff
  review.

#+html: <a id="advanced-features-and-configuration"></a>
* Advanced features and configuration ⚙️

This section is reference material: changing defaults, managing
grammars yourself, using extension features, the exact rules behind
chat file actions, and installing from source.

** Configuration

Here are some common non-default preferences:

#+begin_src emacs-lisp
;; Collapse completed thinking in new chats; live thinking still streams:
(setopt pilish-thinking-display 'hidden)

;; Make the input window 25% of the session's window pair;
;; rebalanced on frame resize:
(setopt pilish-input-window-height 0.25)

;; Copy source Markdown from the chat buffer instead of only visible text:
(setopt pilish-copy-raw-markdown t)

;; Request the native same-window opener for chat file targets;
;; use C-u RET to invert this request for one visit.  Emacs display policy
;; may redirect final placement:
(setopt pilish-visit-file-other-window nil)
#+end_src

The input window has three display styles — pick one:

#+begin_src emacs-lisp
;; Keep both windows visible whenever the session is shown (the default):
;; (setopt pilish-input-window-display 'always)

;; Show the input pane when a session launches and while composing;
;; hide it after each send and reopen it with M-x pilish-open-input:
(setopt pilish-input-window-display 'on-demand)

;; Start sessions with only the chat window visible; open the input on
;; demand (M-x pilish-open-input, or `i'/`a' under Evil) and have it
;; hide again after each send:
;; (setopt pilish-input-window-display 'hidden)
#+end_src

Less common tuning knobs:

#+begin_src emacs-lisp
;; New input buffers use plain text instead of Markdown highlighting:
;; (setopt pilish-input-markdown-highlighting nil)

;; Show more tool output before it collapses, counted in visual lines:
;; (setopt pilish-tool-preview-lines 20)
;; (setopt pilish-bash-preview-lines 10)

;; Lower the 3 MiB source limit for an outgoing prompt image:
;; (setopt pilish-prompt-image-max-bytes (* 2 1024 1024))

;; Cap inline image previews to 640 pixels as well as the chat window width;
;; lower the 10 MiB per-image preview-source limit if desired:
;; (setopt pilish-image-preview-max-width 640)
;; (setopt pilish-image-preview-max-bytes (* 5 1024 1024))

;; Lower context warning/error colors in the header line:
;; (setopt pilish-context-warning-threshold 40)
;; (setopt pilish-context-error-threshold 60)

;; Keep more recent turns live for table rewrapping and tool overlays;
;; older history is cooled to keep long sessions fast:
;; (setopt pilish-hot-tail-turn-count 5)

;; Let Pi's saved project trust decisions decide whether .pi resources load:
;; (setopt pilish-project-trust-policy 'default)

;; Hidden thinking uses generic line-count stubs instead of first-line previews:
;; (setopt pilish-thinking-hidden-preview nil)

;; New browser buffers can start elsewhere; existing browsers keep
;; their toggled state (see the Sessions and context section):
;; (setopt pilish-session-browser-default-scope 'all)
;; (setopt pilish-session-browser-default-view 'recent)
;; (setopt pilish-session-browser-default-named-only t)
;; (setopt pilish-tree-browser-default-filter 'default)
#+end_src

You can also inspect the whole customization group with:

#+begin_src
M-x customize-group RET pilish RET
#+end_src

** Evil integration

Optional Evil keybindings ship in =pilish-evil.el=, modeled on
how Evil and Magit cooperate: the read-only chat buffer starts in
motion state so navigation keys just work, the input buffer starts in
insert state, and =?= opens the transient menu.  It loads
automatically when a session is set up while Evil is in use; to opt
out, set this before loading the package:

#+begin_src emacs-lisp
(setq pilish-evil-integration nil)
#+end_src

Chat buffer (motion state):

| Key       | Action                                  |
|-----------+-----------------------------------------|
| =n= / =p= | next / previous message                 |
| =f=       | fork session at point                   |
| =w=       | copy shell-local file path at point     |
| =TAB=     | toggle tool/thinking section            |
| =RET=     | visit file at point                     |
| =i= / =a= | focus input (=a= goes to end of input)  |
| =?=       | transient menu                          |
| =q=       | quit session                            |

Input buffer (normal state):

| Key   | Action             |
|-------+--------------------|
| =RET= | send               |
| =q=   | close input window |
| =?=   | transient menu     |

Session and tree browser buffers start in motion state as well.  Evil's
=j= / =k= move line-wise; Magit's section motions remain on =n= / =p=
(and =M-n= / =M-p=) and skip folded rows.  =TAB= / =S-TAB= and =^= use
the browser folding and parent commands described above.  In the session
browser =RET= switches to the selected session; in the tree browser it
continues from the selected turn.  Every documented browser key — views,
filters, folding, parent motion, search, scope, rename, delete, refresh,
dispatch, =RET= — is rebound in motion state so the Evil and evil-collection
keymap stack never swallows them (evil's
motion state owns =/=, =?= and the =g= prefix; evil-snipe's =f= / =t=
are disabled in these buffers, mirroring the chat buffer's =f= fork
binding).

Setup also sets =pilish-copy-raw-markdown= buffer-locally in
chat buffers, so that yanking preserves code fences and markup without
changing the global default.  Each part can be tuned independently
before loading:

#+begin_src emacs-lisp
;; Different initial states:
(setq pilish-evil-chat-state 'normal)

;; Open and focus the input window in normal state instead of insert
;; (press i to start composing, RET sends, q closes the window):
(setq pilish-evil-input-state 'normal)

;; Run the browsers in emacs state instead of motion:
(setq pilish-evil-browse-state 'emacs)

;; Keep copying only visible text instead of raw Markdown:
(setq pilish-evil-copy-raw-markdown nil)
#+end_src

User bindings made after =pilish-evil-setup= runs (e.g. with
=evil-define-key= or Doom's =map!=) take precedence over these
defaults, and =M-x pilish-evil-setup= re-applies them after
changing options.

With =evil-snipe= (enabled by default in Doom): its minor-mode keymaps
shadow the chat buffer's =f= binding, so setup turns the snipe minor
modes off in chat buffers via evil-snipe's mode hooks — the same
outcome =magit-mode= gets from its entry in
=evil-snipe-disabled-modes=, and effective regardless of load order.
Fork stays on =f=, char-finding remains available on =F=, =t=, and
=T=, and snipe stays active in the input buffer.  Set
=pilish-evil-disable-snipe= to nil to keep snipe active in
chat buffers.

** Display of thinking

New chat buffers inherit =pilish-thinking-display=.  The
default is =visible=: live thinking streams while the assistant is
working and remains expanded when that thinking block finishes.  Use
=C-c C-p h= to change the current chat, or =C-c C-p H= to change the
default for future chat buffers in the current Emacs session.  To make
that default persist across restarts, set
=pilish-thinking-display= in your init file or via
=M-x customize-option=.

Press =TAB= inside completed thinking to toggle that block locally.

** Markdown tables

Pipe tables in the chat buffer are beautified as a display-only view.
Recent tables re-wrap automatically when the chat window width
changes.  Older history stays frozen at its previous width to avoid
expensive whole-buffer redisplay on every resize.  Tool blocks in
older history also lose their expand/collapse buttons and syntax
highlighting, keeping long sessions fast.  Customize
=pilish-hot-tail-turn-count= to choose how many recent
=You= and =Assistant= turns stay live.

** Images

*Prompt images* are read and materialized when attached, and sent as
stored bytes without resizing or format conversion.  Image-bearing
drafts can be sent only as direct prompts while Pi is idle: they are
not queued while Pi is busy, not used as steering messages, and not
combined with slash commands; a refusal preserves both the prompt
text and the image.  The default source limit is 3 MiB
(=pilish-prompt-image-max-bytes=).  Files are read by Emacs, so an
Emacs-readable path may use a file-name handler such as TRAMP — Pi
receives the encoded bytes, never the path (remote reads depend on
the configured handler and are not broadly tested).  Clipboard image
extraction, automatic detection of paths typed into the prompt, and
multiple attachments are deferred.

*Inline display:* image content in sent user turns and completed tool
results renders inline in graphical Emacs and as a type-and-size
placeholder in terminals; sent and returned images share the same
bounded renderer.  Pi's built-in =read= already returns raster images
as image content, resized to at most 2000x2000.  For SVG, a returned
preview is made only from complete, standalone SVG text supplied by
=read=;
Pilish never reopens the argument path, and text with obvious scripts
or external resources is left as text.  Images in partial tool updates
appear when the final result arrives.  Unresized prompt images and
custom tools can supply animated or highly compressed data whose
decoder cost is not bounded by the source-byte cap; the preview
settings in Configuration bound what renders.  Moving a
terminal-rendered chat to a GUI, resizing previews, or applying a
later extension replacement may require a toggle or history reload.

** Sessions, transcripts, and exports

To open an existing Pi JSONL session file directly, run:

#+begin_src
M-x pilish-open-session-file
#+end_src

This opens the file as a live Pi session in the normal chat/input UI,
not as a static viewer.  In Dired, it defaults the prompt to the
regular file at point.  From a buffer visiting a local readable
=.jsonl= file, it defaults to that file.

You can save the chat buffer like any other buffer to keep a Markdown
transcript on disk.  Saving does not interrupt or replace the live Pi
session.  For a shareable export, use HTML export from the menu
(=C-c C-p e=) or run =/export=.

** File and command actions

These are the exact rules behind =RET=, =!=, and =w= in the chat
buffer.

=RET= opens one strict file target at point: a file-content row in
tool output, a plain path reference, or the label of a local Markdown
link.  Tool headers, fences, preview hints, and other non-content rows
do not open files.  Plain locations such as =src/app.el:12:3= visit
their one-based physical file line and optional one-based column; a
range such as =src/app.el#L12-L20= visits its first line only.  If an
explicit location is outside an existing narrowing, the default
=widen-automatically= policy widens the file.  Coordinates apply only
to file-visiting buffers; directory targets retain native Dired point
and marks.  A target without a location keeps the point, mark, and
narrowing chosen by normal Emacs file visiting.  By default Pilish
requests the native other-window opener; =C-u RET= inverts
=pilish-visit-file-other-window= for that visit, and Emacs display
policy may redirect the final placement.

=!= runs one strict file target through a shell command,
Dired-inspired.  One command word followed only by
whitespace-delimited options beginning with =-= receives the quoted
target appended automatically, so =wc -l= and =grep -n= work as-is.
All other command text — ordinary arguments, compound/control syntax,
multiple lines — must place a textual isolated =*= (bounded by a
space, tab, or string edge) where the target goes: =grep -n TODO *=,
=head -20 * | less=.  A final whitespace-delimited ~&~ uses the usual
asynchronous shell output.  Prompting and execution use a snapshot of
the target session's local or TRAMP execution environment.

=w= copies the same shell-local path that =!= operates on.  Local
paths are absolute; paths in remote sessions use the remote shell's
namespace without an Emacs TRAMP prefix.  Line, column, range, and
link-fragment metadata are excluded, and the kill-ring value is the
literal, unquoted pathname, so spaces remain spaces and no =@= prompt
marker is added.

** Extension support

Pilish has basic support for Pi
extensions.  Extension commands show up in slash completion and the
transient menu, extension tools run normally, and extensions can use
notifications, confirm/select/input prompts, prefill the input buffer,
and show status text.

Rich TUI-specific extension UI is not supported in Emacs yet: custom
widgets, custom editor components, custom headers/footers, and other
component-based views are unavailable or fall back.  Hover extension
status text in the header line to see the exact =statusKey=, which you
can use to change the font for that extension's status text using
=pilish-extension-status-faces=.

** Tree-sitter grammar management

Pilish uses Emacs's built-in tree-sitter support to render
Markdown and highlight code blocks in the chat and input buffers.  The
two essential grammars are =markdown= and =markdown-inline=.  Optional
grammars such as =python=, =javascript=, =rust=, and =go= improve
syntax highlighting inside code blocks.

On first session start, Pilish prompts to install the
essential grammars.  If you decline that prompt, it appears again next
time until the grammars are installed or you change
=pilish-essential-grammar-action=.  A separate optional
prompt offers additional grammars for syntax highlighting inside code
blocks.  Declining the optional prompt is remembered and it appears
again only if new grammar recipes are added or you clear
=pilish-grammar-declined-set=.

To check which grammars are installed or install them later:

#+begin_src
M-x pilish-install-grammars
#+end_src

If you manage tree-sitter grammars outside of Emacs, for example
through a system package manager, set
=pilish-essential-grammar-action= to =warn= to suppress the
essential grammar prompt:

#+begin_src
(setopt pilish-essential-grammar-action 'warn)
#+end_src

** Activity phase hooks

For custom UI changes tied to activity, add functions to
=pilish-activity-phase-functions=.  Each function receives:

#+begin_src emacs-lisp
(CHAT-BUFFER INPUT-BUFFER OLD-PHASE NEW-PHASE REASON)
#+end_src

=NEW-PHASE= is one of =thinking=, =replying=, =running=, =compact=, or
=idle=.  =REASON= explains why the phase was applied:

| Reason         | Meaning                                       |
|----------------+-----------------------------------------------|
| =phase-change= | The session activity phase changed.           |
| =reset=        | A session reset forced =idle=.                |
| =teardown=     | Session teardown forced =idle=.               |
| =input-link=   | A newly linked input should apply the phase.  |
| =input-unlink= | An old input should clean up local UI state.  |

Handlers should be idempotent.  Pilish may reapply the same
phase when buffers are relinked, reset, or torn down.  =INPUT-BUFFER=
may also be nil or dead during teardown.

As an example, consider tinting the input buffer while the session is
busy:

#+begin_src emacs-lisp
(defvar-local my-pi-input-tint-cookie nil)

(defun my-pi-tint-input-while-busy (_chat input _old new _reason)
  (when (buffer-live-p input)
    (with-current-buffer input
      (when my-pi-input-tint-cookie
        (face-remap-remove-relative my-pi-input-tint-cookie)
        (setq my-pi-input-tint-cookie nil))
      (unless (string= new "idle")
        (setq my-pi-input-tint-cookie
              (face-remap-add-relative
               'default :background "gray20"))))))

(add-hook 'pilish-activity-phase-functions
          #'my-pi-tint-input-while-busy)
#+end_src

Another example: Notify when a real session turn finishes.  The
=REASON= check matters: input relink cleanup also applies =idle= to
the old input, but that does not mean Pi finished working.

#+begin_src emacs-lisp
(defun my-pi-message-when-done (chat _input old new reason)
  (when (and (eq reason 'phase-change)
             (not (string= old "idle"))
             (string= new "idle"))
    (message "Pi finished in %s" (buffer-name chat))))

(add-hook 'pilish-activity-phase-functions
          #'my-pi-message-when-done)
#+end_src

Track busy sessions for your own mode-line or tab display:

#+begin_src emacs-lisp
(defvar my-pi-busy-sessions nil)

(defun my-pi-track-busy-sessions (chat _input _old new reason)
  (when (memq reason '(phase-change reset teardown))
    (setq my-pi-busy-sessions (delq chat my-pi-busy-sessions))
    (unless (string= new "idle")
      (push chat my-pi-busy-sessions))
    (force-mode-line-update t)))

(add-hook 'pilish-activity-phase-functions
          #'my-pi-track-busy-sessions)
#+end_src

** Installation details

MELPA (with [[https://elpa.gnu.org/][GNU ELPA]] added for =compat=)
installs =transient=, =magit-section=, =md-ts-mode=, and
=markdown-table-wrap= automatically.  Pilish uses =md-ts-mode= only for its own
chat and input buffers, so installing or loading this package does not
change how unrelated =.md= files open.  If you want tree-sitter
Markdown globally, configure =md-ts-mode= separately.

With =use-package= (after the archive setup from Quick start):

#+begin_src emacs-lisp
(use-package pilish
  :ensure t
  :init (defalias 'pi 'pilish))
#+end_src

On a fresh Emacs installation, run =M-x package-refresh-contents=
once before restarting: =use-package='s =:ensure t= can only install
=pilish= after the archive index has been downloaded.

For a plain Git checkout, configure the MELPA and GNU ELPA archives
as shown in Quick start, then evaluate this once to install the
external dependencies:

#+begin_src emacs-lisp
;; Must be set before installing/upgrading Emacs's bundled transient.
(setq package-install-upgrade-built-in t)

(package-install 'transient)
(package-install 'magit-section)
(package-install 'md-ts-mode)
(package-install 'markdown-table-wrap)
#+end_src

Then clone the repository and add it to your load path:

#+begin_src bash
git clone https://github.com/dnouri/pilish ~/.emacs.d/site-lisp/pilish
#+end_src

#+begin_src emacs-lisp
(require 'package)
(package-initialize)
(add-to-list 'load-path "~/.emacs.d/site-lisp/pilish")
(require 'pilish)
#+end_src

Or with =use-package= after installing the dependencies above:

#+begin_src emacs-lisp
(use-package pilish
  :load-path "~/.emacs.d/site-lisp/pilish"
  :init (defalias 'pi 'pilish))
#+end_src

If you prefer not to install the Pi CLI globally, point
=pilish-executable= at npx instead; see the example in the
Troubleshooting entry about finding the =pi= executable.

#+html: <a id="development"></a>
* Development 🛠️

Most users can skip this section.  It is for contributors and local
package development.

** Running tests locally

The shared integration tests have two lanes:

- a fast fake-pi lane for deterministic RPC-boundary checks
- a real pi lane for backend compatibility coverage

The default local integration target runs the fake lane first and the
real lane second, so it still needs Docker for the real lane.  The GUI
suite is fully fake-backed and does not need Docker or a local pi
install.

#+begin_src bash
# Byte-compile, lint, and unit tests
make check

# Shared integration contract: fake first, then real
make test-integration

# Fast integration lane against the fake-pi harness only;
# needs neither Docker nor a pi install
make test-integration-fake

# Real pi lane only; starts the project-local Ollama Docker container
make test-integration-real

# Run a single integration contract by selector
make test-integration-fake SELECTOR=rpc-smoke
make test-integration-real SELECTOR=steering-contract

# Deterministic GUI tests (fake-backed, no Docker or local pi install)
make test-gui

# Run one GUI regression by selector
make test-gui SELECTOR=tool-overlay-bounded

# All tests
make test-all
#+end_src

** Running fake-pi manually

Run the harness directly when debugging the subprocess contract
itself.  These commands start an interactive JSONL peer on
stdin/stdout.

#+begin_src bash
# Basic prompt lifecycle scenario
uv run --script test/support/fake_pi.py --scenario prompt-lifecycle

# Extension dialog scenario with a longer manual timeout
./test/support/fake_pi.py --scenario extension-confirm --extension-timeout-ms 10000
#+end_src

Scenario fixtures live under =test/fixtures/fake-pi/=.

** GUI tests with visible window

The GUI suite is deterministic and fake-backed.  By default it
auto-detects whether to show a window or run headless.

#+begin_src bash
# With a display available, runs with visible window
./test/run-gui-tests.sh

# Run one visible regression directly
./test/run-gui-tests.sh pilish-gui-test-scroll-auto-when-at-end

# Force headless even with display available
./test/run-gui-tests.sh --headless
#+end_src

** CI setup

GitHub Actions runs on every push:

- =test-unit.yml= - Unit tests across Emacs 29.4, 30.1, and snapshot (31)
- =lint.yml= - Byte-compile, checkdoc, and package-lint across Emacs 29.4, 30.1, and snapshot
- =test-integration.yml= - Split fake/real integration jobs; fake stays fast, real keeps Ollama compatibility coverage
- =test-gui.yml= - Deterministic fake-backed GUI tests with xvfb virtual framebuffer

The real integration workflows use Node 24.  Nightly builds keep real
integration coverage against the pinned pi version (from Makefile) and
latest, while the fake-backed GUI suite runs once.

* Links 🔗

- [[https://pi.dev][pi.dev]]: pi coding agent home page

* License ⚖️

GPL-3.0-or-later. See [[file:LICENSE][LICENSE]].

Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.

agentic-coding
emacs
harness-engineering
pi

Contributors

dnouri

269 commits

SayreBlades

8 commits

leo-ar

2 commits

bobrowadam

2 commits

Languages

Emacs Lisp

94.8%

Python

3.2%

Shell

1.5%