feat: add ask notifications

This commit is contained in:
Erik Kotsiuba
2026-05-06 12:44:37 +02:00
parent 8c02565a5f
commit 43cee4c0f0
15 changed files with 480 additions and 79 deletions
+8 -4
View File
@@ -43,7 +43,7 @@ Once installed, this package gives the agent a native way to ask for clarificati
- 👀 Review tab with `Submit`, `Elaborate`, and `Cancel` - 👀 Review tab with `Submit`, `Elaborate`, and `Cancel`
- 💬 Elaboration flow to capture note-based clarification before final submission - 💬 Elaboration flow to capture note-based clarification before final submission
- ⌨️ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions) - ⌨️ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions)
- ⚙️ Ask settings with persisted behaviour, keymap customization, and `/answer` extraction config - ⚙️ Ask settings with persisted behaviour, notification toggle, keymap customization, and `/answer` extraction config
- 🔁 Slash commands for fallback/replay: - 🔁 Slash commands for fallback/replay:
- `/answer` extracts questions from the latest assistant message into an ask flow - `/answer` extracts questions from the latest assistant message into an ask flow
- `/answer:again` reopens the latest `/answer` form on the current branch - `/answer:again` reopens the latest `/answer` form on the current branch
@@ -122,11 +122,11 @@ Review-tab shortcuts can optionally require the same number key twice via `behav
Config file: `~/.pi/agent/extensions/eko24ive-pi-ask.json` Config file: `~/.pi/agent/extensions/eko24ive-pi-ask.json`
You can edit this file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path and toggle behaviour settings. You can edit this file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path and toggle behaviour/notification settings.
```json ```json
{ {
"schemaVersion": 2, "schemaVersion": 3,
"answer": { "answer": {
"extractionModels": [ "extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" }, { "provider": "openai-codex", "id": "gpt-5.4-mini" },
@@ -149,6 +149,10 @@ You can edit this file yourself, ask pi to edit it for you, or use `/ask-setting
"confirm": "enter", "confirm": "enter",
"optionNote": "n", "optionNote": "n",
"questionNote": "shift+n" "questionNote": "shift+n"
},
"notifications": {
"enabled": true,
"channels": ["bell"]
} }
} }
``` ```
@@ -159,7 +163,7 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa
After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands. After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands.
Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour settings are binary `on`/`off` toggles that save immediately; customizable ask keymaps are changed by editing the shown config file path. Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour and notification settings are binary `on`/`off` toggles that save immediately; customizable ask keymaps and notification channels are changed by editing the shown config file path.
### Answer and replay commands ### Answer and replay commands
+3 -1
View File
@@ -18,6 +18,7 @@ The codebase is split so the implementation reads through file boundaries and na
- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring - `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring
- `src/answer-extraction.ts` — configured extraction model selection and raw-JSON extraction retries - `src/answer-extraction.ts` — configured extraction model selection and raw-JSON extraction retries
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup - `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
- `src/notifications.ts` — best-effort ask notification payload rendering and channel execution
- `src/schema.ts` — TypeBox schema - `src/schema.ts` — TypeBox schema
- `src/types.ts` — shared types - `src/types.ts` — shared types
@@ -76,7 +77,8 @@ The codebase is split so the implementation reads through file boundaries and na
- config schema migrations preserve user-provided values and add new defaults only when fields are absent - config schema migrations preserve user-provided values and add new defaults only when fields are absent
- replay payload lookup scans only the current session branch and revalidates payloads before use - replay payload lookup scans only the current session branch and revalidates payloads before use
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings - invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings
- ask settings behaviour changes persist immediately from the settings list - invalid notification channels are skipped and fall back to the default bell channel if none are valid
- ask settings behaviour and notification enabled changes persist immediately from the settings list
- when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/` - when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/`
- legacy root config files move into `~/.pi/agent/extensions/` only when the current config file is absent - legacy root config files move into `~/.pi/agent/extensions/` only when the current config file is absent
- live config updates can affect an in-progress ask flow immediately - live config updates can affect an in-progress ask flow immediately
+65 -3
View File
@@ -28,7 +28,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
```json ```json
{ {
"schemaVersion": 2, "schemaVersion": 3,
"answer": { "answer": {
"extractionModels": [ "extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" }, { "provider": "openai-codex", "id": "gpt-5.4-mini" },
@@ -51,6 +51,10 @@ Unsupported future versions or invalid files are backed up and defaults are load
"confirm": "enter", "confirm": "enter",
"optionNote": "n", "optionNote": "n",
"questionNote": "shift+n" "questionNote": "shift+n"
},
"notifications": {
"enabled": true,
"channels": ["bell"]
} }
} }
``` ```
@@ -107,6 +111,52 @@ These settings affect only the `/answer` command. Normal `ask_user` tool calls d
- default: `true` - default: `true`
- effect: when disabled, the ask flow hides the footer keymap hints - effect: when disabled, the ask flow hides the footer keymap hints
## Notifications
Notifications are best-effort external alerts emitted once per ask session, when the ask UI opens and is waiting for input.
### `notifications.enabled`
- type: boolean
- default: `true`
- effect: enables or disables external ask notifications
- settings UI: this is the only notification field toggled by `/ask-settings` or `?` in the ask flow
### `notifications.channels`
- type: array
- default: `["bell"]`
- supported channels:
- `"bell"` writes BEL (`\u0007`)
- `"osc9"` writes an OSC 9 terminal notification
- `"osc777"` writes an OSC 777 title/body notification
- `{ "type": "command", "command": string }` runs a shell command
- effect: channels run in order; failures are ignored and never fail the ask flow
- invalid channels are skipped; if none are valid, pi-ask falls back to `["bell"]`
Notification text:
```txt
Question waiting: <label or prompt>
```
Command channels receive these environment variables:
```sh
ASK_NOTIFY_EVENT=question.waiting
ASK_NOTIFY_TITLE="pi ask"
ASK_NOTIFY_MESSAGE="Question waiting: <label or prompt>"
```
cmux example:
```json
{
"type": "command",
"command": "cmux notify --title \"$ASK_NOTIFY_TITLE\" --body \"$ASK_NOTIFY_MESSAGE\""
}
```
## Keymaps ## Keymaps
`keymaps` must contain all 6 configurable actions if present. `keymaps` must contain all 6 configurable actions if present.
@@ -216,7 +266,7 @@ Invalid keymaps include:
```json ```json
{ {
"schemaVersion": 2, "schemaVersion": 3,
"answer": { "answer": {
"extractionRetries": 1, "extractionRetries": 1,
"extractionTimeoutMs": 30000, "extractionTimeoutMs": 30000,
@@ -237,6 +287,15 @@ Invalid keymaps include:
"confirm": "ctrl+k", "confirm": "ctrl+k",
"optionNote": "x", "optionNote": "x",
"questionNote": "shift+x" "questionNote": "shift+x"
},
"notifications": {
"enabled": true,
"channels": [
{
"type": "command",
"command": "cmux notify --title \"$ASK_NOTIFY_TITLE\" --body \"$ASK_NOTIFY_MESSAGE\""
}
]
} }
} }
``` ```
@@ -246,9 +305,12 @@ Invalid keymaps include:
When editing this config for a user: When editing this config for a user:
- preserve unrelated fields - preserve unrelated fields
- keep `schemaVersion` at `2` - keep `schemaVersion` at `3`
- preserve `answer.extractionModels` as explicit provider/id pairs - preserve `answer.extractionModels` as explicit provider/id pairs
- keep `answer.extractionRetries` between `0` and `3` - keep `answer.extractionRetries` between `0` and `3`
- do not assign reserved bindings to configurable actions - do not assign reserved bindings to configurable actions
- do not create duplicate configurable bindings - do not create duplicate configurable bindings
- preserve existing `notifications.channels` unless the user asks to change notification targets
- only toggle `notifications.enabled` unless the user asks to configure channels
- use a `cmux notify` command channel when the user asks for cmux notifications
- after changing the file, tell the user to run `/reload` or restart pi - after changing the file, tell the user to run `/reload` or restart pi
+7 -3
View File
@@ -206,9 +206,9 @@ This document defines the stable external behavior. It does not explain internal
- `/answer` extraction may use an internal `freeform: true` option for open-ended questions with no explicit choices; these render as user-input-only questions with the label `Type your answer:`, no numbered option row, and no selection caret; this marker is not part of the public `ask_user` tool contract - `/answer` extraction may use an internal `freeform: true` option for open-ended questions with no explicit choices; these render as user-input-only questions with the label `Type your answer:`, no numbered option row, and no selection caret; this marker is not part of the public `ask_user` tool contract
- `/answer:again` command to replay the latest `/answer`-extracted form on the current branch - `/answer:again` command to replay the latest `/answer`-extracted form on the current branch
- `/ask:replay` command to replay the latest real `ask_user` form on the current branch - `/ask:replay` command to replay the latest real `ask_user` form on the current branch
- ask settings list with binary behaviour toggles - ask settings list with binary behaviour and notification toggles
- `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay - `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay
- behaviour settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints` - settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, and `Show footer hints`
- `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote` - `Keymaps` is a persisted config section for `cancel`, `dismiss`, `toggle`, `confirm`, `optionNote`, and `questionNote`
- the settings list shows the absolute config file path for changing customizable ask keymaps - the settings list shows the absolute config file path for changing customizable ask keymaps
- if the flow is already on the review tab, all questions are answered, and no notes exist, enabling auto-submit can complete the current ask flow immediately - if the flow is already on the review tab, all questions are answered, and no notes exist, enabling auto-submit can complete the current ask flow immediately
@@ -245,7 +245,11 @@ If `ctx.hasUI === false`, the tool returns a `Needs user input` message in `cont
Validation is handled inside the tool so malformed calls produce the same structured error shape as other invalid payloads instead of relying on pre-execution schema failures. Validation is handled inside the tool so malformed calls produce the same structured error shape as other invalid payloads instead of relying on pre-execution schema failures.
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Show footer hints`, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks. The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks.
## Notifications
When enabled, pi-ask emits one best-effort external notification per ask session after the ask UI opens and waits for input. The default title is `pi ask`; the message is `Question waiting: <label or prompt>`. Channels run in configured order and failures never fail or cancel the ask flow.
## Slash command replay/extraction ## Slash command replay/extraction
+49 -4
View File
@@ -1,5 +1,9 @@
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts"; import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
import type { AskConfig, AskConfigFileV2 } from "./schema.ts"; import type {
AskConfig,
AskConfigFileV3,
AskNotificationChannel,
} from "./schema.ts";
const DEFAULT_EXTRACTION_RETRIES = 1; const DEFAULT_EXTRACTION_RETRIES = 1;
const MAX_EXTRACTION_RETRIES = 3; const MAX_EXTRACTION_RETRIES = 3;
@@ -24,10 +28,14 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
keymaps: { keymaps: {
...DEFAULT_ASK_KEYMAPS, ...DEFAULT_ASK_KEYMAPS,
}, },
notifications: {
channels: ["bell"],
enabled: true,
},
}; };
export function normalizeAskConfig( export function normalizeAskConfig(
config?: Partial<AskConfigFileV2> | AskConfig config?: Partial<AskConfigFileV3> | AskConfig
): AskConfig { ): AskConfig {
return { return {
answer: { answer: {
@@ -63,13 +71,19 @@ export function normalizeAskConfig(
...DEFAULT_ASK_CONFIG.keymaps, ...DEFAULT_ASK_CONFIG.keymaps,
...(config?.keymaps ?? {}), ...(config?.keymaps ?? {}),
}, },
notifications: {
channels: normalizeNotificationChannels(config?.notifications?.channels),
enabled:
config?.notifications?.enabled ??
DEFAULT_ASK_CONFIG.notifications.enabled,
},
}; };
} }
export function toAskConfigFileV2(config: AskConfig): AskConfigFileV2 { export function toAskConfigFileV3(config: AskConfig): AskConfigFileV3 {
const normalized = normalizeAskConfig(config); const normalized = normalizeAskConfig(config);
return { return {
schemaVersion: 2, schemaVersion: 3,
answer: { answer: {
extractionModels: normalized.answer.extractionModels, extractionModels: normalized.answer.extractionModels,
extractionRetries: normalized.answer.extractionRetries, extractionRetries: normalized.answer.extractionRetries,
@@ -91,9 +105,40 @@ export function toAskConfigFileV2(config: AskConfig): AskConfigFileV2 {
optionNote: normalized.keymaps.optionNote, optionNote: normalized.keymaps.optionNote,
questionNote: normalized.keymaps.questionNote, questionNote: normalized.keymaps.questionNote,
}, },
notifications: {
channels: normalized.notifications.channels,
enabled: normalized.notifications.enabled,
},
}; };
} }
function normalizeNotificationChannels(
channels: unknown
): AskNotificationChannel[] {
if (!Array.isArray(channels)) {
return DEFAULT_ASK_CONFIG.notifications.channels;
}
const normalized = channels.filter(isValidNotificationChannel);
return normalized.length > 0
? normalized
: DEFAULT_ASK_CONFIG.notifications.channels;
}
function isValidNotificationChannel(
value: unknown
): value is AskNotificationChannel {
if (value === "bell" || value === "osc9" || value === "osc777") {
return true;
}
return (
!!value &&
typeof value === "object" &&
(value as { type?: unknown }).type === "command" &&
typeof (value as { command?: unknown }).command === "string" &&
(value as { command: string }).command.trim().length > 0
);
}
function isValidModelPreference(value: unknown): value is { function isValidModelPreference(value: unknown): value is {
id: string; id: string;
provider: string; provider: string;
+4 -4
View File
@@ -8,8 +8,8 @@ import {
type AskConfigVersionMigrationResult, type AskConfigVersionMigrationResult,
migrateAskConfigFileToCurrent, migrateAskConfigFileToCurrent,
} from "./migrations/index.ts"; } from "./migrations/index.ts";
import type { AskConfig, AskConfigFileV2 } from "./schema.ts"; import type { AskConfig, AskConfigFileV3 } from "./schema.ts";
import { validateAskConfigFileV2 } from "./schema.ts"; import { validateAskConfigFileV3 } from "./schema.ts";
export class AskConfigMigrationError extends Error { export class AskConfigMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed"; readonly reason: "invalid_or_unsupported" | "migration_failed";
@@ -42,14 +42,14 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
); );
} }
if (!validateAskConfigFileV2.Check(migratedFile.config)) { if (!validateAskConfigFileV3.Check(migratedFile.config)) {
throw new AskConfigMigrationError( throw new AskConfigMigrationError(
"Config was invalid or unsupported.", "Config was invalid or unsupported.",
"invalid_or_unsupported" "invalid_or_unsupported"
); );
} }
const currentFile = migratedFile.config as AskConfigFileV2; const currentFile = migratedFile.config as AskConfigFileV3;
const config = normalizeAskConfig(currentFile); const config = normalizeAskConfig(currentFile);
const keymapsResult = normalizeConfiguredKeymaps( const keymapsResult = normalizeConfiguredKeymaps(
currentFile.keymaps as currentFile.keymaps as
+13 -1
View File
@@ -1,6 +1,6 @@
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts"; import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 2; export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 3;
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
{ {
@@ -11,6 +11,18 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
schemaVersion: 2, schemaVersion: 2,
}), }),
}, },
{
from: 2,
to: 3,
migrate: (config) => ({
...config,
notifications: {
channels: ["bell"],
enabled: true,
},
schemaVersion: 3,
}),
},
]; ];
export class AskConfigVersionMigrationError extends Error { export class AskConfigVersionMigrationError extends Error {
+38 -4
View File
@@ -6,8 +6,18 @@ const AskAnswerModelPreferenceSchema = Type.Object({
provider: Type.String(), provider: Type.String(),
}); });
export const AskConfigFileV2Schema = Type.Object({ const AskNotificationChannelSchema = Type.Union([
schemaVersion: Type.Literal(2), Type.Literal("bell"),
Type.Literal("osc9"),
Type.Literal("osc777"),
Type.Object({
command: Type.String(),
type: Type.Literal("command"),
}),
]);
export const AskConfigFileV3Schema = Type.Object({
schemaVersion: Type.Literal(3),
answer: Type.Optional( answer: Type.Optional(
Type.Object({ Type.Object({
extractionModels: Type.Optional( extractionModels: Type.Optional(
@@ -35,9 +45,23 @@ export const AskConfigFileV2Schema = Type.Object({
toggle: Type.Optional(Type.String()), toggle: Type.Optional(Type.String()),
}) })
), ),
notifications: Type.Optional(
Type.Object({
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
enabled: Type.Optional(Type.Boolean()),
})
),
}); });
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema>; export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
"notifications",
"schemaVersion",
]);
export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
schemaVersion: 2;
};
export type AskConfigFileV1 = Omit< export type AskConfigFileV1 = Omit<
AskConfigFileV2, AskConfigFileV2,
"answer" | "schemaVersion" "answer" | "schemaVersion"
@@ -50,6 +74,12 @@ export interface AskAnswerModelPreference {
provider: string; provider: string;
} }
export type AskNotificationChannel =
| "bell"
| "osc9"
| "osc777"
| { command: string; type: "command" };
export interface AskConfigKeymaps { export interface AskConfigKeymaps {
cancel: string; cancel: string;
confirm: string; confirm: string;
@@ -72,6 +102,10 @@ export interface AskConfig {
showFooterHints: boolean; showFooterHints: boolean;
}; };
keymaps: AskConfigKeymaps; keymaps: AskConfigKeymaps;
notifications: {
channels: AskNotificationChannel[];
enabled: boolean;
};
} }
export const validateAskConfigFileV2 = Compile(AskConfigFileV2Schema); export const validateAskConfigFileV3 = Compile(AskConfigFileV3Schema);
+2 -2
View File
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
import { import {
DEFAULT_ASK_CONFIG, DEFAULT_ASK_CONFIG,
normalizeAskConfig, normalizeAskConfig,
toAskConfigFileV2, toAskConfigFileV3,
} from "./defaults.ts"; } from "./defaults.ts";
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts"; import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts"; import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
@@ -66,7 +66,7 @@ export class AskConfigStore {
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> { async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
const normalized = normalizeAskConfig(config); const normalized = normalizeAskConfig(config);
const content = JSON.stringify( const content = JSON.stringify(
toAskConfigFileV2(normalized), toAskConfigFileV3(normalized),
null, null,
2 2
).concat("\n"); ).concat("\n");
+119
View File
@@ -0,0 +1,119 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { AskConfig } from "./config/schema.ts";
import type { AskQuestion } from "./types.ts";
const execAsync = promisify(exec);
const DEFAULT_TITLE = "pi ask";
const EVENT = "question.waiting";
const MAX_MESSAGE_LENGTH = 120;
const COMMAND_TIMEOUT_MS = 5000;
export interface AskNotificationPayload {
event: typeof EVENT;
message: string;
title: string;
}
export type NotificationAttemptStatus = "attempted" | "failed" | "skipped";
export interface NotificationAttempt {
channel: string;
error?: string;
status: NotificationAttemptStatus;
}
export function createQuestionWaitingNotification(
question: Pick<AskQuestion, "label" | "prompt">
): AskNotificationPayload {
const subject = question.label || question.prompt;
return {
event: EVENT,
title: DEFAULT_TITLE,
message: truncate(
`Question waiting: ${singleLine(subject)}`,
MAX_MESSAGE_LENGTH
),
};
}
export async function notifyQuestionWaiting(
config: AskConfig,
payload: AskNotificationPayload
): Promise<NotificationAttempt[]> {
if (!config.notifications.enabled) {
return [{ channel: "notifications", status: "skipped" }];
}
const attempts: NotificationAttempt[] = [];
for (const channel of config.notifications.channels) {
attempts.push(await notifyChannel(channel, payload));
}
return attempts;
}
async function notifyChannel(
channel: AskConfig["notifications"]["channels"][number],
payload: AskNotificationPayload
): Promise<NotificationAttempt> {
const type = typeof channel === "string" ? channel : channel.type;
try {
switch (type) {
case "bell":
process.stdout.write("\x07");
return { channel: type, status: "attempted" };
case "osc9":
process.stdout.write(`\x1b]9;${sanitizeOsc(payload.message)}\x07`);
return { channel: type, status: "attempted" };
case "osc777":
process.stdout.write(
`\x1b]777;notify;${sanitizeOscField(payload.title)};${sanitizeOscField(payload.message)}\x07`
);
return { channel: type, status: "attempted" };
case "command": {
if (typeof channel === "string") {
return { channel: type, status: "failed", error: "Missing command" };
}
const env = { ...process.env };
env.ASK_NOTIFY_EVENT = payload.event;
env.ASK_NOTIFY_MESSAGE = payload.message;
env.ASK_NOTIFY_TITLE = payload.title;
await execAsync(channel.command, {
env,
timeout: COMMAND_TIMEOUT_MS,
});
return { channel: type, status: "attempted" };
}
default:
return { channel: String(type), status: "skipped" };
}
} catch (error) {
return {
channel: type,
error: error instanceof Error ? error.message : String(error),
status: "failed",
};
}
}
function singleLine(value: string): string {
return value.replace(/\s+/gu, " ").trim();
}
function truncate(value: string, maxLength: number): string {
return value.length <= maxLength
? value
: `${value.slice(0, maxLength - 1)}…`;
}
function sanitizeOsc(value: string): string {
return [...singleLine(value)]
.filter((character) => {
const code = character.codePointAt(0) ?? 0;
return code > 31 && code !== 127;
})
.join("");
}
function sanitizeOscField(value: string): string {
return sanitizeOsc(value).replaceAll(";", ":");
}
+20
View File
@@ -2,6 +2,10 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
import type { AskConfig } from "../config/schema.ts"; import type { AskConfig } from "../config/schema.ts";
import { getAskConfigStore } from "../config/store.ts"; import { getAskConfigStore } from "../config/store.ts";
import {
createQuestionWaitingNotification,
notifyQuestionWaiting,
} from "../notifications.ts";
import { createInitialState } from "../state/create.ts"; import { createInitialState } from "../state/create.ts";
import { import {
getEditorDraft, getEditorDraft,
@@ -134,6 +138,9 @@ function createAskFlowController(
controller.editor.onSubmit = (value) => submitEditor(controller, value); controller.editor.onSubmit = (value) => submitEditor(controller, value);
syncSelection(controller); syncSelection(controller);
notifyCurrentQuestion(controller).catch(() => {
// Notification failures are best-effort and must not affect the ask flow.
});
return { return {
render: (width: number) => renderController(controller, width), render: (width: number) => renderController(controller, width),
@@ -457,6 +464,19 @@ function refresh(controller: AskFlowController) {
controller.tui.requestRender(); controller.tui.requestRender();
} }
async function notifyCurrentQuestion(
controller: AskFlowController
): Promise<void> {
const question = getCurrentQuestion(controller.state);
if (!question) {
return;
}
await notifyQuestionWaiting(
controller.config,
createQuestionWaitingNotification(question)
);
}
function maybeFinish(controller: AskFlowController) { function maybeFinish(controller: AskFlowController) {
if (controller.state.completed) { if (controller.state.completed) {
controller.done(toAskResult(controller.state)); controller.done(toAskResult(controller.state));
+42 -25
View File
@@ -29,7 +29,7 @@ interface AskSettingsListOptions {
const DESCRIPTION_LINE_COUNT = 3; const DESCRIPTION_LINE_COUNT = 3;
const COMPACT_WIDTH = 40; const COMPACT_WIDTH = 40;
const BEHAVIOUR_SETTINGS = [ const SETTINGS = [
{ {
description: description:
"Auto-submit completed ask flows when no question or option notes were added.", "Auto-submit completed ask flows when no question or option notes were added.",
@@ -48,6 +48,12 @@ const BEHAVIOUR_SETTINGS = [
key: "doublePressReviewShortcuts", key: "doublePressReviewShortcuts",
label: "Double-press review shortcuts", label: "Double-press review shortcuts",
}, },
{
description:
"Emit one external notification when the ask flow opens and waits for input.",
key: "notifications.enabled",
label: "Notifications",
},
{ {
description: "Show footer keymap hints at the bottom of the ask flow.", description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints", key: "showFooterHints",
@@ -55,7 +61,7 @@ const BEHAVIOUR_SETTINGS = [
}, },
] as const; ] as const;
type BehaviourKey = (typeof BEHAVIOUR_SETTINGS)[number]["key"]; type SettingKey = (typeof SETTINGS)[number]["key"];
export class AskSettingsList { export class AskSettingsList {
private closed = false; private closed = false;
@@ -90,21 +96,19 @@ export class AskSettingsList {
} }
if (matchesKey(data, Key.up)) { if (matchesKey(data, Key.up)) {
this.focusIndex = this.focusIndex =
this.focusIndex === 0 this.focusIndex === 0 ? SETTINGS.length - 1 : this.focusIndex - 1;
? BEHAVIOUR_SETTINGS.length - 1
: this.focusIndex - 1;
this.tui.requestRender(); this.tui.requestRender();
return; return;
} }
if (matchesKey(data, Key.down)) { if (matchesKey(data, Key.down)) {
this.focusIndex = (this.focusIndex + 1) % BEHAVIOUR_SETTINGS.length; this.focusIndex = (this.focusIndex + 1) % SETTINGS.length;
this.tui.requestRender(); this.tui.requestRender();
return; return;
} }
if (matchesKey(data, Key.enter) || data === " ") { if (matchesKey(data, Key.enter) || data === " ") {
const setting = BEHAVIOUR_SETTINGS[this.focusIndex]; const setting = SETTINGS[this.focusIndex];
if (setting) { if (setting) {
this.saveBehaviour(setting.key, !this.config.behaviour[setting.key]); this.saveSetting(setting.key, !this.getSettingValue(setting.key));
this.tui.requestRender(); this.tui.requestRender();
} }
} }
@@ -139,7 +143,7 @@ export class AskSettingsList {
); );
lines.push(this.line("", innerWidth)); lines.push(this.line("", innerWidth));
for (const [index, setting] of BEHAVIOUR_SETTINGS.entries()) { for (const [index, setting] of SETTINGS.entries()) {
for (const settingLine of this.renderSetting( for (const settingLine of this.renderSetting(
setting, setting,
index, index,
@@ -147,7 +151,7 @@ export class AskSettingsList {
)) { )) {
lines.push(this.line(settingLine, innerWidth)); lines.push(this.line(settingLine, innerWidth));
} }
if (innerWidth < COMPACT_WIDTH && index < BEHAVIOUR_SETTINGS.length - 1) { if (innerWidth < COMPACT_WIDTH && index < SETTINGS.length - 1) {
lines.push(this.line("", innerWidth)); lines.push(this.line("", innerWidth));
} }
} }
@@ -185,7 +189,7 @@ export class AskSettingsList {
} }
private appendSelectedDescription(lines: string[], innerWidth: number): void { private appendSelectedDescription(lines: string[], innerWidth: number): void {
const selectedSetting = BEHAVIOUR_SETTINGS[this.focusIndex]; const selectedSetting = SETTINGS[this.focusIndex];
if (!selectedSetting) { if (!selectedSetting) {
return; return;
} }
@@ -236,18 +240,16 @@ export class AskSettingsList {
} }
private renderSetting( private renderSetting(
setting: (typeof BEHAVIOUR_SETTINGS)[number], setting: (typeof SETTINGS)[number],
index: number, index: number,
innerWidth: number innerWidth: number
): string[] { ): string[] {
const selected = index === this.focusIndex; const selected = index === this.focusIndex;
const prefix = selected ? this.theme.fg("accent", "❯ ") : " "; const prefix = selected ? this.theme.fg("accent", "❯ ") : " ";
const continuationPrefix = " "; const continuationPrefix = " ";
const value = this.renderValue( const enabled = this.getSettingValue(setting.key);
this.config.behaviour[setting.key], const value = this.renderValue(enabled, selected);
selected const valueWidth = enabled ? 4 : 5;
);
const valueWidth = this.config.behaviour[setting.key] ? 4 : 5;
const labelWidth = Math.max( const labelWidth = Math.max(
1, 1,
innerWidth - visibleWidth(prefix) - valueWidth - 2 innerWidth - visibleWidth(prefix) - valueWidth - 2
@@ -272,16 +274,31 @@ export class AskSettingsList {
return `${this.theme.fg("dim", "[")}${styledValue}${this.theme.fg("dim", "]")}`; return `${this.theme.fg("dim", "[")}${styledValue}${this.theme.fg("dim", "]")}`;
} }
private saveBehaviour(key: BehaviourKey, enabled: boolean): void { private getSettingValue(key: SettingKey): boolean {
return key === "notifications.enabled"
? this.config.notifications.enabled
: this.config.behaviour[key];
}
private saveSetting(key: SettingKey, enabled: boolean): void {
this.error = undefined; this.error = undefined;
const previousConfig = this.config; const previousConfig = this.config;
const nextConfig = { const nextConfig =
...this.config, key === "notifications.enabled"
behaviour: { ? {
...this.config.behaviour, ...this.config,
[key]: enabled, notifications: {
}, ...this.config.notifications,
}; enabled,
},
}
: {
...this.config,
behaviour: {
...this.config.behaviour,
[key]: enabled,
},
};
this.config = nextConfig; this.config = nextConfig;
this.onSave(nextConfig) this.onSave(nextConfig)
.then((savedConfig) => { .then((savedConfig) => {
+38 -28
View File
@@ -9,6 +9,18 @@ import { AskConfigStore } from "../src/config/store.ts";
const DEFAULT_KEYMAPS_NOTICE_PATTERN = const DEFAULT_KEYMAPS_NOTICE_PATTERN =
/Using default ask keymaps for this session/; /Using default ask keymaps for this session/;
function expectedConfigFile(
overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {}
) {
return {
schemaVersion: 3,
answer: DEFAULT_ASK_CONFIG.answer,
behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour,
keymaps: DEFAULT_ASK_CONFIG.keymaps,
notifications: DEFAULT_ASK_CONFIG.notifications,
};
}
async function makeTempPath(name: string): Promise<string> { async function makeTempPath(name: string): Promise<string> {
const root = await import("node:fs/promises").then(({ mkdtemp }) => const root = await import("node:fs/promises").then(({ mkdtemp }) =>
mkdtemp(join(tmpdir(), name)) mkdtemp(join(tmpdir(), name))
@@ -23,12 +35,10 @@ test("config store writes defaults when file is missing", async () => {
const result = await store.ensureLoaded(); const result = await store.ensureLoaded();
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG); assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), { assert.deepEqual(
schemaVersion: 2, JSON.parse(await readFile(path, "utf-8")),
answer: DEFAULT_ASK_CONFIG.answer, expectedConfigFile()
behaviour: DEFAULT_ASK_CONFIG.behaviour, );
keymaps: DEFAULT_ASK_CONFIG.keymaps,
});
await rm(dirname(path), { force: true, recursive: true }); await rm(dirname(path), { force: true, recursive: true });
}); });
@@ -47,17 +57,17 @@ test("config store writes full normalized config on save", async () => {
}); });
const content = await readFile(path, "utf-8"); const content = await readFile(path, "utf-8");
assert.deepEqual(JSON.parse(content), { assert.deepEqual(
schemaVersion: 2, JSON.parse(content),
answer: DEFAULT_ASK_CONFIG.answer, expectedConfigFile({
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true, confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true, doublePressReviewShortcuts: true,
showFooterHints: false, showFooterHints: false,
}, },
keymaps: DEFAULT_ASK_CONFIG.keymaps, })
}); );
await rm(dirname(path), { force: true, recursive: true }); await rm(dirname(path), { force: true, recursive: true });
}); });
@@ -144,17 +154,17 @@ test("config store migrates the legacy root config path into extensions", async
assert.equal(result.config.behaviour.doublePressReviewShortcuts, true); assert.equal(result.config.behaviour.doublePressReviewShortcuts, true);
assert.equal(result.config.behaviour.showFooterHints, false); assert.equal(result.config.behaviour.showFooterHints, false);
await assert.rejects(readFile(legacyPath, "utf-8")); await assert.rejects(readFile(legacyPath, "utf-8"));
assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), { assert.deepEqual(
schemaVersion: 2, JSON.parse(await readFile(path, "utf-8")),
answer: DEFAULT_ASK_CONFIG.answer, expectedConfigFile({
behaviour: { behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true, confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true, doublePressReviewShortcuts: true,
showFooterHints: false, showFooterHints: false,
}, },
keymaps: DEFAULT_ASK_CONFIG.keymaps, })
}); );
await rm(root, { force: true, recursive: true }); await rm(root, { force: true, recursive: true });
}); });
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
import {
createQuestionWaitingNotification,
notifyQuestionWaiting,
} from "../src/notifications.ts";
test("notification payload uses question label before prompt", () => {
const payload = createQuestionWaitingNotification({
label: "Scope",
prompt: "What should we change?",
});
assert.deepEqual(payload, {
event: "question.waiting",
title: "pi ask",
message: "Question waiting: Scope",
});
});
test("notification payload falls back to prompt and compacts whitespace", () => {
const payload = createQuestionWaitingNotification({
label: "",
prompt: "What\nshould\twe change?",
});
assert.equal(payload.message, "Question waiting: What should we change?");
});
test("disabled notifications are skipped", async () => {
const attempts = await notifyQuestionWaiting(
{
...DEFAULT_ASK_CONFIG,
notifications: { channels: ["bell"], enabled: false },
},
createQuestionWaitingNotification({ label: "Scope", prompt: "Prompt" })
);
assert.deepEqual(attempts, [{ channel: "notifications", status: "skipped" }]);
});
test("command notification receives env and swallows failures", async () => {
const payload = createQuestionWaitingNotification({
label: "Scope",
prompt: "Prompt",
});
const attempts = await notifyQuestionWaiting(
{
...DEFAULT_ASK_CONFIG,
notifications: {
channels: [
{
command:
"node -e 'if (process.env.ASK_NOTIFY_MESSAGE !== \"Question waiting: Scope\") process.exit(7)'",
type: "command",
},
{ command: "node -e 'process.exit(3)'", type: "command" },
],
enabled: true,
},
},
payload
);
assert.equal(attempts[0]?.status, "attempted");
assert.equal(attempts[1]?.status, "failed");
});
+4
View File
@@ -23,6 +23,10 @@ const savedConfig: AskConfig = {
optionNote: "n", optionNote: "n",
questionNote: "shift+n", questionNote: "shift+n",
}, },
notifications: {
channels: ["bell"],
enabled: true,
},
}; };
function plainTheme() { function plainTheme() {