feat: add ask notifications
This commit is contained in:
+49
-4
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user