Skip to content
Open
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
6 changes: 5 additions & 1 deletion packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Session selector (`/resume`) now supports named-only filter toggle (default `Ctrl+N`, configurable via `toggleSessionNamedFilter`) to show only named sessions ([#862](https://github.com/badlogic/pi-mono/issues/862))

## [0.50.1] - 2026-01-26

### Fixed
Expand Down Expand Up @@ -2257,4 +2261,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
144 changes: 144 additions & 0 deletions packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,143 @@ Submit messages while the agent is working:
- **Alt+Up** retrieves queued messages back to editor

Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUpMode` can be `"one-at-a-time"` (default, waits for response) or `"all"` (delivers all queued at once).
- Letters: `a-z`
- Numbers: `0-9`
- Special keys: `escape`, `tab`, `enter`, `space`, `backspace`, `delete`, `home`, `end`, `up`, `down`, `left`, `right`
- Symbol keys: `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?`

**Configurable actions:**

| Action | Default | Description |
|--------|---------|-------------|
| `cursorUp` | `up` | Move cursor up |
| `cursorDown` | `down` | Move cursor down |
| `cursorLeft` | `left` | Move cursor left |
| `cursorRight` | `right` | Move cursor right |
| `cursorWordLeft` | `alt+left`, `ctrl+left` | Move cursor word left |
| `cursorWordRight` | `alt+right`, `ctrl+right` | Move cursor word right |
| `cursorLineStart` | `home`, `ctrl+a` | Move to line start |
| `cursorLineEnd` | `end`, `ctrl+e` | Move to line end |
| `deleteCharBackward` | `backspace` | Delete char backward |
| `deleteCharForward` | `delete` | Delete char forward |
| `deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward |
| `deleteWordForward` | `alt+d`, `alt+delete` | Delete word forward |
| `deleteToLineStart` | `ctrl+u` | Delete to line start |
| `deleteToLineEnd` | `ctrl+k` | Delete to line end |
| `yank` | `ctrl+y` | Paste most recently deleted text |
| `yankPop` | `alt+y` | Cycle through deleted text after pasting |
| `undo` | `ctrl+-` | Undo last edit |
| `newLine` | `shift+enter` | Insert new line |
| `submit` | `enter` | Submit input |
| `tab` | `tab` | Tab/autocomplete |
| `interrupt` | `escape` | Interrupt operation |
| `clear` | `ctrl+c` | Clear editor |
| `exit` | `ctrl+d` | Exit (when empty) |
| `suspend` | `ctrl+z` | Suspend process |
| `cycleThinkingLevel` | `shift+tab` | Cycle thinking level |
| `cycleModelForward` | `ctrl+p` | Next model |
| `cycleModelBackward` | `shift+ctrl+p` | Previous model |
| `selectModel` | `ctrl+l` | Open model selector |
| `expandTools` | `ctrl+o` | Expand tool output |
| `toggleThinking` | `ctrl+t` | Toggle thinking |
| `toggleSessionNamedFilter` | `ctrl+n` | Toggle named-only filter in session picker (`/resume`) |
| `externalEditor` | `ctrl+g` | Open external editor |
| `followUp` | `alt+enter` | Queue follow-up message |
| `dequeue` | `alt+up` | Restore queued messages to editor |
| `selectUp` | `up` | Move selection up in lists (session picker, model selector) |
| `selectDown` | `down` | Move selection down in lists |
| `selectConfirm` | `enter` | Confirm selection |
| `selectCancel` | `escape`, `ctrl+c` | Cancel selection |

**Example (Emacs-style):**

```json
{
"cursorUp": ["up", "ctrl+p"],
"cursorDown": ["down", "ctrl+n"],
"cursorLeft": ["left", "ctrl+b"],
"cursorRight": ["right", "ctrl+f"],
"cursorWordLeft": ["alt+left", "alt+b"],
"cursorWordRight": ["alt+right", "alt+f"],
"deleteCharForward": ["delete", "ctrl+d"],
"deleteCharBackward": ["backspace", "ctrl+h"],
"newLine": ["shift+enter", "ctrl+j"]
}
```

**Example (Vim-style):**

```json
{
"cursorUp": ["up", "alt+k"],
"cursorDown": ["down", "alt+j"],
"cursorLeft": ["left", "alt+h"],
"cursorRight": ["right", "alt+l"],
"cursorWordLeft": ["alt+left", "alt+b"],
"cursorWordRight": ["alt+right", "alt+w"],
"deleteCharBackward": ["backspace", "ctrl+h"],
"deleteWordBackward": ["ctrl+w", "alt+backspace"]
}
```

**Example (symbol keys):**

```json
{
"submit": ["enter", "ctrl+j"],
"newLine": ["shift+enter", "ctrl+;"],
"toggleThinking": "ctrl+/",
"cycleModelForward": "ctrl+.",
"cycleModelBackward": "ctrl+,",
"interrupt": ["escape", "ctrl+`"]
}
```

> **Note:** Some `ctrl+symbol` combinations overlap with ASCII control characters due to terminal legacy behavior (e.g., `ctrl+[` is the same as Escape, `ctrl+M` is the same as Enter). These can still be used with `ctrl+shift+key` (e.g., `ctrl+shift+]`). See [Kitty keyboard protocol: legacy ctrl mapping of ASCII keys](https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys) for all unsupported keys.

### Bash Mode

Prefix commands with `!` to execute them and add output to context:

```
!ls -la
!git status
!cat package.json | jq '.dependencies'
```

Output streams in real-time. Press Escape to cancel. Large outputs truncate at 2000 lines / 50KB.

The output becomes part of your next prompt, formatted as:

```
Ran `ls -la`

<output here>
```

Run multiple commands before prompting; all outputs are included together.

### Image Support

**Pasting images:** Press `Ctrl+V` to paste an image from your clipboard.

> **Note:** On macOS, pressing Cmd+C on an image file in Finder copies the file path, not the image contents. Use Preview or another image viewer to copy the actual image, or drag the file onto the terminal instead.

**Dragging images:** Drag image files onto the terminal to insert their path. On macOS, you can also drag the screenshot thumbnail (after Cmd+Shift+4) directly onto the terminal.

**Attaching images:** Include image paths in your message:

```
You: What's in this screenshot? /path/to/image.png
```

Supported formats: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`

**Auto-resize:** Images larger than 2000x2000 pixels are automatically resized to fit within this limit for better compatibility with Anthropic models. The original dimensions are noted in the context so the model can map coordinates back if needed. Disable via `images.autoResize: false` in settings.

**Inline rendering:** On terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images, images in tool output are rendered inline. On unsupported terminals, a text placeholder is shown instead.

Toggle inline images via `/settings` or set `terminal.showImages: false` in settings.

---

Expand All @@ -197,6 +334,13 @@ pi --no-session # Ephemeral mode (don't save)
pi --session <path> # Use specific session file or ID
```

In the `/resume` picker:
- `Ctrl+P` toggles display of the session `.jsonl` file path
- `Ctrl+N` toggles the named-only filter (configurable via `toggleSessionNamedFilter` in `keybindings.json`)
- `Ctrl+D` deletes the selected session (inline confirmation; uses `trash` if available and cannot delete the active session)

**Resuming by session ID:** The `--session` flag accepts a session UUID (or prefix). Session IDs are visible in filenames under `~/.pi/agent/sessions/<project>/` (e.g., `2025-12-13T17-47-46-817Z_a8ec1c2a-5a5f-4699-88cb-03e7d3cb9292.jsonl`). The UUID is the part after the underscore. You can also search by session ID in the `pi -r` picker.

### Branching

**`/tree`** - Navigate the session tree in-place. Select any previous point, continue from there, and switch between branches. All history preserved in a single file.
Expand Down
4 changes: 3 additions & 1 deletion packages/coding-agent/src/cli/session-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
import { KeybindingsManager } from "../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";

Expand All @@ -15,6 +16,7 @@ export async function selectSession(
): Promise<string | null> {
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal());
const keybindings = KeybindingsManager.create();
let resolved = false;

const selector = new SessionSelectorComponent(
Expand All @@ -39,7 +41,7 @@ export async function selectSession(
process.exit(0);
},
() => ui.requestRender(),
{ showRenameHint: false },
{ showRenameHint: false, keybindings },
);

ui.addChild(selector);
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/core/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export type AppAction =
| "selectModel"
| "expandTools"
| "toggleThinking"
// Session selector-only action. Intentionally not treated as a globally reserved shortcut for
// extension shortcut conflict checks, since it only applies inside the /resume picker
| "toggleSessionNamedFilter"
| "externalEditor"
| "followUp"
| "dequeue"
Expand Down Expand Up @@ -56,6 +59,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
selectModel: "ctrl+l",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
toggleSessionNamedFilter: "ctrl+n",
externalEditor: "ctrl+g",
followUp: "alt+enter",
dequeue: "alt+up",
Expand All @@ -82,6 +86,7 @@ const APP_ACTIONS: AppAction[] = [
"selectModel",
"expandTools",
"toggleThinking",
"toggleSessionNamedFilter",
"externalEditor",
"followUp",
"dequeue",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { SessionInfo } from "../../../core/session-manager.js";

export type SortMode = "recent" | "relevance";

export type NameFilter = "all" | "named";

export interface ParsedSearchQuery {
mode: "tokens" | "regex";
tokens: { kind: "fuzzy" | "phrase"; value: string }[];
Expand All @@ -25,6 +27,15 @@ function getSessionSearchText(session: SessionInfo): string {
return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`;
}

