@yusukeshib/pi-stash 0.1.0 → 0.3.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-stash
2
2
 
3
- A personal stash of reusable prompt fragments for the [Pi coding agent](https://pi.dev).
3
+ A per-session stash of reusable prompt fragments for the [Pi coding agent](https://pi.dev).
4
4
 
5
5
  Jot a prompt down whenever you think of it mid-task, then pop it into the editor
6
6
  when you actually need it. Think of it as a stack-like scratchpad for prompts —
@@ -50,7 +50,20 @@ pi install npm:@yusukeshib/pi-stash
50
50
  |---------|--------|
51
51
  | `/stash <text>` | **Push** — save the given text onto the stash. |
52
52
  | `/stash` | **Pop** — pick a saved entry, insert it into the editor, and remove it from the stash. Run repeatedly to stack several fragments together. |
53
- | `/stash-clear` | Delete every stashed entry (with confirm). |
53
+ | `/stash-clear` | Delete every stashed entry in this session (with confirm). |
54
+
55
+ ### Shortcut
56
+
57
+ | Key | Action |
58
+ |-----|--------|
59
+ | `Ctrl+S` | If the editor has text, **push** it onto the stash and clear the editor. If the editor is empty, **pop** a saved entry into it. |
60
+
61
+ `Ctrl+S` is the one-key way to do what `/stash` does: park the prompt you're
62
+ halfway through typing, or pull one back — without typing the command. Rebind it
63
+ in `~/.pi/agent/keybindings.json` if it clashes with another shortcut.
64
+
65
+ While the stash is non-empty, a red `stash:N` badge is shown in the footer so
66
+ you never forget you have prompts parked.
54
67
 
55
68
  ## How it works
56
69
 
@@ -65,21 +78,13 @@ build up by hand during real work:
65
78
 
66
79
  ## Storage
67
80
 
68
- Entries live in a single global JSON file, **shared across every Pi session on
69
- the machine** (not per-project, not per-session):
70
-
71
- ```
72
- ~/.pi/agent/prompt-stash.json
73
- ```
74
-
75
- Override the location with the `PI_STASH_PATH` environment variable:
76
-
77
- ```bash
78
- export PI_STASH_PATH="$HOME/.config/pi/stash.json"
79
- ```
81
+ Entries are stored **inside the session itself** as custom session entries —
82
+ each Pi session has its own independent stash. It survives restarts and
83
+ `/resume`, and follows branching (`/fork`, `/clone`) correctly: a forked
84
+ session sees the stash as it was at the fork point.
80
85
 
81
- > Note: the store is read-modify-written on each command. If two sessions edit
82
- > the stash at the exact same moment, the last write wins.
86
+ Nothing is written outside the session file, and the stash does not
87
+ participate in the LLM context.
83
88
 
84
89
  ## License
85
90
 
@@ -1,7 +1,7 @@
1
1
  /**
2
- * pi-stash — a personal stash of reusable prompt fragments for the Pi coding
3
- * agent. Jot a prompt down whenever you think of it, then later pull up the
4
- * list and pop one into the editor to compose your next message.
2
+ * pi-stash — a per-session stash of reusable prompt fragments for the Pi
3
+ * coding agent. Jot a prompt down whenever you think of it, then later pull
4
+ * up the list and pop one into the editor to compose your next message.
5
5
  *
6
6
  * Unlike file-based prompt templates (static `/name` commands), this is an
7
7
  * ad-hoc, mutable, stack-like backlog you build up by hand during real work.
@@ -12,43 +12,31 @@
12
12
  * remove it from the stash. Run repeatedly to stack fragments.
13
13
  * /stash-clear Delete every entry (with confirm).
14
14
  *
15
- * Storage: a single global JSON file, shared across every Pi session on the
16
- * machine (not per-project, not per-session). Default location:
17
- * ~/.pi/agent/prompt-stash.json
18
- * Override with the PI_STASH_PATH environment variable.
15
+ * Shortcut:
16
+ * Ctrl+S If the editor holds text, push it onto the stash (and clear
17
+ * the editor); otherwise pop a saved entry into the editor.
18
+ * A one-key way to park the prompt you're typing, or pull one
19
+ * back, without typing `/stash`.
20
+ *
21
+ * Storage: stash entries are persisted INSIDE the session itself via custom
22
+ * session entries (`pi.appendEntry`). Each session has its own stash, it
23
+ * survives restarts/resume, and it follows branching (fork/clone) correctly.
24
+ *
25
+ * While the stash is non-empty, a red badge with the entry count is shown in
26
+ * the footer so you don't forget about pending prompts.
19
27
  */
20
28
 
21
- import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
23
- import { homedir } from "node:os";
24
- import { dirname, join } from "node:path";
29
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
25
30
 
26
31
  interface StashEntry {
27
32
  text: string;
28
33
  addedAt: number;
29
34
  }
30
35
 
31
- const STORE_PATH = process.env.PI_STASH_PATH || join(homedir(), ".pi", "agent", "prompt-stash.json");
36
+ const CUSTOM_TYPE = "pi-stash-state";
37
+ const STATUS_KEY = "pi-stash";
32
38
  const PREVIEW_LEN = 72;
33
39
 
34
- function load(): StashEntry[] {
35
- try {
36
- const raw = readFileSync(STORE_PATH, "utf8");
37
- const parsed = JSON.parse(raw);
38
- if (Array.isArray(parsed)) {
39
- return parsed.filter((e): e is StashEntry => e && typeof e.text === "string");
40
- }
41
- } catch {
42
- // missing or corrupt → empty stash
43
- }
44
- return [];
45
- }
46
-
47
- function save(entries: StashEntry[]): void {
48
- mkdirSync(dirname(STORE_PATH), { recursive: true });
49
- writeFileSync(STORE_PATH, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
50
- }
51
-
52
40
  /** One-line, length-bounded preview for select menus. */
53
41
  function preview(text: string, index: number): string {
54
42
  const oneLine = text.replace(/\s+/g, " ").trim();
@@ -58,58 +46,115 @@ function preview(text: string, index: number): string {
58
46
  }
59
47
 
60
48
  export default function (pi: ExtensionAPI) {
61
- pi.registerCommand("stash", {
62
- description: "Push text (with arg) or pop an entry into the editor (no arg)",
63
- handler: async (args, ctx) => {
64
- const text = (args ?? "").trim();
65
-
66
- // PUSH: /stash <text>
67
- if (text) {
68
- const entries = load();
69
- if (entries.some((e) => e.text === text)) {
70
- ctx.ui.notify("Already in stash", "info");
71
- return;
49
+ // In-memory stash for the current session; persisted as custom entries.
50
+ let entries: StashEntry[] = [];
51
+
52
+ function persist(): void {
53
+ pi.appendEntry(CUSTOM_TYPE, { entries });
54
+ }
55
+
56
+ /** Red, hard-to-miss footer badge while the stash is non-empty. */
57
+ function updateStatus(ctx: ExtensionContext): void {
58
+ if (entries.length > 0) {
59
+ // White on red background (raw ANSI so it stays red in any theme).
60
+ ctx.ui.setStatus(STATUS_KEY, `\x1b[41m\x1b[97m\x1b[1m stash:${entries.length} \x1b[0m`);
61
+ } else {
62
+ ctx.ui.setStatus(STATUS_KEY, undefined);
63
+ }
64
+ }
65
+
66
+ // Restore stash from the session (last persisted state on the active path).
67
+ pi.on("session_start", async (_event, ctx) => {
68
+ entries = [];
69
+ for (const entry of ctx.sessionManager.getEntries()) {
70
+ if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) {
71
+ const data = entry.data as { entries?: unknown } | undefined;
72
+ if (data && Array.isArray(data.entries)) {
73
+ entries = data.entries.filter(
74
+ (e): e is StashEntry => !!e && typeof (e as StashEntry).text === "string",
75
+ );
72
76
  }
73
- entries.push({ text, addedAt: Date.now() });
74
- save(entries);
75
- ctx.ui.notify(`Stashed (${entries.length} total)`, "info");
76
- return;
77
77
  }
78
+ }
79
+ updateStatus(ctx);
80
+ });
78
81
 
79
- // POP: /stash → pick, insert into editor, remove from stash
80
- const entries = load();
81
- if (entries.length === 0) {
82
- ctx.ui.notify("Stash is empty. Add one with /stash <text>", "info");
82
+ /**
83
+ * Core stash behaviour shared by the `/stash` command and the Ctrl+S
84
+ * shortcut. With text → push; without text → pop into the editor.
85
+ */
86
+ async function runStash(rawText: string, ctx: ExtensionContext): Promise<void> {
87
+ const text = (rawText ?? "").trim();
88
+
89
+ // PUSH: /stash <text>
90
+ if (text) {
91
+ if (entries.some((e) => e.text === text)) {
92
+ ctx.ui.notify("Already in stash", "info");
83
93
  return;
84
94
  }
85
- const labels = entries.map((e, i) => preview(e.text, i));
86
- const choice = await ctx.ui.select("Pop prompt:", labels);
87
- if (choice === undefined) return; // cancelled / timed out
88
- const idx = labels.indexOf(choice);
89
- if (idx < 0) return;
90
-
91
- const chosen = entries[idx].text;
92
- const current = ctx.ui.getEditorText() ?? "";
93
- const next = current.trim().length > 0 ? `${current}\n${chosen}` : chosen;
94
- ctx.ui.setEditorText(next);
95
-
96
- // pop = remove the chosen entry
97
- entries.splice(idx, 1);
98
- save(entries);
95
+ entries.push({ text, addedAt: Date.now() });
96
+ persist();
97
+ updateStatus(ctx);
98
+ ctx.ui.notify(`Stashed (${entries.length} total)`, "info");
99
+ return;
100
+ }
101
+
102
+ // POP: /stash pick, insert into editor, remove from stash
103
+ if (entries.length === 0) {
104
+ ctx.ui.notify("Stash is empty. Add one with /stash <text>", "info");
105
+ return;
106
+ }
107
+ const labels = entries.map((e, i) => preview(e.text, i));
108
+ const choice = await ctx.ui.select("Pop prompt:", labels);
109
+ if (choice === undefined) return; // cancelled / timed out
110
+ const idx = labels.indexOf(choice);
111
+ if (idx < 0) return;
112
+
113
+ const chosen = entries[idx].text;
114
+ const current = ctx.ui.getEditorText() ?? "";
115
+ const next = current.trim().length > 0 ? `${current}\n${chosen}` : chosen;
116
+ ctx.ui.setEditorText(next);
117
+
118
+ // pop = remove the chosen entry
119
+ entries.splice(idx, 1);
120
+ persist();
121
+ updateStatus(ctx);
122
+ }
123
+
124
+ pi.registerCommand("stash", {
125
+ description: "Push text (with arg) or pop an entry into the editor (no arg)",
126
+ handler: async (args, ctx) => {
127
+ await runStash(args ?? "", ctx);
128
+ },
129
+ });
130
+
131
+ // Ctrl+S → same as bare `/stash`: pop an entry into the editor. If the
132
+ // editor already holds text, push it onto the stash instead.
133
+ pi.registerShortcut("ctrl+s", {
134
+ description: "Stash: push editor text, or pop a saved prompt",
135
+ handler: async (ctx) => {
136
+ const editorText = (ctx.ui.getEditorText() ?? "").trim();
137
+ if (editorText) {
138
+ ctx.ui.setEditorText("");
139
+ await runStash(editorText, ctx);
140
+ } else {
141
+ await runStash("", ctx);
142
+ }
99
143
  },
100
144
  });
101
145
 
102
146
  pi.registerCommand("stash-clear", {
103
- description: "Delete every stashed prompt",
147
+ description: "Delete every stashed prompt in this session",
104
148
  handler: async (_args, ctx) => {
105
- const entries = load();
106
149
  if (entries.length === 0) {
107
150
  ctx.ui.notify("Stash is already empty", "info");
108
151
  return;
109
152
  }
110
153
  const ok = await ctx.ui.confirm("Clear the entire stash?", `${entries.length} entries will be deleted`);
111
154
  if (!ok) return;
112
- save([]);
155
+ entries = [];
156
+ persist();
157
+ updateStatus(ctx);
113
158
  ctx.ui.notify("Stash cleared", "info");
114
159
  },
115
160
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-stash",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A personal stash of reusable prompt fragments for the Pi coding agent. Push prompts you think of mid-task, then pop them into the editor when you need them.",
5
5
  "type": "module",
6
6
  "license": "MIT",