@andares/pi-toolkits 0.9.4 → 0.9.5

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
@@ -106,7 +106,7 @@ Enter with `/ask` (press it again to exit). Meant for the case where you only wa
106
106
  - `/ask-perm allow allow allow ask` — positional form (`in-read in-write out-read out-write`), enables immediately without a dialog. Missing arguments fall back to the defaults. This is the form autostart uses.
107
107
  - `/ask-perm` again — turns it off.
108
108
 
109
- Repeating the argument form with the modes already in force is a **no-op**: no second "ON" notice and no loss of the session grants collected so far. Changing any mode notifies and clears those grants, because they were made under the old policy.
109
+ Repeating the argument form with the modes already in force is a **no-op**: no second "ON" notice and no loss of the session grants collected so far. Changing any mode records the change and clears those grants, because they were made under the old policy.
110
110
 
111
111
  **Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
112
112
 
@@ -133,8 +133,6 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
133
133
 
134
134
  **Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists.
135
135
 
136
- **Startup is quiet**: when the command comes from autostart, the chat notice is suppressed and only the footer status (`perm i:… o:…`) announces the mode. This is deliberate — the host keeps every info notice in the chat but renders all of them from one shared "last status" field, so an info notice emitted after another extension's warning (for example `/lens-toggle`) makes the earlier line display the new text too, i.e. the same message twice. A manual `/ask-perm` still prints the notice.
137
-
138
136
  **Enable it for every session** (the state itself is never persisted — this is the opt-in):
139
137
 
140
138
  ```toml
@@ -143,7 +141,7 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
143
141
  run = ["/ask-perm allow allow allow ask"]
144
142
  ```
145
143
 
146
- > **Implementation notes**: state is reset on `session_start` and cleared on `session_shutdown`; the footer shows `perm i:A/A o:A/ask` while on (`A` allow, `a` ask, `D` deny). Subagent sessions start with their own state and do not inherit the parent's mode — when child sessions must be gated too, enable it through the project's autostart file. POSIX hosts (Linux / macOS / WSL) are the tested target; native Windows Git-Bash path spellings are approximated, not verified. bash decomposition uses `shell-quote`; unknown variables expand to a sentinel that makes the unit ask instead of silently becoming an empty path. Added directories are read from pi-add-dir's session entry (`add-dir:state`). Heuristic, not a sandbox: project-internal destructive commands are covered by the fixed table, not by a sandbox. One known blind spot: a *read* command's bare filename operand (`cat notes.txt`) is not collected as a target, so with `out read = ask` only absolute-path-shaped read operands are questioned — reads never write, so the four modes' write side is unaffected.
144
+ > **Implementation notes**: a new session resets the state on `session_start` (a repeated emission for the same session keeps it); the footer shows `perm i:A/A o:A/ask` while on (`A` allow, `a` ask, `D` deny). Subagent sessions start with their own state and do not inherit the parent's mode — when child sessions must be gated too, enable it through the project's autostart file. POSIX hosts (Linux / macOS / WSL) are the tested target; native Windows Git-Bash path spellings are approximated, not verified. bash decomposition uses `shell-quote`; unknown variables expand to a sentinel that makes the unit ask instead of silently becoming an empty path. Added directories are read from pi-add-dir's session entry (`add-dir:state`). Heuristic, not a sandbox: project-internal destructive commands are covered by the fixed table, not by a sandbox. One known blind spot: a *read* command's bare filename operand (`cat notes.txt`) is not collected as a target, so with `out read = ask` only absolute-path-shaped read operands are questioned — reads never write, so the four modes' write side is unaffected.
147
145
 
148
146
  ---
149
147
 
package/README.zh-CN.md CHANGED
@@ -133,8 +133,6 @@ pi -e ./src/index.ts
133
133
 
