Skip to content

Latest commit

 

History

History

README.md

@nicknisi/pi-composer

Replaces pi's input editor with a configurable boxed input rendered inside pi's TUI. All native editor features — cursor movement, history, autocomplete, paste — work normally inside the box. It also implements paste-again-to-expand: when a collapsed [paste #N ...] marker is present in the editor, pasting the same content again expands it inline so you can see and edit the actual text.

Evolved from the earlier single-file box-editor.ts: rendering is now config-driven and the paste-expand behavior was merged in from the former standalone paste-expand.ts so the two features don't fight over setEditorComponent (last call wins).

What it adds

  • Custom editor component via ctx.ui.setEditorComponent (no tools, no keybindings, no custom entry types).
  • /composer command: reloads composer.json without restarting pi and previews the working animation for ~3 seconds.
  • Events hooked: session_start (installs the editor component, reads the session name), session_info_changed (tracks renames for the inlay), agent_start / agent_settled (drive the working-state animations), and session_shutdown (removes it and tears down focus tracking and animation timers). TUI mode only; all handlers no-op when ctx.mode !== "tui".
  • Paste-again-to-expand: overrides the editor's handlePaste to detect when an incoming paste matches an already-collapsed paste's stored content; if the marker [paste #N ...] is still in the buffer, the marker is replaced with the real content and the paste registry is renumbered to stay dense.
  • History recall puts the cursor at the end: pi-tui places the cursor at the start of a message recalled with Up, so typing prepends to your own sentence. Composer overrides navigateHistory to move the cursor to the end after a successful Up recall. Down, draft restoration, and boundary no-ops (Up at the oldest entry) are untouched.
  • Extension API over pi.events: other extensions can push their own text into the border inlay — see Extension API.

Features

  • Session-name inlay: when the session has a name, it is inlaid in the border (default right end of the top rule). Position, surround glyphs, max width, and which border are all configurable:

    sessionNamePosition "right", sessionNameFormat "─ {name} ─" (defaults):
    ╭──────────────── refactor auth ─╮
    
    sessionNamePosition "left", sessionNameFormat "[ {name} ]":
    ╭[ refactor auth ]───────────────╮
    
  • Rounded or square box: ╭╮│╰╯ (default, preserves the original look) or ┌┐│└┘

  • Configurable prefix glyph on the first body line (default ); continuation lines get a space so content aligns

  • Theme-aware colors: border and prefix accept any theme colour token or hex value

  • Boxed / unboxed: full box with side borders, or top/bottom horizontal rules only

  • Menu outside box: slash-menu lines render below the box, with configurable gap and indent

  • Scroll indicators: pi's stock ↑ N more / ↓ N more indicators are detected in the stock borders and re-embedded in the replacement borders

  • Responsive: below a minimum width (see caveats) the extension defers to pi's stock editor rendering

  • Focus indicator: border switches colour when the tmux pane holding this session has terminal focus (requires tmux focus-events on)

  • Spinner prefix: while pi is working, the prefix glyph animates as a spinner — pick from built-in presets or define your own frames (configurable speed and colour, including "rainbow")

  • Border glow: while pi is working, the border either pulses (breathes between the border colour and a glow colour) or shimmers (a highlight sweeps along the top/bottom rules)

Extension API

Other extensions can replace the border-inlay text over pi's shared event bus (pi.events), without importing anything from this package:

// Push a label (any non-empty string):
pi.events.emit('composer:set-label', { text: `⏱ ${elapsed}` });

// Clear it (falls back to the session name):
pi.events.emit('composer:set-label', {});

The pushed text takes precedence over the session name and goes through the same formatting pipeline — sessionNameFormat, sessionNameColor, position, border, and truncation all apply. The override is cleared on every session start so a stale label never leaks across sessions.

Because extension load order is not guaranteed, composer emits composer:label-request on every session_start; producers should respond to it (and to whatever changes their own state) with composer:set-label:

pi.events.on('composer:label-request', () => {
  pi.events.emit('composer:set-label', { text: currentLabel() });
});

Install

pi install /path/to/pi-extensions/packages/composer

Usage

Once installed, the editor component is installed automatically at session start. Editing, history, autocomplete, and paste behave as usual inside the box. Run /composer after editing the config to reload it in place and preview the spinner/glow without sending a prompt.

Paste-again-to-expand works automatically too: pi collapses large pastes (>10 lines or >1000 chars) into [paste #N +X lines] markers; paste the same content a second time while the marker is present and it expands inline. The comparison replicates pi-tui's paste cleanup (CSI-u Ctrl+letter decoding, CRLF→LF, tabs→4 spaces, non-printable stripping) and also tolerates a single leading space that pi prepends to path-like pastes.

Layout (boxed):

╭──────────────────────────╮
│ ❯ <content>               │
│   <content continued>     │
╰──────────────────────────╯
<autocomplete menu>

Configuration

Config is read at extension load from ~/.pi/agent/configs/composer.json; run /composer to reload it without restarting pi (it also previews the working animation). The path follows pi's agent dir, so it moves with PI_CODING_AGENT_DIR if you set it. A missing file means all defaults; invalid JSON is reported (at startup via a warning, on /composer via an error) and the previous/default config is kept. Copy composer.example.json from this package as a starting point.

{
  "boxedView": true,
  "boxPadX": 1,
  "menuGap": 0,
  "extraMenuIndent": 1,
  "borderColor": "border",
  "prefix": "",
  "prefixColor": "accent",
  "corners": "rounded",
  "focusIndicator": true,
  "focusedBorderColor": "accent",
  "sessionName": true,
  "sessionNameColor": "muted",
  "sessionNamePosition": "right",
  "sessionNameFormat": "─ {name} ─",
  "sessionNameMaxWidth": 0,
  "sessionNameBorder": "top",
  "spinner": true,
  "spinnerStyle": "dots",
  "spinnerColor": "accent",
  "glow": true,
  "glowStyle": "pulse",
  "glowColor": "accent",
  "glowPeriodMs": 2000
}
Option Type Default Description
boxedView boolean true true = full box with side borders. false = top/bottom horizontal rules only.
boxPadX number 1 Horizontal padding inside the box (and around the prefix).
menuGap number 0 Blank lines between the bottom border and the slash-menu.
extraMenuIndent number 1 Extra indent (spaces) for slash-menu lines.
borderColor string "border" Theme colour token or hex colour ("#ff6600") for the box border.
prefix string "❯" Prefix glyph shown on the first body line.
prefixColor string "accent" Theme colour token or hex colour for the prefix.
corners "rounded" | "square" "rounded" rounded = ╭╮│╰╯, square = ┌┐│└┘. Any other value falls back to rounded.
focusIndicator boolean true Track terminal focus (DECSET 1004) and restyle the border when this pane is focused. Requires focus-events on in tmux.
focusedBorderColor string "accent" Border colour while the pane is focused; borderColor is used when unfocused.
sessionName boolean true Inlay the session name in the border when the session has one (set via /name, --name, or the session-name extension).
sessionNameColor string "muted" Theme colour token or hex colour for the session name inlay.
sessionNamePosition "left" | "right" "right" Which end of the border the name sits at. left shares that end with the scroll indicator when the input overflows.
sessionNameFormat string "─ {name} ─" Surround template; must contain {name}. The surrounding glyphs render in the border colour, the name in sessionNameColor.
sessionNameMaxWidth number 0 Cell cap for the name before truncating with ; 0 = fit within the rule, keeping 8 cells of plain border.
sessionNameBorder "top" | "bottom" "top" Which border (top or bottom rule) carries the name.
spinner boolean true Animate the prefix as a spinner while pi is working (between agent_start and agent_settled).
spinnerStyle string "dots" Built-in spinner preset — see Spinner presets. Unknown names fall back to dots.
spinnerFrames string[] Custom spinner frames; overrides spinnerStyle. Any cell width — the prefix slot is sized to the widest frame.
spinnerIntervalMs number per preset Milliseconds per spinner frame. Defaults to the active preset's tuned interval.
spinnerColor string "accent" Theme colour token, hex colour, or "rainbow" (hue rotates while spinning).
glow boolean true Animate the border while pi is working.
glowStyle "pulse" | "shimmer" "pulse" pulse breathes the whole border toward glowColor; shimmer sweeps a highlight along the top/bottom rules.
glowColor string "accent" Theme colour token, hex colour, or "rainbow" the glow animates toward.
glowPeriodMs number 2000 Milliseconds per glow cycle (one full pulse breath or one shimmer sweep).
rainbowPeriodMs number 1200 Milliseconds per full hue rotation when a colour is set to "rainbow".

Spinner presets

Each preset has a tuned default interval; set spinnerIntervalMs to override.

Preset Frames Interval Notes
dots ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ 80ms Classic braille dots (default).
disc ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷ 90ms Dense braille disc with an orbiting gap.
moon 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘 90ms Moon phases. Emoji — widens the prefix slot to 2 cells.
star ✶ ✸ ✹ ✺ ✹ ✸ 90ms Twinkling star — flares and dims.
orbit ◜ ◠ ◝ ◞ ◡ ◟ 80ms A dot orbiting a circle.
corners ▖ ▘ ▝ ▗ 120ms A quarter-block bouncing around the cell's corners.
triangle ◢ ◣ ◤ ◥ 120ms A spinning filled triangle.
scanner ●····· … ·····● … (bounce) 60ms KITT scanner — comet with a fading tail. 6 cells wide, so the idle prefix slot is 6 cells too.
mini-scanner ●·· ·●· ··● ·●· 120ms Scanner's little sibling — 3 cells, no tail.

Define your own with spinnerFrames (any array of non-empty strings; it takes precedence over spinnerStyle):

{ "spinnerFrames": ["", "", "", ""], "spinnerIntervalMs": 100 }

The prefix slot is sized to the widest frame of the active spinner (idle included), so wide frames permanently indent the input — the layout never shifts when the agent starts or stops.

Colour tokens

Any valid theme colour token works. See your active theme in ~/.pi/agent/themes/ or via /settings → Theme for available tokens (border, accent, text, muted, success, error, customMessageLabel, …). Hex values must be 6-digit #rrggbb; invalid values fall back to the uncoloured text. Hex takes precedence over theme tokens in applyColor.

spinnerColor and glowColor additionally accept "rainbow": a truecolor hue rotation completing one full spectrum lap per rainbowPeriodMs. A rainbow glowColor makes the pulse breathe toward a continuously rotating hue (and the shimmer highlight cycle colours).

The pulse anchors on borderColor (not the focus-adjusted border), so the default borderaccent pulse is visible even while the pane is focused and the resting border is already accent. If you set glowColor equal to borderColor, the pulse has nothing to breathe toward and is invisible — pick two colours that differ.

No environment variables are used.

Dependencies

Peer dependencies (*):

  • @earendil-works/pi-coding-agentExtensionAPI, ExtensionContext, CustomEditor, KeybindingsManager, Theme, ThemeColor types; the session_start / session_shutdown events; ctx.ui.setEditorComponent.
  • @earendil-works/pi-tuiTUI and EditorTheme types, isViewportTUI for focus-reporting ownership, visibleWidth for width arithmetic over ANSI-styled strings, and tui.requestRender for focus updates.

No npm runtime dependencies; node:child_process / node:fs / node:os / node:path only.

Caveats

  • pi-tui internals: the paste-expand and history-cursor features reach into Editor privates at runtime (state, pastes, pasteCounter, historyIndex, pushUndoSnapshot, cancelAutocomplete, exitHistoryBrowsing, setCursorCol, moveToLineEnd) and override the TS-private handlePaste and navigateHistory (compile-time private, runtime-accessible). It also hard-codes pi's paste-marker format ([paste #N +X lines] / [paste #N X chars]) and replicates pi-tui's paste cleanup and registry renumbering. Any change to pi-tui's paste handling or marker format can break this.
  • Stock-render parsing: the boxed renderer calls super.render() and then re-wraps its output, detecting pi's solid borders and ↑/↓ N more scroll indicators by string matching. If pi-tui changes how the stock editor renders borders or scroll indicators, the box layout will misdetect sections.
  • Narrow terminals: if width < 5 + BOX_PAD_X * padMultiplier (padMultiplier is 3 boxed, 1 unboxed) or the stock render produces fewer than 2 lines, the component falls back to pi's stock rendering.
  • Focus tracking (DECSET 1004): fullscreen pi owns focus reporting; composer reasserts it during setup but only disables it in other TUI modes. Composer observes raw stdin because fullscreen pi consumes CSI I / CSI O in its viewport listener before extension input listeners run. When composer owns reporting, shutdown and process-exit hooks disable it so the shell does not inherit [I/[O. A signal that bypasses the exit hook can still leave focus reporting enabled.
  • tmux: the focus indicator only changes state if tmux has focus-events on (and the outer terminal passes focus events through). Outside tmux it works only if the terminal itself emits CSI I / CSI O.
  • Config validation is shallow: bad JSON is reported and numeric/enum fields are range-checked, but wildly wrong types for unchecked string fields (e.g. a number for prefix) may throw at render time.
  • Reload doesn't rebuild the editor: /composer mutates the live config that render code reads, which covers every documented key. A key that only takes effect at component construction would need a restart (none currently do).
  • setEditorComponent conflicts: any other extension calling setEditorComponent after this one will replace the editor (last call wins).
  • Pulse needs truecolor: the pulse glow interpolates RGB between borderColor and glowColor. Both endpoints must resolve to RGB — hex values always do; theme tokens only if the theme emits truecolor (38;2;r;g;b) sequences. If either endpoint can't be resolved, the border falls back to a steady glowColor while working. Shimmer has no such requirement.
  • Animation cost: while pi is working, a single timer requests a TUI re-render every spinnerIntervalMs (or 80ms for glow-only). Idle sessions have no timer running.