Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 114 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Design: open work for agent-shell-side

Status as of 2026-09-06. `make check` is green (99 ERT tests against stubs)
and `make live-check` passes 10 of 10 against a real claude-agent-acp 0.70.
Status as of 2026-09-06. `make check` is green (107 ERT tests against stubs).
`make live-check` last passed 10 of 10 against a real claude-agent-acp 0.70,
before the handback moved to a compose buffer; its probes were updated to
match but have not been re-run against an agent since.

Four questions were open when this was written. This document records the
decision for each, and what changed once they were built and run. Items 1
Expand Down Expand Up @@ -299,9 +301,11 @@ and what runs either side of it.
clearing happens in the same call right after the event is dispatched.

4. **The README described a mode-line marker that did not exist.** The
pending flag was read only by the `conclude` guard. Fixed by building
it: the lighter now reads " Side:findings waiting", and pending
outranks parent status, which would otherwise say the same thing twice.
pending flag was read only by the `conclude` guard. It was built --
the lighter grew a " Side:findings waiting" arm -- and then removed
again with the rest of the wait-for-idle machinery once the handback
became a compose buffer. Nothing is ever pending now, so there is
nothing for a marker to say.

5. **Resume dropped the record before the session was known to load.**
`session/load` is still in flight at that point, so an agent that
Expand Down Expand Up @@ -350,6 +354,111 @@ and what runs either side of it.
resumed shell reads its id from agent-shell's state. The test was
passing against a source the real path never uses.

## 7. Where it is shown, and one handback path

Two changes from using the thing, 2026-09-06.

### Beside the parent, not instead of it

The package had no display option of its own: it reused
`agent-shell-display-action`, whose default is
`display-buffer-same-window`, so a side conversation took over the
parent's window and toggling swapped them. That is Codex's behaviour
because a terminal has one pane. Emacs does not, and the whole appeal of
a side conversation is reading it next to what it forked from.

`agent-shell-side-display-action` now defaults to a window on the right.
Kept separate from `agent-shell-display-action` deliberately: pointing
that one at a side window would move every shell, not just these.

Three things had to move with it, all found by pointing a side-window
action at the old code rather than by reading it:

- **Closing put the parent into the side window.** The close path handed
the side's window to the parent, giving `((PARENT right) (PARENT nil))`
-- the parent displayed twice, once in a strip meant for something
else. A side window is scenery for what it holds, so it is now deleted
with the conversation.
- **Toggling duplicated the side.** With both visible it gave
`((SIDE nil) (SIDE right))`, the parent nowhere. Toggle now selects the
other window when both are on screen. When they are not, it displays
through the action, which reproduces the old swap exactly when that
action is `display-buffer-same-window` -- so the old behaviour is still
one setting away and needs no special case.
- **`delete-other-windows` signals** while a side window is selected
("Cannot make side window the only window"). That is why the test
helper deletes side windows before calling it.

The parent is displayed through `agent-shell-display-action`, not the
side action. It is an ordinary shell.

Under viewport interaction the action is not consulted at all. The
viewport owns its layout, and a compose buffer wedged into a side window
is not an improvement.

### One handback path

The handback had two shapes. Under viewport interaction the findings went
straight into the parent's compose buffer, which takes text mid-turn.
Otherwise they went to the shell prompt, which does not, so a busy parent
meant holding the findings, a pending flag, a mode-line marker, a
deferred re-check to work around `error` arriving before the busy flag
clears, and the side conversation closing itself at whatever moment the
parent's turn happened to end.

All of that is gone. The findings always go to a compose buffer.

The argument for it is not just that it is less code. That setting
decides where the *user's own prompts* go; it has no business deciding
what happens to findings, and letting it do so gave the two settings
visibly different commands for the same task. The compose buffer is also
strictly better than waiting: it takes the text immediately, mid-turn,
and hands back the three answers that matter from its own keys -- send,
queue behind the running turn, or steer into it. Waiting could only ever
offer the first, and only later.

Removed with it: `agent-shell-side-handback-submit`, which had become a
setting that could not do anything, since sending is now the compose
buffer's business rather than ours.

Review of that change found four more, three of them real:

- **`conclude` signalled from a compose buffer.** `agent-shell-insert`
dispatches on the *current* buffer, not the target: from a viewport it
routes into `agent-shell-viewport--show-buffer`, whose first two lines
reject `:submit` and `:no-focus` with "Not yet supported". So asking
the side conversation for its summary failed exactly where the compose
buffer had just become the routine place to stand. The subscription and
its 180s timer were already installed, so the user saw nothing for
three minutes and then "no summary after 180s". Both call sites now go
through `agent-shell-side-compat-send-to-shell`, which addresses the
shell buffer directly. The stub had hidden this by not dispatching at
all; making it faithful also failed an existing viewport test that had
been passing vacuously.
- **The handback downgraded a queued draft to a steer.**
`agent-shell-viewport--show-buffer` writes the compose disposition on
every call, nil included, by design. Appending findings to a prompt the
user had marked `queue` reset it, so `C-c C-c` fell through to
`agent-shell-prompt-while-busy`, whose default is `steer` -- their
queued follow-up would have been injected into the running turn.
The disposition of an in-progress draft is now read first and handed
back.
- **The new side-window arm in `--close` was dead.**
`display-buffer-in-side-window` dedicates the window, so `kill-buffer`
has already deleted it via `replace-buffer-in-windows`. Verified: the
predicate was never called, and its test passed anyway because Emacs
did the work. The arm and `agent-shell-side--side-window-p` are gone;
the test stays, since it asserts the outcome.
- Not a defect: the compose buffer is selected when the summary lands,
which the old `:no-focus` path never did. That is the point of the
change, but it is a visible difference for every user, so the README
now says so.