134
134
  **与 `/ask` 的组合**:两者是同一总线上的独立闸门,各自只回答自己的问题 —— ask 回答「会不会产生变更」,permission 回答「有没有越过边界」,任一拦截都生效。所以 ask 模式下凭据仍然读不到,permission 在只读沙箱下也照常工作;两个功能互不需要知道对方存在。
135
135
 
136
- **启动时保持安静**:命令由 autostart 触发时不再往聊天区发提示,只由 footer 状态(`perm i:… o:…`)体现。这是有意的——宿主会把每条 info 提示留在聊天记录里,但所有这种行都渲染同一个「最新状态」字段,于是紧跟在别的扩展 warning(例如 `/lens-toggle`)之后的 info 提示会让前一行也显示新文案,也就是同一条消息出现两次。手动执行 `/ask-perm` 仍然会提示。
137
-
138
136
  **每个会话自动开启**(状态本身不落盘,这就是持久化的替身):
139
137
 
140
138
  ```toml
@@ -143,7 +141,7 @@ pi -e ./src/index.ts
143
141
  run = ["/ask-perm allow allow allow ask"]
144
142
  ```
145
143
 
146
- > **实现说明**:状态在 `session_start` 重置、`session_shutdown` 清空;开启时 footer 显示 `perm i:A/A o:A/ask`(`A` allow、`a` ask、`D` deny)。子代理会话有自己的状态、不继承父会话的模式;需要子会话也受控时,通过项目 autostart 再开启一次。实测目标是 POSIX 宿主(Linux/macOS/WSL);原生 Windows Git-Bash 的路径拼写只做了近似,未验证。bash 拆解使用 `shell-quote`;未知变量会展开成哨兵而不是空字符串,使该命令转为询问而不是被悄悄当成空路径。add-dir 目录从 pi-add-dir 的会话条目 `add-dir:state` 读取。启发式,不是沙箱:项目内的破坏性操作由固定表兜底,而不是靠沙箱。已知盲区一处:**读命令的裸文件名参数**(`cat notes.txt`)不被收集为目标,所以把 `out read` 调成 `ask` 时只有绝对路径形状的读目标会被问——读本身不会写,四格模式的写侧不受影响。
144
+ > **实现说明**:新会话在 `session_start` 重置状态(同一会话的重复 emission 保持状态);开启时 footer 显示 `perm i:A/A o:A/ask`(`A` allow、`a` ask、`D` deny)。子代理会话有自己的状态、不继承父会话的模式;需要子会话也受控时,通过项目 autostart 再开启一次。实测目标是 POSIX 宿主(Linux/macOS/WSL);原生 Windows Git-Bash 的路径拼写只做了近似,未验证。bash 拆解使用 `shell-quote`;未知变量会展开成哨兵而不是空字符串,使该命令转为询问而不是被悄悄当成空路径。add-dir 目录从 pi-add-dir 的会话条目 `add-dir:state` 读取。启发式,不是沙箱:项目内的破坏性操作由固定表兜底,而不是靠沙箱。已知盲区一处:**读命令的裸文件名参数**(`cat notes.txt`)不被收集为目标,所以把 `out read` 调成 `ask` 时只有绝对路径形状的读目标会被问——读本身不会写,四格模式的写侧不受影响。
147
145
 
148
146
  ---
149
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, and a dedicated compaction model",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@ import {
21
21
  RUNNABLE_REASONS,
22
22
  shouldReplay,
23
23
  } from "./autostart.js";
24
- import { consumeQuiet } from "../../lib/quiet.js";
24
+ import { consumeStartupCommand } from "../../lib/startup.js";
25
25
  import { registerAutostart } from "./index.js";
26
26
 
