pi-sticky-prompt 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/assets/demo.mp4 +0 -0
- package/assets/sticky-bar-collapsed.png +0 -0
- package/assets/sticky-bar-expanded.png +0 -0
- package/assets/sticky-bar-session-picker.png +0 -0
- package/extensions/sticky-prompt.ts +252 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-sticky-prompt contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# pi-sticky-prompt
|
|
2
|
+
|
|
3
|
+
> Always-on-top, full-width macOS prompt bar for [pi](https://github.com/earendil-works/pi).
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Pi runs in normal terminal scrollback (not alternate-screen mode), so when
|
|
8
|
+
you scroll the terminal up to read history, the input prompt scrolls out
|
|
9
|
+
of view with everything else. **pi-sticky-prompt** solves that with a
|
|
10
|
+
tiny native macOS window that sits permanently on top of every other
|
|
11
|
+
window, on every space, and talks to your live pi sessions over a Unix
|
|
12
|
+
domain socket.
|
|
13
|
+
|
|
14
|
+
You can scroll the terminal as much as you want — the prompt stays glued
|
|
15
|
+
to the bottom of the screen.
|
|
16
|
+
|
|
17
|
+
### Demo
|
|
18
|
+
|
|
19
|
+
https://github.com/user-attachments/assets/4b8a7e41-6df2-4bf2-98d3-4cd1513aefd9
|
|
20
|
+
|
|
21
|
+
| Collapsed | Session picker |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
|  |  |
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
┌──────────────────────────────────┐ ┌──────────────────────────┐
|
|
27
|
+
│ Terminal running pi │ UDS │ PiStickyPrompt.app │
|
|
28
|
+
│ (interactive, scrollback intact) │ ◄────── │ floating NSPanel │
|
|
29
|
+
│ │ │ always on top │
|
|
30
|
+
└──────────────────────────────────┘ └──────────────────────────┘
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- 🪟 **Native floating window** — `NSPanel` with `.floating` level + `.canJoinAllSpaces`. Visible above every app, every space, even fullscreen Ghostty / Terminal / iTerm.
|
|
36
|
+
- 🖥️ **Auto-docked** to the bottom edge of whichever screen has a terminal app open. Plug in a monitor or move Terminal across screens — the bar follows.
|
|
37
|
+
- 🔒 **Lock / unlock** — locked: full-width pinned to bottom; unlocked: free-move, resize, drag between monitors.
|
|
38
|
+
- 📜 **Multi-session aware** — every pi process publishes its own socket; the bar lists all live sessions in a picker (`⌘L`) and remembers your selection across launches.
|
|
39
|
+
- ⌨️ **Global hotkey** `⌘⌥P` to toggle visibility from anywhere on the system.
|
|
40
|
+
- 📉 **Collapse to a one-line preview** of long input (`⌘M`); expanding grows upward leaving the toolbar flush with the screen edge.
|
|
41
|
+
- 🚦 **Status echo** — model, session name, and a live streaming indicator (green = idle, yellow = streaming, red = disconnected).
|
|
42
|
+
- ↩️ **Auto-focus the terminal** after sending — keystrokes you make right after pressing Enter land in the terminal hosting that pi session, not in the bar.
|
|
43
|
+
- 🛑 **Abort current pi turn** with `Esc`; press `Esc` twice quickly to hide the bar.
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- macOS 13+
|
|
48
|
+
- A pi installation (`@mariozechner/pi-coding-agent`)
|
|
49
|
+
- Xcode command-line tools for building the HUD: `xcode-select --install`
|
|
50
|
+
- Swift 5.9+ (ships with current macOS)
|
|
51
|
+
|
|
52
|
+
Tested with Ghostty, Terminal.app, iTerm2, Alacritty, WezTerm, kitty, and Warp.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
pi-sticky-prompt has two halves and each lives in the registry that
|
|
57
|
+
fits it best:
|
|
58
|
+
|
|
59
|
+
| Half | What it does | Where it lives |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| **Extension** | Lets pi sessions expose themselves over a Unix domain socket so the HUD can find them | npm — `npm:pi-sticky-prompt` |
|
|
62
|
+
| **HUD app** | The native macOS floating window itself | Homebrew cask — `pi-sticky-prompt` |
|
|
63
|
+
|
|
64
|
+
### 1. Install the pi extension
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pi install npm:pi-sticky-prompt
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Reload pi (`/reload`) or start a fresh session. Each session will now
|
|
71
|
+
publish a socket + descriptor under `~/.pi/agent/sockets/`.
|
|
72
|
+
|
|
73
|
+
### 2. Install the macOS HUD
|
|
74
|
+
|
|
75
|
+
**Recommended — Homebrew:**
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
brew tap alonmartin2222/pi
|
|
79
|
+
brew install --cask pi-sticky-prompt
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
First launch may show a Gatekeeper prompt because the app is ad-hoc
|
|
83
|
+
signed — right-click `PiStickyPrompt.app` in `/Applications` and pick
|
|
84
|
+
*Open* once to whitelist it.
|
|
85
|
+
|
|
86
|
+
**Alternative — download the prebuilt zip from a GitHub release:**
|
|
87
|
+
|
|
88
|
+
1. Grab `PiStickyPrompt.app.zip` from <https://github.com/alonmartin2222/pi-sticky-prompt/releases/latest>
|
|
89
|
+
2. Unzip and drag `PiStickyPrompt.app` into `/Applications`
|
|
90
|
+
|
|
91
|
+
**Alternative — build from source:**
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git clone https://github.com/alonmartin2222/pi-sticky-prompt.git
|
|
95
|
+
cd pi-sticky-prompt
|
|
96
|
+
make install # builds and copies the .app to ~/Applications
|
|
97
|
+
make run # builds + opens it
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 3. Launch
|
|
101
|
+
|
|
102
|
+
Open `PiStickyPrompt.app` (Spotlight / Launchpad / `open -a PiStickyPrompt`).
|
|
103
|
+
Add it to your Login Items in System Settings if you want it always
|
|
104
|
+
running. Press **⌘⌥P** to toggle the bar.
|
|
105
|
+
|
|
106
|
+
## Usage
|
|
107
|
+
|
|
108
|
+
1. Start any number of `pi` sessions in any terminal. Each session writes:
|
|
109
|
+
- **socket**: `~/.pi/agent/sockets/pi-<pid>.sock`
|
|
110
|
+
- **descriptor**: `~/.pi/agent/sockets/pi-<pid>.json`
|
|
111
|
+
2. Launch `PiStickyPrompt.app`. It scans the descriptor directory and
|
|
112
|
+
auto-attaches to the most-recent live session (or the one you previously
|
|
113
|
+
chose).
|
|
114
|
+
3. Type. **Enter** sends; **Shift+Enter** inserts a newline.
|
|
115
|
+
|
|
116
|
+
### Keys
|
|
117
|
+
|
|
118
|
+
While the bar has keyboard focus:
|
|
119
|
+
|
|
120
|
+
| Key | Action |
|
|
121
|
+
| ------------------ | --------------------------------------------------- |
|
|
122
|
+
| **⌘⌥P** *(global)* | Toggle bar visibility from anywhere on the system |
|
|
123
|
+
| **Enter** | Send the prompt to the attached pi session |
|
|
124
|
+
| **Shift+Enter** | Insert a newline inside the editor |
|
|
125
|
+
| **Esc** | Abort current pi turn (twice quickly: hide bar) |
|
|
126
|
+
| **⌘M** | Collapse to one-line preview / expand back |
|
|
127
|
+
| **⌘L** | Open the session picker |
|
|
128
|
+
| **⌘W** | Hide the bar |
|
|
129
|
+
|
|
130
|
+
A status-bar icon (`π▸`) also exposes Toggle / Pick Session / Quit.
|
|
131
|
+
|
|
132
|
+
### Toolbar
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
[● session-name │ model/name] [≡ 🔒 ▲]
|
|
136
|
+
│ │ │ │ │ │
|
|
137
|
+
│ │ │ │ │ └ collapse / expand
|
|
138
|
+
│ │ │ │ └ lock / unlock
|
|
139
|
+
│ │ │ └ session picker
|
|
140
|
+
│ │ └ provider/model
|
|
141
|
+
│ └ session name (or cwd basename if unnamed)
|
|
142
|
+
└ status dot: green = idle · yellow = streaming · red = disconnected
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
When idle, sending a prompt triggers a new turn. When pi is mid-turn (yellow
|
|
146
|
+
dot), sending **steers** the running turn instead of queueing — same
|
|
147
|
+
behaviour as typing in the pi TUI itself.
|
|
148
|
+
|
|
149
|
+
### Lock vs unlock
|
|
150
|
+
|
|
151
|
+
- **🔒 locked** *(default)* — full screen-width, pinned to the bottom of
|
|
152
|
+
whichever screen has a terminal app open. Re-snaps automatically on
|
|
153
|
+
display changes (`NSApplication.didChangeScreenParametersNotification`).
|
|
154
|
+
- **🔓 unlocked** *(orange tint)* — drag from any background pixel to
|
|
155
|
+
move, drag from the edges to resize, drag freely between monitors. The
|
|
156
|
+
last unlocked frame is remembered. Click again to re-dock.
|
|
157
|
+
|
|
158
|
+
## Architecture
|
|
159
|
+
|
|
160
|
+
Two pieces:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
extensions/sticky-prompt.ts ← TypeScript pi extension
|
|
164
|
+
PiStickyPrompt/ ← Swift Package for the macOS HUD
|
|
165
|
+
├── Package.swift
|
|
166
|
+
├── Sources/PiStickyPrompt/
|
|
167
|
+
│ ├── main.swift ← entry point, sets accessory activation
|
|
168
|
+
│ ├── AppDelegate.swift ← menu-bar item + global hotkey wiring
|
|
169
|
+
│ ├── HUDController.swift ← owns the panel, picks a session, locking
|
|
170
|
+
│ ├── HUDPanel.swift ← NSPanel subclass; canBecomeKey overrides
|
|
171
|
+
│ ├── PromptView.swift ← top toolbar + editor + status row
|
|
172
|
+
│ ├── PromptTextView.swift ← NSTextView with Enter/Esc/⌘M handling
|
|
173
|
+
│ ├── BridgeClient.swift ← UDS client; line-delimited JSON protocol
|
|
174
|
+
│ ├── SessionDiscovery.swift ← scans ~/.pi/agent/sockets for live pids
|
|
175
|
+
│ ├── TerminalScreen.swift ← finds which NSScreen hosts a terminal
|
|
176
|
+
│ ├── TerminalLocator.swift ← walks parent PIDs to find owning terminal
|
|
177
|
+
│ └── Hotkey.swift ← Carbon RegisterEventHotKey wrapper
|
|
178
|
+
└── make-app.sh ← bundles the binary into a .app
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Wire protocol
|
|
182
|
+
|
|
183
|
+
Line-delimited JSON over the Unix domain socket (LF only, both directions):
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
server -> client
|
|
187
|
+
{"type":"hello", pid, cwd, sessionFile, sessionName?, model?, streaming, started}
|
|
188
|
+
{"type":"state", streaming, model?, sessionName?}
|
|
189
|
+
{"type":"ack", ok, command:"prompt"|"abort", error?}
|
|
190
|
+
{"type":"bye"}
|
|
191
|
+
|
|
192
|
+
client -> server
|
|
193
|
+
{"type":"prompt", text}
|
|
194
|
+
{"type":"abort"}
|
|
195
|
+
{"type":"ping"}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
You can drive the bridge from the shell to verify it without the HUD:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
SOCK=$(ls -t ~/.pi/agent/sockets/pi-*.sock | head -1)
|
|
202
|
+
echo '{"type":"prompt","text":"hello from nc"}' | nc -U "$SOCK"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Permissions
|
|
206
|
+
|
|
207
|
+
- **No Accessibility permission** required. We never use AX APIs.
|
|
208
|
+
- **No Screen Recording permission** required. `CGWindowListCopyWindowInfo`
|
|
209
|
+
returns window owner + bounds without it; we read only those, never
|
|
210
|
+
pixels or window names.
|
|
211
|
+
- The global hotkey uses Carbon's `RegisterEventHotKey`, which works for
|
|
212
|
+
accessory (LSUIElement) apps without any permission prompts.
|
|
213
|
+
|
|
214
|
+
### Auto-focus to terminal on send
|
|
215
|
+
|
|
216
|
+
After a successful `prompt` ack, the HUD walks the BSD process tree
|
|
217
|
+
upward from the pi session's PID using `sysctl(KERN_PROC_PID)` until it
|
|
218
|
+
finds an ancestor whose bundle ID matches a known terminal app (Ghostty,
|
|
219
|
+
Terminal, iTerm2, Alacritty, WezTerm, kitty, Warp, Hyper). It then calls
|
|
220
|
+
`NSRunningApplication.activate(.activateIgnoringOtherApps)` on that app.
|
|
221
|
+
This brings the terminal to the front so your next keystroke goes to pi
|
|
222
|
+
output instead of the now-empty input bar.
|
|
223
|
+
|
|
224
|
+
## Multiple pi sessions
|
|
225
|
+
|
|
226
|
+
The pi extension publishes one socket + descriptor per pi process. The
|
|
227
|
+
HUD scans the directory, hides any whose PID is no longer running, and
|
|
228
|
+
shows the rest in the session picker (`⌘L`). The current selection is
|
|
229
|
+
persisted in `UserDefaults` as `pi.preferredPID` so re-launches reattach
|
|
230
|
+
to the same session if it's still alive.
|
|
231
|
+
|
|
232
|
+
Heads-up: macOS doesn't expose per-window activation through
|
|
233
|
+
`NSRunningApplication`. If you have multiple windows of the same terminal
|
|
234
|
+
app, only the most-recently-focused one of that app comes forward. Per-
|
|
235
|
+
window raising would require Accessibility permission, which this project
|
|
236
|
+
deliberately avoids.
|
|
237
|
+
|
|
238
|
+
## Disabling
|
|
239
|
+
|
|
240
|
+
- Hide the bar with `⌘⌥P` or quit it from the menu bar.
|
|
241
|
+
- To remove the extension half: `pi remove npm:pi-sticky-prompt` (or
|
|
242
|
+
whichever spec you used to install it).
|
|
243
|
+
- To keep the extension but stop the HUD: just don't launch the app. The
|
|
244
|
+
socket sits unused; pi sessions don't notice.
|
|
245
|
+
|
|
246
|
+
## Limitations
|
|
247
|
+
|
|
248
|
+
- The bar is **viewport-pinned** because it is a separate macOS window,
|
|
249
|
+
not because pi affects terminal scrollback. Terminal scrollback itself
|
|
250
|
+
is unchanged.
|
|
251
|
+
- One HUD process per machine is the intended deployment. Multiple HUDs
|
|
252
|
+
can connect to the same socket but they will all see each other's
|
|
253
|
+
state echoes.
|
|
254
|
+
- macOS only. The HUD uses AppKit. The pi extension itself is
|
|
255
|
+
cross-platform Node code, but the only client implementation today is
|
|
256
|
+
the macOS app.
|
|
257
|
+
- Tested only on macOS 13+ on Apple Silicon. Intel builds should work
|
|
258
|
+
(the binary is built for `arm64` only by default — drop in a
|
|
259
|
+
universal slice in `make-app.sh` if needed).
|
|
260
|
+
|
|
261
|
+
## Contributing
|
|
262
|
+
|
|
263
|
+
Issues and PRs welcome at <https://github.com/alonmartin2222/pi-sticky-prompt>.
|
|
264
|
+
The codebase is intentionally small:
|
|
265
|
+
|
|
266
|
+
- `extensions/sticky-prompt.ts` — ~250 lines TypeScript
|
|
267
|
+
- `PiStickyPrompt/Sources/PiStickyPrompt/*.swift` — ~900 lines Swift
|
|
268
|
+
|
|
269
|
+
Build the Swift app with `make debug` to get faster rebuild loops while
|
|
270
|
+
iterating. Use `swift build -c debug` directly if you don't need the
|
|
271
|
+
`.app` bundle.
|
|
272
|
+
|
|
273
|
+
The Carbon `RegisterEventHotKey` symbol signature is `'piPb'` (`0x70695062`)
|
|
274
|
+
— historical, but kept stable so config files stay portable.
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT — see [LICENSE](./LICENSE).
|
package/assets/demo.mp4
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-sticky-prompt
|
|
3
|
+
*
|
|
4
|
+
* Exposes the active pi session over a Unix domain socket so an external,
|
|
5
|
+
* always-on-top input HUD (PiStickyPrompt.app) can send prompts and receive
|
|
6
|
+
* basic state. Each pi process gets its own socket; a sibling JSON
|
|
7
|
+
* descriptor file is published so the HUD can list and pick a session.
|
|
8
|
+
*
|
|
9
|
+
* Layout:
|
|
10
|
+
* ~/.pi/agent/sockets/pi-<pid>.sock — server socket
|
|
11
|
+
* ~/.pi/agent/sockets/pi-<pid>.json — descriptor (pid, cwd, started, ...)
|
|
12
|
+
*
|
|
13
|
+
* Wire protocol (line-delimited JSON, both directions, LF only):
|
|
14
|
+
*
|
|
15
|
+
* server -> client
|
|
16
|
+
* {"type":"hello", pid, cwd, sessionFile, sessionName?, model?, streaming, started}
|
|
17
|
+
* {"type":"state", streaming, model?, sessionName?}
|
|
18
|
+
* {"type":"ack", ok:bool, command:"prompt"|"abort", error?:string}
|
|
19
|
+
* {"type":"bye"}
|
|
20
|
+
*
|
|
21
|
+
* client -> server
|
|
22
|
+
* {"type":"prompt", text:string}
|
|
23
|
+
* {"type":"abort"}
|
|
24
|
+
* {"type":"ping"}
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
28
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
29
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
const SOCK_DIR = join(homedir(), ".pi", "agent", "sockets");
|
|
34
|
+
const SOCK_PATH = join(SOCK_DIR, `pi-${process.pid}.sock`);
|
|
35
|
+
const DESC_PATH = join(SOCK_DIR, `pi-${process.pid}.json`);
|
|
36
|
+
|
|
37
|
+
interface ClientLine {
|
|
38
|
+
type?: string;
|
|
39
|
+
text?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ExtState {
|
|
43
|
+
streaming: boolean;
|
|
44
|
+
model?: string;
|
|
45
|
+
sessionName?: string;
|
|
46
|
+
sessionFile?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function (pi: ExtensionAPI) {
|
|
50
|
+
let server: Server | null = null;
|
|
51
|
+
const clients = new Set<Socket>();
|
|
52
|
+
const state: ExtState = { streaming: false };
|
|
53
|
+
|
|
54
|
+
const send = (sock: Socket, msg: unknown) => {
|
|
55
|
+
try {
|
|
56
|
+
sock.write(JSON.stringify(msg) + "\n");
|
|
57
|
+
} catch {
|
|
58
|
+
/* client gone */
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const broadcast = (msg: unknown) => {
|
|
62
|
+
for (const c of clients) send(c, msg);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const writeDescriptor = async () => {
|
|
66
|
+
const desc = {
|
|
67
|
+
pid: process.pid,
|
|
68
|
+
cwd: process.cwd(),
|
|
69
|
+
socket: SOCK_PATH,
|
|
70
|
+
started: Date.now(),
|
|
71
|
+
sessionFile: state.sessionFile,
|
|
72
|
+
sessionName: state.sessionName,
|
|
73
|
+
model: state.model,
|
|
74
|
+
streaming: state.streaming,
|
|
75
|
+
};
|
|
76
|
+
await writeFile(DESC_PATH, JSON.stringify(desc, null, 2));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleLine = async (sock: Socket, line: string, ctx: any) => {
|
|
80
|
+
let msg: ClientLine;
|
|
81
|
+
try {
|
|
82
|
+
msg = JSON.parse(line);
|
|
83
|
+
} catch {
|
|
84
|
+
send(sock, { type: "ack", ok: false, command: "?", error: "bad json" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (msg.type === "ping") {
|
|
89
|
+
send(sock, { type: "pong" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (msg.type === "prompt") {
|
|
94
|
+
const text = (msg.text ?? "").trim();
|
|
95
|
+
if (!text) {
|
|
96
|
+
send(sock, { type: "ack", ok: false, command: "prompt", error: "empty" });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (state.streaming) {
|
|
101
|
+
pi.sendUserMessage(text, { deliverAs: "steer" });
|
|
102
|
+
} else {
|
|
103
|
+
pi.sendUserMessage(text);
|
|
104
|
+
}
|
|
105
|
+
send(sock, { type: "ack", ok: true, command: "prompt" });
|
|
106
|
+
} catch (e: any) {
|
|
107
|
+
send(sock, { type: "ack", ok: false, command: "prompt", error: String(e?.message ?? e) });
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (msg.type === "abort") {
|
|
113
|
+
try {
|
|
114
|
+
if (typeof ctx?.abort === "function") {
|
|
115
|
+
ctx.abort();
|
|
116
|
+
}
|
|
117
|
+
send(sock, { type: "ack", ok: true, command: "abort" });
|
|
118
|
+
} catch (e: any) {
|
|
119
|
+
send(sock, { type: "ack", ok: false, command: "abort", error: String(e?.message ?? e) });
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
send(sock, { type: "ack", ok: false, command: msg.type ?? "?", error: "unknown command" });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onConnection = (sock: Socket, ctx: any) => {
|
|
128
|
+
clients.add(sock);
|
|
129
|
+
sock.setEncoding("utf8");
|
|
130
|
+
|
|
131
|
+
send(sock, {
|
|
132
|
+
type: "hello",
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
cwd: process.cwd(),
|
|
135
|
+
sessionFile: state.sessionFile,
|
|
136
|
+
sessionName: state.sessionName,
|
|
137
|
+
model: state.model,
|
|
138
|
+
streaming: state.streaming,
|
|
139
|
+
started: Date.now(),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
let buf = "";
|
|
143
|
+
sock.on("data", (chunk) => {
|
|
144
|
+
buf += chunk.toString();
|
|
145
|
+
let idx: number;
|
|
146
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
147
|
+
const line = buf.slice(0, idx).replace(/\r$/, "");
|
|
148
|
+
buf = buf.slice(idx + 1);
|
|
149
|
+
if (line.length > 0) void handleLine(sock, line, ctx);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
const cleanup = () => {
|
|
153
|
+
clients.delete(sock);
|
|
154
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
155
|
+
};
|
|
156
|
+
sock.on("error", cleanup);
|
|
157
|
+
sock.on("close", cleanup);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const startServer = async (ctx: any) => {
|
|
161
|
+
await mkdir(SOCK_DIR, { recursive: true });
|
|
162
|
+
// Stale socket from a previous crashed pi with same pid (very unlikely)
|
|
163
|
+
await rm(SOCK_PATH, { force: true });
|
|
164
|
+
|
|
165
|
+
server = createServer((sock) => onConnection(sock, ctx));
|
|
166
|
+
await new Promise<void>((resolve, reject) => {
|
|
167
|
+
server!.once("error", reject);
|
|
168
|
+
server!.listen(SOCK_PATH, () => {
|
|
169
|
+
server!.removeListener("error", reject);
|
|
170
|
+
resolve();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
await writeDescriptor();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const stopServer = async () => {
|
|
177
|
+
broadcast({ type: "bye" });
|
|
178
|
+
for (const c of clients) {
|
|
179
|
+
try { c.end(); } catch { /* ignore */ }
|
|
180
|
+
}
|
|
181
|
+
clients.clear();
|
|
182
|
+
if (server) {
|
|
183
|
+
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
184
|
+
server = null;
|
|
185
|
+
}
|
|
186
|
+
await rm(SOCK_PATH, { force: true });
|
|
187
|
+
await rm(DESC_PATH, { force: true });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const refreshState = (next: Partial<ExtState>) => {
|
|
191
|
+
Object.assign(state, next);
|
|
192
|
+
broadcast({
|
|
193
|
+
type: "state",
|
|
194
|
+
streaming: state.streaming,
|
|
195
|
+
model: state.model,
|
|
196
|
+
sessionName: state.sessionName,
|
|
197
|
+
});
|
|
198
|
+
// Best-effort descriptor refresh; non-fatal if it fails.
|
|
199
|
+
writeDescriptor().catch(() => undefined);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
203
|
+
state.sessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
|
204
|
+
state.sessionName = pi.getSessionName?.() ?? undefined;
|
|
205
|
+
state.model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
206
|
+
state.streaming = false;
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await startServer(ctx);
|
|
210
|
+
ctx.ui?.notify?.(`pi-sticky-prompt listening on ${SOCK_PATH}`, "info");
|
|
211
|
+
} catch (e: any) {
|
|
212
|
+
ctx.ui?.notify?.(`pi-sticky-prompt failed to start: ${e?.message ?? e}`, "error");
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
pi.on("session_shutdown", async () => {
|
|
217
|
+
await stopServer();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
pi.on("agent_start", async () => {
|
|
221
|
+
refreshState({ streaming: true });
|
|
222
|
+
});
|
|
223
|
+
pi.on("agent_end", async () => {
|
|
224
|
+
refreshState({ streaming: false });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
pi.on("model_select", async (event) => {
|
|
228
|
+
const m = event.model;
|
|
229
|
+
refreshState({ model: m ? `${m.provider}/${m.id}` : undefined });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Reflect /set-session-name etc. — there is no dedicated event, so we
|
|
233
|
+
// poll on each turn end (cheap, only runs once per LLM turn).
|
|
234
|
+
pi.on("turn_end", async () => {
|
|
235
|
+
const name = pi.getSessionName?.();
|
|
236
|
+
if (name !== state.sessionName) refreshState({ sessionName: name });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
pi.registerCommand("prompt-bar-status", {
|
|
240
|
+
description: "Show pi-sticky-prompt status",
|
|
241
|
+
handler: async (_args, ctx) => {
|
|
242
|
+
const lines = [
|
|
243
|
+
`socket : ${SOCK_PATH}`,
|
|
244
|
+
`desc : ${DESC_PATH}`,
|
|
245
|
+
`clients: ${clients.size}`,
|
|
246
|
+
`stream : ${state.streaming}`,
|
|
247
|
+
`model : ${state.model ?? "-"}`,
|
|
248
|
+
];
|
|
249
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-sticky-prompt",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Always-on-top, full-width macOS prompt bar for pi. A floating native window that survives terminal scrollback and lets you keep typing while you read scrollback history.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"macos",
|
|
9
|
+
"prompt-bar",
|
|
10
|
+
"hud",
|
|
11
|
+
"always-on-top",
|
|
12
|
+
"ghostty",
|
|
13
|
+
"terminal"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/alonmartin2222/pi-sticky-prompt.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/alonmartin2222/pi-sticky-prompt#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/alonmartin2222/pi-sticky-prompt/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": ["./extensions/sticky-prompt.ts"],
|
|
33
|
+
"video": "https://github.com/alonmartin2222/pi-sticky-prompt/releases/download/v0.1.1/demo.mp4",
|
|
34
|
+
"image": "https://raw.githubusercontent.com/alonmartin2222/pi-sticky-prompt/main/assets/sticky-bar-expanded.png"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"extensions/",
|
|
38
|
+
"assets/",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
]
|
|
42
|
+
}
|