The recurring lesson holds, in a third form. It was not enough to read
what an upstream function does; this one dispatches on ambient state, so
the same call means different things depending on where the user is
standing.

## Order of work

1. Item 1's live test file, including the item 2 probe. It is the
Expand Down
69 changes: 50 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ one.
| Command | Key (in linked buffers) | What it does |
| --- | --- | --- |
| `agent-shell-side` | | Fork this conversation into a side one and switch to it |
| `agent-shell-side-toggle` | `C-c C-b` | Switch between the side conversation and its parent |
| `agent-shell-side-toggle` | `C-c C-b` | Move between the side conversation and its parent |
| `agent-shell-side-conclude` | `C-c C-q` | Summarise the side conversation into the parent, then close it |
| `agent-shell-side-dismiss` | `C-c C-k` | Close the side conversation, discarding what it learned |
| `agent-shell-side-resume` | | Reopen a side conversation that was kept |
Expand All @@ -47,19 +47,27 @@ An empty question opens it blank.
### Carrying findings back

`agent-shell-side-conclude` (`C-c C-q`) asks the side conversation for a summary
addressed to the parent, waits for it, puts it at the parent's prompt, and closes
the side conversation. The summary is left for you to review rather than sent.
Set `agent-shell-side-handback-submit` to send it instead.

Where the summary lands follows how you use `agent-shell`. With
`agent-shell-prefer-viewport-interaction` it is appended to the parent's viewport
compose buffer, opened in edit mode so this works while the parent is mid-turn;
the compose buffer's own keys then send, queue, or steer it. Otherwise it goes
to the parent's shell prompt: at once when the parent is idle, or as soon as its
turn ends when it is not. A busy shell cannot take text at its prompt, since
shell-maker appends output at the end of the buffer and would swallow it. The
side conversation stays open until the findings are staged, so a parent that is
killed first loses nothing, and its mode line says the findings are waiting.
addressed to the parent, waits for it, seeds it into a compose buffer for the
parent, and closes the side conversation.

The summary is never sent for you. It arrives as a draft you can edit, and the
compose buffer's own keys decide what happens to it: `C-c C-c` sends, `C-c C-q`
queues it behind the parent's running turn, `C-c C-i` steers it into that turn,
and `C-c C-k` throws it away. That is the same set of choices you have for any
prompt, which is the point.

This is one path, not two. It does not depend on
`agent-shell-prefer-viewport-interaction`: that setting decides where your own
prompts go and has no business deciding what happens to findings. A compose
buffer is also the only surface that works while the parent is mid-turn, since a
busy shell cannot take text at its prompt. shell-maker appends output at the end
of the buffer and would swallow it.

The draft comes to you. Summarising takes as long as the agent takes, so the
compose buffer opens and is selected whenever the summary lands, which may be
after you have moved on to something else. If the compose buffer already held an
unsent draft of your own, the findings are appended below it and your draft's
queue-or-steer choice is kept.

If the summary comes back empty, or the parent is gone, the side conversation is
left open rather than closed on nothing. Customize
Expand All @@ -71,6 +79,28 @@ finishes, fails, or asks for approval, the side conversation's mode line says so
echoes it once. Set `agent-shell-side-report-parent-status` to nil for the mode
line only.

### Where it opens

A side conversation runs beside the conversation it was forked from, so it opens
in a window of its own on the right and the parent stays visible. That is the
arrangement a terminal cannot offer, and the reason `agent-shell-side-display-action`
exists separately from `agent-shell-display-action`, which is left to decide
where ordinary shells go.

`agent-shell-side-toggle` (`C-c C-b`) then moves point between the two windows
rather than rearranging them. Closing the side conversation deletes its window.

To get the old behaviour, where the side conversation takes over the parent's
window and toggling swaps the two, set it to same-window:

```elisp
(setq agent-shell-side-display-action '(display-buffer-same-window))
```

Everything follows from the action: toggling still selects the other window when
both happen to be visible, and falls back to displaying through the action when
they are not.

### Keeping track of what is open

A side conversation lives until you close it, so they collect quietly.
Expand Down Expand Up @@ -165,8 +195,8 @@ the record stays so you can try again.