27
27
  describe("parseAutostartConfig", () => {
@@ -160,7 +160,7 @@ interface Harness {
160
160
  notifications: Array<{ message: string; type?: string }>;
161
161
  commands: Array<{ name: string; source: string }>;
162
162
  failCommands: Set<string>;
163
- quietAtDispatch: Array<{ command: string; quiet: boolean }>;
163
+ startupAtDispatch: Array<{ command: string; startup: boolean }>;
164
164
  setTrusted(trusted: boolean): void;
165
165
  /** Simulate a session that already had a conversation (resumed process). */
166
166
  setHistory(entries: Array<{ type: string }>): void;
@@ -205,7 +205,7 @@ async function makeHarness(
205
205
  { name: "ask", source: "extension" },
206
206
  { name: "ask-perm", source: "extension" },
207
207
  ];
208
- const quietAtDispatch: Harness["quietAtDispatch"] = [];
208
+ const startupAtDispatch: Harness["startupAtDispatch"] = [];
209
209
  const failCommands = new Set<string>();
210
210
  const pi = {
211
211
  on: (event: string, h: SessionStartHandler) => {
@@ -217,7 +217,10 @@ async function makeHarness(
217
217
  options?: { expandPromptTemplates?: boolean },
218
218
  ) => {
219
219
  sent.push({ command, options }); // records attempts, including failures
220
- quietAtDispatch.push({ command, quiet: consumeQuiet("ask-perm") });
220
+ startupAtDispatch.push({
221
+ command,
222
+ startup: consumeStartupCommand("ask-perm"),
223
+ });
221
224
  if (failCommands.has(command)) {
222
225
  return Promise.reject(new Error("boom"));
223
226
  }
@@ -227,7 +230,7 @@ async function makeHarness(
227
230
  return {
228
231
  handler,
229
232
  sent,
230
- quietAtDispatch,
233
+ startupAtDispatch,
231
234
  notifications,
232
235
  commands,
233
236
  failCommands,
@@ -321,19 +324,21 @@ describe("registerAutostart", () => {
321
324
  expect(h.notifications).toEqual([]);
322
325
  });
323
326
 
324
- it("marks our own command quiet for the dispatch and clears the mark", async () => {
327
+ it("marks our own command as a startup notice and clears the mark", async () => {
325
328
  const h = await harness('[command]\nrun = ["/ask-perm allow"]');
326
329
  await h.start("startup");
327
- expect(h.quietAtDispatch).toEqual([
328
- { command: "/ask-perm allow", quiet: true },
330
+ expect(h.startupAtDispatch).toEqual([
331
+ { command: "/ask-perm allow", startup: true },
329
332
  ]);
330
- expect(consumeQuiet("ask-perm")).toBe(false);
333
+ expect(consumeStartupCommand("ask-perm")).toBe(false);
331
334
  });
332
335
 
333
336
  it("leaves other extensions' commands unmarked", async () => {
334
337
  const h = await harness(fullConfig);
335
338
  await h.start("startup");
336
- expect(h.quietAtDispatch.every((entry) => entry.quiet === false)).toBe(true);
339
+ expect(h.startupAtDispatch.every((entry) => entry.startup === false)).toBe(
340
+ true,
341
+ );
337
342
  });
338
343
 
339
344
  it("replays once per session even if session_start is emitted twice", async () => {
@@ -40,7 +40,7 @@ import { readFile } from "node:fs/promises";
40
40
  import { join } from "node:path";
41
41
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
42
42
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
43
- import { clearQuiet, markQuiet } from "../../lib/quiet.js";
43
+ import { clearStartupCommand, markStartupCommand } from "../../lib/startup.js";
44
44
  import {
45
45
  AUTOSTART_CONFIG_PATH,
46
46
  AutostartParseError,
@@ -214,12 +214,12 @@ export function registerAutostart(
214
214
  }
215
215
  if (dispatched.has(command)) continue;
216
216
  dispatched.add(command);
217
- // Our own commands stay silent on startup: the host keeps every
218
- // info notice and re-renders old lines with the newest text, so a
219
- // second info notice after another extension's warning would show
220
- // the same message twice (see lib/quiet.ts).
221
- const quiet = name === "ask-perm";
222
- if (quiet) markQuiet(name);
217
+ // Our own command answers a startup notice with a session entry
218
+ // instead of a status line: at startup several commands emit status
219
+ // lines and a later UI invalidation would paint our text on all of
220
+ // them (see lib/startup.ts).
221
+ const startup = name === "ask-perm";
222
+ if (startup) markStartupCommand(name);
223
223
  // One failing command must not cancel the rest of the list.
224
224
  try {
225
225
  await pi.sendUserMessage(command, { expandPromptTemplates: true });
@@ -229,7 +229,7 @@ export function registerAutostart(
229
229
  "error",
230
230
  );
231
231
  } finally {
232
- if (quiet) clearQuiet(name);
232
+ if (startup) clearStartupCommand(name);
233
233
  }
234
234
  }
235
235
  }
@@ -15,7 +15,7 @@ import type {
15
15
  ToolCallEvent,
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
  import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
18
- import { markQuiet } from "../../lib/quiet.js";
18
+ import { markStartupCommand } from "../../lib/startup.js";
19
19
  import { registerPermission } from "./index.js";
20
20
 
21
21
  function bashEvent(command: string): ToolCallEvent {
@@ -51,6 +51,7 @@ interface Harness {
51
51
  readonly gate: ToolGate;
52
52
  readonly statuses: Array<[string, string | undefined]>;
53
53
  readonly notes: Array<{ message: string; kind?: string }>;
54
+ readonly entries: Array<{ type: string; data: unknown }>;
54
55
  readonly branch: Array<Record<string, unknown>>;
55
56
  setSessionId(id: string): void;
56
57
  evaluate(event: ToolCallEvent): Promise<unknown>;
@@ -63,6 +64,7 @@ function makeHarness(): Harness {
63
64
  const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
64
65
  const statuses: Harness["statuses"] = [];
65
66
  const notes: Harness["notes"] = [];
67
+ const entries: Harness["entries"] = [];
66
68
  const branch: Harness["branch"] = [];
67
69
  let sessionId = "session-1";
68
70
  const ctx = {
@@ -103,6 +105,10 @@ function makeHarness(): Harness {
103
105
  ) {
104
106
  if (name === "ask-perm") commandHandler = options.handler;
105
107
  },
108
+ appendEntry(type: string, data: unknown) {
109
+ entries.push({ type, data });
110
+ },
111
+ registerEntryRenderer() {},
106
112
  } as unknown as ExtensionAPI;
107
113
 
108
114
  registerPermission(pi, hub);
@@ -115,6 +121,7 @@ function makeHarness(): Harness {
115
121
  gate,
116
122
  statuses,
117
123
  notes,
124
+ entries,
118
125
  branch,
119
126
  setSessionId(id: string) {
120
127
  sessionId = id;
@@ -209,6 +216,29 @@ describe("/ask-perm", () => {
209
216
  expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
210
217
  });
211
218
 
219
+ it("writes a startup notice as an entry instead of a status line", async () => {
220
+ const h = makeHarness();
221
+ markStartupCommand("ask-perm");
222
+ await h.command("allow allow allow ask");
223
+ expect(h.notes).toHaveLength(0);
224
+ expect(h.entries).toHaveLength(1);
225
+ expect((h.entries[0]?.data as { text: string }).text).toContain(
226
+ "Permission ON",
227
+ );
228
+ expect(h.statuses.at(-1)?.[1]).toContain("perm");
229
+ expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
230
+ "ask",
231
+ );
232
+ });
233
+
234
+ it("still reports an invalid argument from autostart", async () => {
235
+ const h = makeHarness();
236
+ markStartupCommand("ask-perm");
237
+ await h.command("deny");
238
+ expect(h.notes.at(-1)?.kind).toBe("error");
239
+ expect(h.entries).toHaveLength(0);
240
+ });
241
+
212
242
  it("keeps the mode off when the panel is cancelled", async () => {
213
243
  const h = makeHarness();
214
244
  await h.command("");
@@ -297,25 +327,6 @@ describe("/ask-perm", () => {
297
327
  expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
298
328
  });
299
329
 
300
- it("suppresses the chat notice when autostart started the command", async () => {
301
- const h = makeHarness();
302
- markQuiet("ask-perm");
303
- await h.command("allow allow allow ask");
304
- // The footer carries the state; the chat stays quiet at startup.
305
- expect(h.notes).toHaveLength(0);
306
- expect(h.statuses.at(-1)?.[1]).toContain("perm");
307
- expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
308
- "ask",
309
- );
310
- });
311
-
312
- it("still reports an invalid argument from autostart", async () => {
313
- const h = makeHarness();
314
- markQuiet("ask-perm");
315
- await h.command("deny");
316
- expect(h.notes.at(-1)?.kind).toBe("error");
317
- });
318
-
319
330
  it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
320
331
  const h = makeHarness();
321
332
  await h.command("allow allow allow ask");
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Design notes:
5
5
  * - Off by default and never persisted. The mode, the modes, and every session
6
- * grant live in this closure; `session_start` resets them and
7
- * `session_shutdown` clears them. A forgotten toggle cannot follow the
8
- * operator into the next session.
6
+ * grant live in this closure; `session_start` resets them for a new session
7
+ * and keeps them across a rebind of the same session. A forgotten toggle
8
+ * cannot follow the operator into the next session.
9
9
  * - Toggling: no arguments flips on→off, off→panel. Arguments configure and
10
10
  * enable in one step (`/ask-perm allow allow allow ask`), which is also the
11
11
  * autostart path and never opens a dialog.
@@ -21,8 +21,9 @@ import type {
21
21
  ExtensionContext,
22
22
  ToolCallEvent,
23
23
  } from "@earendil-works/pi-coding-agent";
24
+ import { Text } from "@earendil-works/pi-tui";
24
25
  import type { AskRequest, GateHub } from "../../lib/gates.js";
25
- import { consumeQuiet } from "../../lib/quiet.js";
26
+ import { consumeStartupCommand } from "../../lib/startup.js";
26
27
  import {
27
28
  DEFAULT_MODES,
28
29
  type PermissionModes,
@@ -97,7 +98,37 @@ function toolUnit(
97
98
  };
98
99
  }
99
100
 
101
+ /**
102
+ * Chat notices are custom session entries, not `ctx.ui.notify("info")`.
103
+ *
104
+ * The host renders every info notice as a `ThemedText` whose builder reads one
105
+ * shared `lastStatusMessage` field, and `ui.invalidate()` (theme switch, theme
106
+ * file edit, syntax-grammar load at startup) rebuilds *every* status line from
107
+ * that field. Any earlier status line — for example `/add-dir`'s — then shows
108
+ * our text too, so a single command looks like it ran twice. The invalidate is
109
+ * also why the duplicate depends on startup timing and terminal.
110
+ *
111
+ * A custom entry carries its own text and is rendered from that data, so it is
112
+ * immune to invalidation. Entries stay out of the model context (same pattern
113
+ * as pi-chrome) and survive the startup chat rebuild instead of being wiped.
114
+ *
115
+ * Only startup notices (dispatched by autostart, `lib/startup.ts`) use an
116
+ * entry. Interactive toggles keep `ctx.ui.notify`: consecutive info notices
117
+ * update the previous line in place, so ON/OFF/ON does not scroll the screen.
118
+ */
119
+ const NOTICE_ENTRY = "ask-perm:notice";
120
+
121
+ interface NoticeData {
122
+ text: string;
123
+ }
124
+
100
125
  export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
126
+ pi.registerEntryRenderer?.(NOTICE_ENTRY, (entry, _options, theme) => {
127
+ const text = (entry.data as NoticeData | undefined)?.text;
128
+ return text === undefined
129
+ ? undefined
130
+ : new Text(theme.fg("dim", text), 1, 0);
131
+ });
101
132
  let enabled = false;
102
133
  let modes: PermissionModes = { ...DEFAULT_MODES };
103
134
  // Session this state belongs to. A runtime rebind can emit `session_start`
@@ -264,21 +295,18 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
264
295
  description:
265
296
  "Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
266
297
  handler: async (args, ctx) => {
267
- // Started by autostart: the footer status is the notice (lib/quiet.ts).
268
- const quietNotice = consumeQuiet("ask-perm");
269
- const notify = (
270
- message: string,
271
- type: "info" | "warning" | "error" = "info",
272
- ): void => {
273
- if (type === "info" && quietNotice) return;
274
- ctx.ui.notify(message, type);
298
+ // Startup notices are entries; interactive ones stay in-place toasts.
299
+ const startup = consumeStartupCommand("ask-perm");
300
+ const notice = (text: string): void => {
301
+ if (startup) pi.appendEntry<NoticeData>(NOTICE_ENTRY, { text });
302
+ else ctx.ui.notify(text, "info");
275
303
  };
276
304
  const raw = args.trim();
277
305
 
278
306
  if (raw !== "") {
279
307
  const parsed = parsePermissionArgs(raw);
280
308
  if ("error" in parsed) {
281
- notify(`ask-perm: ${parsed.error}`, "error");
309
+ ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
282
310
  return;
283
311
  }
284
312
  // Idempotent: the same command may arrive twice in one session start
@@ -293,10 +321,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
293
321
  if (changed) rules.clear();
294
322
  enabled = true;
295
323
  syncStatus(ctx);
296
- notify(
297
- `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
298
- "info",
299
- );
324
+ notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
300
325
  return;
301
326
  }
302
327
 
@@ -304,18 +329,14 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
304
329
  enabled = false;
305
330
  rules.clear();
306
331
  syncStatus(ctx);
307
- notify(
308
- "Permission OFF — no boundary checks in this session.",
309
- "info",
310
- );
332
+ notice("Permission OFF — no boundary checks in this session.");
311
333
  return;
312
334
  }
313
335
 
314
336
  const chosen = await openPermissionPanel(ctx, modes);
315
337
  if (!chosen) {
316
- notify(
338
+ notice(
317
339
  "ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
318
- "info",
319
340
  );
320
341
  return;
321
342
  }
@@ -324,10 +345,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
324
345
  rules.clear();
325
346
  enabled = true;
326
347
  syncStatus(ctx);
327
- notify(
328
- `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
329
- "info",
330
- );
348
+ notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
331
349
  },
332
350
  });
333
351
 
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ clearStartupCommand,
4
+ consumeStartupCommand,
5
+ markStartupCommand,
6
+ } from "./startup.js";
7
+
8
+ describe("startup handoff", () => {
9
+ it("consumes a mark exactly once", () => {
10
+ markStartupCommand("ask-perm");
11
+ expect(consumeStartupCommand("ask-perm")).toBe(true);
12
+ expect(consumeStartupCommand("ask-perm")).toBe(false);
13
+ });
14
+
15
+ it("ignores other commands", () => {
16
+ markStartupCommand("ask-perm");
17
+ expect(consumeStartupCommand("third-review")).toBe(false);
18
+ expect(consumeStartupCommand("ask-perm")).toBe(true);
19
+ });
20
+
21
+ it("can be cleared without a consumer", () => {
22
+ markStartupCommand("ask-perm");
23
+ clearStartupCommand("ask-perm");
24
+ expect(consumeStartupCommand("ask-perm")).toBe(false);
25
+ });
26
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Startup handoff for commands dispatched by autostart.
3
+ *
4
+ * Why the distinction: the host renders an info notice (`ctx.ui.notify`) as a
5
+ * `ThemedText` whose builder reads one shared `lastStatusMessage` field, and a
6
+ * UI invalidation (theme switch, theme-file edit, syntax-grammar load at
7
+ * startup) rebuilds *every* status line from that field. At startup several
8
+ * commands emit status lines (/add-dir, …), so our notice makes the earlier
9
+ * lines display our text too — one command looks like it ran twice.
10
+ *
11
+ * Interactive toggles have the opposite preference: consecutive info notices
12
+ * update the previous line in place, so ON/OFF/ON does not scroll the screen.
13
+ *
14
+ * Autostart therefore marks the commands it starts, and our command answers
15
+ * differently: a startup notice is a custom session entry (own text, immune to
16
+ * invalidation), while an interactive one stays a transient status line.
17
+ *
18
+ * The mark is keyed by command name and is always cleared by the dispatcher
19
+ * (`finally`), so it cannot leak into a later manual invocation.
20
+ */
21
+ const startupCommands = new Set<string>();
22
+
23
+ /** Mark `commandName` as dispatched by autostart. */
24
+ export function markStartupCommand(commandName: string): void {
25
+ startupCommands.add(commandName);
26
+ }
27
+
28
+ /** Drop a mark whether or not the command consumed it. */
29
+ export function clearStartupCommand(commandName: string): void {
30
+ startupCommands.delete(commandName);
31
+ }
32
+
33
+ /** `true` once, when the command was started by autostart; consumes the mark. */
34
+ export function consumeStartupCommand(commandName: string): boolean {
35
+ return startupCommands.delete(commandName);
36
+ }
@@ -1,22 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { clearQuiet, consumeQuiet, markQuiet } from "./quiet.js";
3
-
4
- describe("quiet handoff", () => {
5
- it("consumes a mark exactly once", () => {
6
- markQuiet("ask-perm");
7
- expect(consumeQuiet("ask-perm")).toBe(true);
8
- expect(consumeQuiet("ask-perm")).toBe(false);
9
- });
10
-
11
- it("ignores other commands", () => {
12
- markQuiet("ask-perm");
13
- expect(consumeQuiet("third-review")).toBe(false);
14
- expect(consumeQuiet("ask-perm")).toBe(true);
15
- });
16
-
17
- it("can be cleared without a consumer", () => {
18
- markQuiet("ask-perm");
19
- clearQuiet("ask-perm");
20
- expect(consumeQuiet("ask-perm")).toBe(false);
21
- });
22
- });
package/src/lib/quiet.ts DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * Quiet handoff for commands dispatched by autostart.
3
- *
4
- * The host's interactive status line keeps every info notice in the chat, but
5
- * each line renders one shared "last status message" field
6
- * (`interactive-mode.js#showStatus`: `new ThemedText(() => ... this.lastStatusMessage)`).
7
- * A warning emitted between two info notices breaks the coalescing, so a second
8
- * info notice creates a new line — and *both* lines then render the newest
9
- * text. The visible result is the same message twice, separated by the warning.
10
- *
11
- * That is a host display quirk, not a second execution. Startup replay is the
12
- * worst case: `/add-dir` (info) → `/lens-toggle` (warning) → `/ask-perm` (info)
13
- * shows "Permission ON" twice. Autostart therefore marks the commands it
14
- * dispatches as quiet, and our own commands skip the chat notice when they were
15
- * started that way. The footer status still reflects the new state.
16
- *
17
- * The mark is keyed by command name and is always cleared by the dispatcher
18
- * (`finally`), so it cannot leak into a later manual invocation.
19
- */
20
- const quietCommands = new Set<string>();
21
-
22
- /** Mark `commandName` as dispatched by autostart (no chat notice). */
23
- export function markQuiet(commandName: string): void {
24
- quietCommands.add(commandName);
25
- }
26
-
27
- /** Drop a mark whether or not the command consumed it. */
28
- export function clearQuiet(commandName: string): void {
29
- quietCommands.delete(commandName);
30
- }
31
-
32
- /** `true` once, when the command was marked quiet; consumes the mark. */
33
- export function consumeQuiet(commandName: string): boolean {
34
- return quietCommands.delete(commandName);
35
- }