function hasSessionName(session: SessionInfo): boolean {
return !!session.name?.trim();
}

function matchesNameFilter(session: SessionInfo, filter: NameFilter): boolean {
if (filter === "all") return true;
return hasSessionName(session);
}

export function parseSearchQuery(query: string): ParsedSearchQuery {
const trimmed = query.trim();
if (!trimmed) {
Expand Down Expand Up @@ -142,17 +153,25 @@ export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): M
return { matches: true, score: totalScore };
}

export function filterAndSortSessions(sessions: SessionInfo[], query: string, sortMode: SortMode): SessionInfo[] {
export function filterAndSortSessions(
sessions: SessionInfo[],
query: string,
sortMode: SortMode,
nameFilter: NameFilter = "all",
): SessionInfo[] {
// Apply name filter first.
const nameFiltered = nameFilter === "all" ? sessions : sessions.filter((s) => matchesNameFilter(s, nameFilter));

const trimmed = query.trim();
if (!trimmed) return sessions;
if (!trimmed) return nameFiltered;

const parsed = parseSearchQuery(query);
if (parsed.error) return [];

// Recent mode: filter only, keep incoming order.
if (sortMode === "recent") {
const filtered: SessionInfo[] = [];
for (const s of sessions) {
for (const s of nameFiltered) {
const res = matchSession(s, parsed);
if (res.matches) filtered.push(s);
}
Expand All @@ -161,7 +180,7 @@ export function filterAndSortSessions(sessions: SessionInfo[], query: string, so

// Relevance mode: sort by score, tie-break by modified desc.
const scored: { session: SessionInfo; score: number }[] = [];
for (const s of sessions) {
for (const s of nameFiltered) {
const res = matchSession(s, parsed);
if (!res.matches) continue;
scored.push({ session: s, score: res.score });
Expand Down
Loading