With `agent-shell-prefer-viewport-interaction` set, or when started from a
viewport buffer, the side conversation opens through a viewport as
`agent-shell-fork` would, and the handback goes to the parent's own compose
buffer rather than its shell prompt.
`agent-shell-fork` would, and `agent-shell-side-display-action` is not consulted:
the viewport owns its own layout.

The keys above are deliberately *not* bound in a viewport compose buffer.
`agent-shell-viewport-edit-mode` already uses `C-c C-k` to discard the draft and
Expand Down Expand Up @@ -202,10 +232,11 @@ Against claude-agent-acp 0.70, end to end:
before the standing-conventions clause; see above)
- `session/delete` succeeds

`make live-check` covers the rest, and passes 10 of 10 against
claude-agent-acp 0.70:
`make live-check` covers the rest. It last passed 10 of 10 against
claude-agent-acp 0.70, before the handback moved to a compose buffer:

- the handback summarises, reaches the parent's prompt staged rather than sent,
- the handback summarises, reaches the parent's compose buffer as an editable
draft rather than being sent,
and closes the side conversation
- a handback whose parent is killed mid-summary leaves the side conversation
open rather than discarding it
Expand Down
55 changes: 51 additions & 4 deletions agent-shell-side-compat.el
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
(eval-when-compile (require 'cl-lib))

(declare-function agent-shell--start "agent-shell")
(declare-function agent-shell--insert-to-shell-buffer "agent-shell")
(declare-function agent-shell-viewport--show-buffer "agent-shell-viewport")
(declare-function agent-shell-viewport--buffer "agent-shell-viewport")

(defvar agent-shell--state)
(defvar agent-shell-prefer-viewport-interaction)
Expand Down Expand Up @@ -148,18 +150,63 @@ was issued from a viewport buffer. Mirrors `agent-shell--fork-shell-buffer'."
agent-shell-side-compat--upgrade-hint))
(agent-shell-viewport--show-buffer :shell-buffer shell-buffer))

(defun agent-shell-side-compat--compose-disposition (shell-buffer)
"Return the disposition of SHELL-BUFFER\='s in-progress compose draft.

Nil when there is no draft to speak for. `agent-shell-viewport--show-buffer\='
writes the disposition on every call, nil included, so appending to a
draft the user had already marked `queue\=' would silently downgrade it to
whatever `agent-shell-prompt-while-busy\=' says -- steer, by default.
Reading it first lets us hand the same answer back.

Only an edit-mode buffer with something in it counts, matching how
`agent-shell-viewport--show-buffer\=' itself decides a draft is in
progress. An empty compose buffer keeps a stale disposition, which is
the very thing writing on every call exists to clear."
(when (fboundp 'agent-shell-viewport--buffer)
(when-let* ((viewport (ignore-errors
(agent-shell-viewport--buffer
:shell-buffer shell-buffer :existing-only t)))
((buffer-live-p viewport)))
(with-current-buffer viewport
(and (derived-mode-p 'agent-shell-viewport-edit-mode)
(> (buffer-size) 0)
(bound-and-true-p agent-shell-viewport--compose-disposition))))))

(defun agent-shell-side-compat-compose-in-viewport (shell-buffer text)
"Open SHELL-BUFFER's viewport compose buffer with TEXT appended, to be sent.

Opens in edit mode even while the shell is busy, which is the point: the
compose buffer is the one surface a viewport user can write in while a
turn runs, and its send keys let them queue or steer what they wrote."
turn runs, and its send keys let them queue or steer what they wrote.

Carries any in-progress draft's disposition through, so appending
findings to a prompt the user had already chosen to queue does not turn
it into a steer."
(unless (fboundp 'agent-shell-viewport--show-buffer)
(error "Missing agent-shell-viewport--show-buffer; %s"
agent-shell-side-compat--upgrade-hint))
(agent-shell-viewport--show-buffer :shell-buffer shell-buffer
:append text
:edit t))
(agent-shell-viewport--show-buffer
:shell-buffer shell-buffer
:append text
:edit t
:disposition (agent-shell-side-compat--compose-disposition shell-buffer)))

(defun agent-shell-side-compat-send-to-shell (shell-buffer text)
"Submit TEXT to SHELL-BUFFER without moving the user\='s focus.

Deliberately not `agent-shell-insert\=': that dispatches on the *current*
buffer, so calling it from a viewport compose buffer routes into
`agent-shell-viewport--show-buffer\=', which signals \"Not yet supported\"
for both `:submit\=' and `:no-focus\='. The target here is always a side
conversation\='s shell buffer, never a viewport, so address it directly."
(unless (fboundp 'agent-shell--insert-to-shell-buffer)
(error "Missing agent-shell--insert-to-shell-buffer; %s"
agent-shell-side-compat--upgrade-hint))
(agent-shell--insert-to-shell-buffer :text text
:submit t
:no-focus t
:shell-buffer shell-buffer))

(provide 'agent-shell-side-compat)

Expand Down
Loading
Loading