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
+49 -4
View File
@@ -1,5 +1,9 @@
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 MAX_EXTRACTION_RETRIES = 3;
@@ -24,10 +28,14 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
keymaps: {
...DEFAULT_ASK_KEYMAPS,
},
notifications: {
channels: ["bell"],
enabled: true,
},
};
export function normalizeAskConfig(
config?: Partial<AskConfigFileV2> | AskConfig
config?: Partial<AskConfigFileV3> | AskConfig
): AskConfig {
return {
answer: {
@@ -63,13 +71,19 @@ export function normalizeAskConfig(
...DEFAULT_ASK_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);
return {
schemaVersion: 2,
schemaVersion: 3,
answer: {
extractionModels: normalized.answer.extractionModels,
extractionRetries: normalized.answer.extractionRetries,
@@ -91,9 +105,40 @@ export function toAskConfigFileV2(config: AskConfig): AskConfigFileV2 {
optionNote: normalized.keymaps.optionNote,
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 {
id: string;
provider: string;
+4 -4
View File
@@ -8,8 +8,8 @@ import {
type AskConfigVersionMigrationResult,
migrateAskConfigFileToCurrent,
} from "./migrations/index.ts";
import type { AskConfig, AskConfigFileV2 } from "./schema.ts";
import { validateAskConfigFileV2 } from "./schema.ts";
import type { AskConfig, AskConfigFileV3 } from "./schema.ts";
import { validateAskConfigFileV3 } from "./schema.ts";
export class AskConfigMigrationError extends Error {
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(
"Config was invalid or unsupported.",
"invalid_or_unsupported"
);
}
const currentFile = migratedFile.config as AskConfigFileV2;
const currentFile = migratedFile.config as AskConfigFileV3;
const config = normalizeAskConfig(currentFile);
const keymapsResult = normalizeConfiguredKeymaps(
currentFile.keymaps as
+13 -1
View File
@@ -1,6 +1,6 @@
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[] = [
{
@@ -11,6 +11,18 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
schemaVersion: 2,
}),
},
{
from: 2,
to: 3,
migrate: (config) => ({
...config,
notifications: {
channels: ["bell"],
enabled: true,
},
schemaVersion: 3,
}),
},
];
export class AskConfigVersionMigrationError extends Error {
+38 -4
View File
@@ -6,8 +6,18 @@ const AskAnswerModelPreferenceSchema = Type.Object({
provider: Type.String(),
});
export const AskConfigFileV2Schema = Type.Object({
schemaVersion: Type.Literal(2),
const AskNotificationChannelSchema = Type.Union([
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(
Type.Object({
extractionModels: Type.Optional(
@@ -35,9 +45,23 @@ export const AskConfigFileV2Schema = Type.Object({
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<
AskConfigFileV2,
"answer" | "schemaVersion"
@@ -50,6 +74,12 @@ export interface AskAnswerModelPreference {
provider: string;
}
export type AskNotificationChannel =
| "bell"
| "osc9"
| "osc777"
| { command: string; type: "command" };
export interface AskConfigKeymaps {
cancel: string;
confirm: string;
@@ -72,6 +102,10 @@ export interface AskConfig {
showFooterHints: boolean;
};
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 {
DEFAULT_ASK_CONFIG,
normalizeAskConfig,
toAskConfigFileV2,
toAskConfigFileV3,
} from "./defaults.ts";
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
@@ -66,7 +66,7 @@ export class AskConfigStore {
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
const normalized = normalizeAskConfig(config);
const content = JSON.stringify(
toAskConfigFileV2(normalized),
toAskConfigFileV3(normalized),
null,
2
).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 type { AskConfig } from "../config/schema.ts";
import { getAskConfigStore } from "../config/store.ts";
import {
createQuestionWaitingNotification,
notifyQuestionWaiting,
} from "../notifications.ts";
import { createInitialState } from "../state/create.ts";
import {
getEditorDraft,
@@ -134,6 +138,9 @@ function createAskFlowController(
controller.editor.onSubmit = (value) => submitEditor(controller, value);
syncSelection(controller);
notifyCurrentQuestion(controller).catch(() => {
// Notification failures are best-effort and must not affect the ask flow.
});
return {
render: (width: number) => renderController(controller, width),
@@ -457,6 +464,19 @@ function refresh(controller: AskFlowController) {
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) {
if (controller.state.completed) {
controller.done(toAskResult(controller.state));
+42 -25
View File
@@ -29,7 +29,7 @@ interface AskSettingsListOptions {
const DESCRIPTION_LINE_COUNT = 3;
const COMPACT_WIDTH = 40;
const BEHAVIOUR_SETTINGS = [
const SETTINGS = [
{
description:
"Auto-submit completed ask flows when no question or option notes were added.",
@@ -48,6 +48,12 @@ const BEHAVIOUR_SETTINGS = [
key: "doublePressReviewShortcuts",
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.",
key: "showFooterHints",
@@ -55,7 +61,7 @@ const BEHAVIOUR_SETTINGS = [
},
] as const;
type BehaviourKey = (typeof BEHAVIOUR_SETTINGS)[number]["key"];
type SettingKey = (typeof SETTINGS)[number]["key"];
export class AskSettingsList {
private closed = false;
@@ -90,21 +96,19 @@ export class AskSettingsList {
}
if (matchesKey(data, Key.up)) {
this.focusIndex =
this.focusIndex === 0
? BEHAVIOUR_SETTINGS.length - 1
: this.focusIndex - 1;
this.focusIndex === 0 ? SETTINGS.length - 1 : this.focusIndex - 1;
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.down)) {
this.focusIndex = (this.focusIndex + 1) % BEHAVIOUR_SETTINGS.length;
this.focusIndex = (this.focusIndex + 1) % SETTINGS.length;
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.enter) || data === " ") {
const setting = BEHAVIOUR_SETTINGS[this.focusIndex];
const setting = SETTINGS[this.focusIndex];
if (setting) {
this.saveBehaviour(setting.key, !this.config.behaviour[setting.key]);
this.saveSetting(setting.key, !this.getSettingValue(setting.key));
this.tui.requestRender();
}
}
@@ -139,7 +143,7 @@ export class AskSettingsList {
);
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(
setting,
index,
@@ -147,7 +151,7 @@ export class AskSettingsList {
)) {
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));
}
}
@@ -185,7 +189,7 @@ export class AskSettingsList {
}
private appendSelectedDescription(lines: string[], innerWidth: number): void {
const selectedSetting = BEHAVIOUR_SETTINGS[this.focusIndex];
const selectedSetting = SETTINGS[this.focusIndex];
if (!selectedSetting) {
return;
}
@@ -236,18 +240,16 @@ export class AskSettingsList {
}
private renderSetting(
setting: (typeof BEHAVIOUR_SETTINGS)[number],
setting: (typeof SETTINGS)[number],
index: number,
innerWidth: number
): string[] {
const selected = index === this.focusIndex;
const prefix = selected ? this.theme.fg("accent", "❯ ") : " ";
const continuationPrefix = " ";
const value = this.renderValue(
this.config.behaviour[setting.key],
selected
);
const valueWidth = this.config.behaviour[setting.key] ? 4 : 5;
const enabled = this.getSettingValue(setting.key);
const value = this.renderValue(enabled, selected);
const valueWidth = enabled ? 4 : 5;
const labelWidth = Math.max(
1,
innerWidth - visibleWidth(prefix) - valueWidth - 2
@@ -272,16 +274,31 @@ export class AskSettingsList {
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;
const previousConfig = this.config;
const nextConfig = {
...this.config,
behaviour: {
...this.config.behaviour,
[key]: enabled,
},
};
const nextConfig =
key === "notifications.enabled"
? {
...this.config,
notifications: {
...this.config.notifications,
enabled,
},
}
: {
...this.config,
behaviour: {
...this.config.behaviour,
[key]: enabled,
},
};
this.config = nextConfig;
this.onSave(nextConfig)
.then((savedConfig) => {