feat: add ask notifications
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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(";", ":");
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
+36
-19
@@ -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,10 +274,25 @@ 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 =
|
||||||
|
key === "notifications.enabled"
|
||||||
|
? {
|
||||||
|
...this.config,
|
||||||
|
notifications: {
|
||||||
|
...this.config.notifications,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
...this.config,
|
...this.config,
|
||||||
behaviour: {
|
behaviour: {
|
||||||
...this.config.behaviour,
|
...this.config.behaviour,
|
||||||
|
|||||||
+26
-16
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user