From 2c0ab50094bc9a048cbb0d9927730b2885302dc5 Mon Sep 17 00:00:00 2001 From: Vahor Date: Sat, 23 May 2026 16:26:41 +0200 Subject: [PATCH 1/5] refactor: share command config schemas across extensions --- lib/shared/package.json | 4 + .../src/config/__tests__/config.test.ts | 1 + lib/shared/src/config/parse.ts | 2 +- lib/shared/src/runner/config.ts | 21 +++ .../pi-hooks/src/__tests__/config.test.ts | 32 ++-- packages/pi-hooks/src/config.ts | 27 +--- .../pi-keymap/src/__tests__/config.test.ts | 41 +++-- packages/pi-keymap/src/config.ts | 141 +++++++++--------- scripts/generate-config-schemas.ts | 2 +- 9 files changed, 159 insertions(+), 112 deletions(-) create mode 100644 lib/shared/src/runner/config.ts diff --git a/lib/shared/package.json b/lib/shared/package.json index 16c6695..239370c 100644 --- a/lib/shared/package.json +++ b/lib/shared/package.json @@ -18,6 +18,10 @@ "types": "./src/runner/index.ts", "import": "./src/runner/index.ts" }, + "./runner/config": { + "types": "./src/runner/config.ts", + "import": "./src/runner/config.ts" + }, "./trie": { "types": "./src/trie.ts", "import": "./src/trie.ts" diff --git a/lib/shared/src/config/__tests__/config.test.ts b/lib/shared/src/config/__tests__/config.test.ts index 1b3a323..665260c 100644 --- a/lib/shared/src/config/__tests__/config.test.ts +++ b/lib/shared/src/config/__tests__/config.test.ts @@ -133,6 +133,7 @@ describe("getProjectConfigPath", () => { describe("readConfig", () => { const HookSchema = Schema.Struct({ + $schema: Schema.optional(Schema.String), hooks: Schema.optional( Schema.Record({ key: Schema.String, diff --git a/lib/shared/src/config/parse.ts b/lib/shared/src/config/parse.ts index 4bfd07b..2c2f6ac 100644 --- a/lib/shared/src/config/parse.ts +++ b/lib/shared/src/config/parse.ts @@ -14,7 +14,7 @@ export const decodeConfig = ( value: unknown, schema: Schema.Schema, ): Effect.Effect => - Schema.decodeUnknown(schema)(value).pipe( + Schema.decodeUnknown(schema, { onExcessProperty: "error" })(value).pipe( Effect.mapError((errors) => new ValidationError(errors)), ); diff --git a/lib/shared/src/runner/config.ts b/lib/shared/src/runner/config.ts new file mode 100644 index 0000000..ca1226d --- /dev/null +++ b/lib/shared/src/runner/config.ts @@ -0,0 +1,21 @@ +import { Schema } from "effect"; + +export const CommandEntryStruct = Schema.Struct({ + command: Schema.String, + cwd: Schema.optional(Schema.String), + timeout: Schema.optional(Schema.Number), + print: Schema.optional(Schema.Boolean), + context: Schema.optional(Schema.Boolean), + interactive: Schema.optional(Schema.Boolean), +}); + +export const CommandEntrySchema = Schema.transform( + Schema.Union(Schema.String, CommandEntryStruct), + CommandEntryStruct, + { + decode: (from) => (typeof from === "string" ? { command: from } : from), + encode: (to) => to, + }, +); + +export type CommandEntry = Schema.Schema.Type; diff --git a/packages/pi-hooks/src/__tests__/config.test.ts b/packages/pi-hooks/src/__tests__/config.test.ts index 6d3c6bb..a58a398 100644 --- a/packages/pi-hooks/src/__tests__/config.test.ts +++ b/packages/pi-hooks/src/__tests__/config.test.ts @@ -2,30 +2,42 @@ import { describe, expect, test } from "bun:test"; import { Schema } from "effect"; import { CommandHooksConfigSchema } from "../config.js"; +const decodeConfig = (value: unknown) => + Schema.decodeUnknownSync(CommandHooksConfigSchema, { + onExcessProperty: "error", + })(value); + describe("CommandHooksConfigSchema", () => { test("allows empty hooks object (all events optional)", () => { - const result = Schema.decodeUnknownSync(CommandHooksConfigSchema)({ + const result = decodeConfig({ hooks: {}, }); expect(result.hooks).toEqual({}); }); + test("allows editor schema URI", () => { + const result = decodeConfig({ + $schema: "https://example.com/hooks.schema.json", + hooks: {}, + }); + expect(result.$schema).toBe("https://example.com/hooks.schema.json"); + }); + test("fails on missing hooks key", () => { - expect(() => - Schema.decodeUnknownSync(CommandHooksConfigSchema)({}), - ).toThrow(); + expect(() => decodeConfig({})).toThrow(); }); - test("silently drops unknown event names", () => { - const result = Schema.decodeUnknownSync(CommandHooksConfigSchema)({ - hooks: { not_an_event: ["echo"] }, - }); - expect(result.hooks).toEqual({}); + test("fails on unknown event names", () => { + expect(() => + decodeConfig({ + hooks: { not_an_event: ["echo"] }, + }), + ).toThrow(); }); test("fails on invalid entry", () => { expect(() => - Schema.decodeUnknownSync(CommandHooksConfigSchema)({ + decodeConfig({ hooks: { session_start: [42] }, }), ).toThrow(); diff --git a/packages/pi-hooks/src/config.ts b/packages/pi-hooks/src/config.ts index 1733a79..c22f37e 100644 --- a/packages/pi-hooks/src/config.ts +++ b/packages/pi-hooks/src/config.ts @@ -1,32 +1,19 @@ +import { + type CommandEntry, + CommandEntrySchema, +} from "@vahor/shared/runner/config"; import { Schema } from "effect"; import { PiEvent } from "./events.js"; export const HooksConfigSchemaUrl = "https://raw.githubusercontent.com/Vahor/pi-extensions/main/packages/pi-hooks/schemas/hooks.schema.json"; -const HookEntryStruct = Schema.Struct({ - command: Schema.String, - cwd: Schema.optional(Schema.String), - timeout: Schema.optional(Schema.Number), - print: Schema.optional(Schema.Boolean), - context: Schema.optional(Schema.Boolean), - interactive: Schema.optional(Schema.Boolean), -}); - -export const HookEntrySchema = Schema.transform( - Schema.Union(Schema.String, HookEntryStruct), - HookEntryStruct, - { - decode: (from) => (typeof from === "string" ? { command: from } : from), - encode: (to) => to, - }, -); - export const CommandHooksConfigSchema = Schema.Struct({ + $schema: Schema.optional(Schema.String), hooks: Schema.partial( Schema.Record({ key: PiEvent, - value: Schema.Array(HookEntrySchema), + value: Schema.Array(CommandEntrySchema), }), ), }).annotations({ @@ -47,7 +34,7 @@ export const EmptyCommandHooksConfig = { hooks: {}, } as const; -export type HookEntry = Schema.Schema.Type; +export type HookEntry = CommandEntry; export type CommandHooksConfig = Schema.Schema.Type< typeof CommandHooksConfigSchema >; diff --git a/packages/pi-keymap/src/__tests__/config.test.ts b/packages/pi-keymap/src/__tests__/config.test.ts index 8a79a7d..38d21c3 100644 --- a/packages/pi-keymap/src/__tests__/config.test.ts +++ b/packages/pi-keymap/src/__tests__/config.test.ts @@ -2,9 +2,14 @@ import { describe, expect, test } from "bun:test"; import { Schema } from "effect"; import { KeymapsConfigSchema } from "../config.js"; +const decodeConfig = (value: unknown) => + Schema.decodeUnknownSync(KeymapsConfigSchema, { + onExcessProperty: "error", + })(value); + describe("KeymapsConfigSchema", () => { test("validates a correct config", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [ { @@ -19,7 +24,7 @@ describe("KeymapsConfigSchema", () => { }); test("validates commands with object form", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [ { @@ -48,7 +53,7 @@ describe("KeymapsConfigSchema", () => { }); test("inherits entry-level options for string commands", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [ { @@ -70,7 +75,7 @@ describe("KeymapsConfigSchema", () => { }); test("validates mixed string and object commands", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [ { @@ -88,15 +93,25 @@ describe("KeymapsConfigSchema", () => { }); test("allows empty keymaps array", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [], }); expect(result.keymaps).toEqual([]); }); + test("fails on unknown config keys", () => { + expect(() => + decodeConfig({ + leader: "space", + keymaps: [], + extra: true, + }), + ).toThrow(); + }); + test("resolves leader prefix into leaderKey with leader: true", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [ { key: "t", commands: ["bun run test"] }, @@ -111,7 +126,7 @@ describe("KeymapsConfigSchema", () => { }); test("mixed leader and direct keymaps", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [ { key: "t", commands: ["bun run test"] }, @@ -124,7 +139,7 @@ describe("KeymapsConfigSchema", () => { }); test("carries through optional description", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [ { @@ -141,7 +156,7 @@ describe("KeymapsConfigSchema", () => { }); test("encode round-trips correctly", () => { - const decoded = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const decoded = decodeConfig({ leader: "space", keymaps: [ { key: "t", commands: ["bun test"] }, @@ -156,7 +171,7 @@ describe("KeymapsConfigSchema", () => { test("fails on missing key", () => { expect(() => - Schema.decodeUnknownSync(KeymapsConfigSchema)({ + decodeConfig({ leader: "space", keymaps: [{ commands: ["echo"] }], }), @@ -164,7 +179,7 @@ describe("KeymapsConfigSchema", () => { }); test("allows missing commands (prefix-only node)", () => { - const result = Schema.decodeUnknownSync(KeymapsConfigSchema)({ + const result = decodeConfig({ leader: "space", keymaps: [{ key: "t", description: "Test prefix" }], }); @@ -175,7 +190,7 @@ describe("KeymapsConfigSchema", () => { test("fails when commands and prompt are both present", () => { expect(() => - Schema.decodeUnknownSync(KeymapsConfigSchema)({ + decodeConfig({ leader: "space", keymaps: [{ key: "ctrl+x", commands: ["echo"], prompt: "text" }], }), @@ -184,7 +199,7 @@ describe("KeymapsConfigSchema", () => { test("fails on empty commands array", () => { expect(() => - Schema.decodeUnknownSync(KeymapsConfigSchema)({ + decodeConfig({ leader: "space", keymaps: [{ key: "ctrl+x", commands: [] }], }), diff --git a/packages/pi-keymap/src/config.ts b/packages/pi-keymap/src/config.ts index 989e301..7d19053 100644 --- a/packages/pi-keymap/src/config.ts +++ b/packages/pi-keymap/src/config.ts @@ -1,35 +1,23 @@ +import { + type CommandEntry, + CommandEntrySchema, +} from "@vahor/shared/runner/config"; import { Schema } from "effect"; export const KeymapConfigSchemaUrl = "https://raw.githubusercontent.com/Vahor/pi-extensions/main/packages/pi-keymap/schemas/keymap.schema.json"; -const KeymapCommandStruct = Schema.Struct({ - command: Schema.String, - cwd: Schema.optional(Schema.String), - timeout: Schema.optional(Schema.Number), - print: Schema.optional(Schema.Boolean), - context: Schema.optional(Schema.Boolean), - interactive: Schema.optional(Schema.Boolean), -}); +export const KeymapCommandSchema = CommandEntrySchema; -export const KeymapCommandSchema = Schema.transform( - Schema.Union(Schema.String, KeymapCommandStruct), - KeymapCommandStruct, - { - decode: (from) => (typeof from === "string" ? { command: from } : from), - encode: (to) => to, - }, -); - -const Forbidden = Schema.optional(Schema.Never); +const DisallowedField = Schema.optional(Schema.Never); const RawKeymapCommandEntry = Schema.Struct({ key: Schema.String, description: Schema.optional(Schema.String), commands: Schema.NonEmptyArray(KeymapCommandSchema), - prompt: Forbidden, - send: Forbidden, - open: Forbidden, + prompt: DisallowedField, + send: DisallowedField, + open: DisallowedField, print: Schema.optional(Schema.Boolean), context: Schema.optional(Schema.Boolean), interactive: Schema.optional(Schema.Boolean), @@ -38,11 +26,11 @@ const RawKeymapCommandEntry = Schema.Struct({ const RawKeymapPromptEntry = Schema.Struct({ key: Schema.String, description: Schema.optional(Schema.String), - commands: Forbidden, + commands: DisallowedField, prompt: Schema.String, - print: Forbidden, - context: Forbidden, - interactive: Forbidden, + print: DisallowedField, + context: DisallowedField, + interactive: DisallowedField, send: Schema.optional(Schema.Boolean), open: Schema.optional(Schema.Boolean), }); @@ -50,23 +38,21 @@ const RawKeymapPromptEntry = Schema.Struct({ const RawKeymapGroupEntry = Schema.Struct({ key: Schema.String, description: Schema.optional(Schema.String), - commands: Forbidden, - prompt: Forbidden, - print: Forbidden, - context: Forbidden, - interactive: Forbidden, - send: Forbidden, - open: Forbidden, + commands: DisallowedField, + prompt: DisallowedField, + print: DisallowedField, + context: DisallowedField, + interactive: DisallowedField, + send: DisallowedField, + open: DisallowedField, }); -/** Raw keymap entry shape from the config file */ const RawKeymapEntry = Schema.Union( RawKeymapCommandEntry, RawKeymapPromptEntry, RawKeymapGroupEntry, ); -/** Decoded keymap entry with leader flag resolved */ const DecodedKeymapEntry = Schema.Struct({ leaderKey: Schema.String, description: Schema.optional(Schema.String), @@ -80,11 +66,61 @@ const DecodedKeymapEntry = Schema.Struct({ leader: Schema.Boolean, }); +const leaderPrefix = ""; + +function withCommandDefaults( + commands: readonly CommandEntry[] | undefined, + entry: { + print?: boolean; + context?: boolean; + interactive?: boolean; + }, +): readonly CommandEntry[] | undefined { + return commands?.map((command) => ({ + ...command, + print: command.print ?? entry.print, + context: command.context ?? entry.context, + interactive: command.interactive ?? entry.interactive, + })); +} + +function decodeKeymapEntry(km: Schema.Schema.Type) { + const leader = km.key.startsWith(leaderPrefix); + return { + leaderKey: leader ? km.key.slice(leaderPrefix.length) : km.key, + description: km.description, + commands: withCommandDefaults(km.commands, km), + prompt: km.prompt, + send: km.send, + open: km.open, + print: km.print, + context: km.context, + interactive: km.interactive, + leader, + }; +} + +type EncodableKeymapEntry = Schema.Schema.Encoded; + +function encodeKeymapEntry(km: EncodableKeymapEntry) { + return { + key: km.leader ? `${leaderPrefix}${km.leaderKey}` : km.leaderKey, + description: km.description, + commands: km.commands, + prompt: km.prompt, + send: km.send, + open: km.open, + print: km.print, + context: km.context, + interactive: km.interactive, + }; +} + export type KeymapEntry = Schema.Schema.Type; -/** Full decoded config with leader prefix resolved out */ export const KeymapsConfigSchema = Schema.transform( Schema.Struct({ + $schema: Schema.optional(Schema.String), keymaps: Schema.Array(RawKeymapEntry), leader: Schema.String, }), @@ -95,40 +131,11 @@ export const KeymapsConfigSchema = Schema.transform( { decode: (raw) => ({ leader: raw.leader, - keymaps: raw.keymaps.map((km) => { - const isLeader = km.key.startsWith(""); - return { - leaderKey: isLeader ? km.key.slice("".length) : km.key, - description: km.description, - commands: km.commands?.map((command) => ({ - ...command, - print: command.print ?? km.print, - context: command.context ?? km.context, - interactive: command.interactive ?? km.interactive, - })), - prompt: km.prompt, - send: km.send, - open: km.open, - print: km.print, - context: km.context, - interactive: km.interactive, - leader: isLeader, - }; - }), + keymaps: raw.keymaps.map(decodeKeymapEntry), }), encode: (decoded) => ({ leader: decoded.leader, - keymaps: decoded.keymaps.map((km) => ({ - key: km.leader ? `${km.leaderKey}` : km.leaderKey, - description: km.description, - commands: km.commands, - prompt: km.prompt, - send: km.send, - open: km.open, - print: km.print, - context: km.context, - interactive: km.interactive, - })), + keymaps: decoded.keymaps.map(encodeKeymapEntry), }), strict: false, }, @@ -169,5 +176,5 @@ export const EmptyKeymapsConfig = { keymaps: [], } as const; -export type KeymapCommand = Schema.Schema.Type; +export type KeymapCommand = CommandEntry; export type KeymapsConfig = Schema.Schema.Type; diff --git a/scripts/generate-config-schemas.ts b/scripts/generate-config-schemas.ts index f447e91..9a4593c 100644 --- a/scripts/generate-config-schemas.ts +++ b/scripts/generate-config-schemas.ts @@ -25,8 +25,8 @@ const allowEditorSchemaProperty = (schema: JsonSchemaRoot): JsonSchemaRoot => { return { ...schema, properties: { - $schema: schemaProperty, ...schema.properties, + $schema: schemaProperty, }, }; }; From cccb8fe3e052deb138c733f7462e4f0168544de4 Mon Sep 17 00:00:00 2001 From: Vahor Date: Sat, 23 May 2026 16:26:45 +0200 Subject: [PATCH 2/5] refactor: merge keyed config arrays by default --- lib/shared/src/config/__tests__/merge.test.ts | 60 +++++++++++++++++++ lib/shared/src/config/merge.ts | 33 +++++----- lib/shared/src/config/test/merge.test.ts | 26 -------- 3 files changed, 76 insertions(+), 43 deletions(-) create mode 100644 lib/shared/src/config/__tests__/merge.test.ts delete mode 100644 lib/shared/src/config/test/merge.test.ts diff --git a/lib/shared/src/config/__tests__/merge.test.ts b/lib/shared/src/config/__tests__/merge.test.ts new file mode 100644 index 0000000..fb986b0 --- /dev/null +++ b/lib/shared/src/config/__tests__/merge.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { mergeSettings } from "../merge"; + +describe("mergeSettings", () => { + test("merges objects recursively and lets override win", () => { + const result = mergeSettings( + { + leader: "bidule", + nested: { a: 1, b: 2 }, + }, + { + leader: "space", + nested: { b: 3 }, + }, + ); + + expect(result).toEqual({ + leader: "space", + nested: { a: 1, b: 3 }, + }); + }); + + test("merges keyed arrays by default", () => { + const result = mergeSettings( + { keymaps: [{ key: "f", value: "foo" }] }, + { keymaps: [{ key: "g", value: "bar" }] }, + ); + + expect(result).toEqual({ + keymaps: [ + { key: "f", value: "foo" }, + { key: "g", value: "bar" }, + ], + }); + }); + + test("project keyed array entries override global entries", () => { + const result = mergeSettings( + { + leader: "bidule", + keymaps: [ + { key: "f", value: "foo" }, + { key: "g", value: "bar" }, + ], + }, + { + leader: "space", + keymaps: [{ key: "f", value: "baz" }], + }, + ); + + expect(result).toEqual({ + leader: "space", + keymaps: [ + { key: "f", value: "baz" }, + { key: "g", value: "bar" }, + ], + }); + }); +}); diff --git a/lib/shared/src/config/merge.ts b/lib/shared/src/config/merge.ts index 994d3eb..6074b1f 100644 --- a/lib/shared/src/config/merge.ts +++ b/lib/shared/src/config/merge.ts @@ -10,34 +10,33 @@ const isKeyedSettingsArray = (value: unknown): value is KeyedSetting[] => Array.isArray(value) && value.every(isKeyedSetting); const mergeKeyedSettings = ( - a: KeyedSetting[], - b: KeyedSetting[], + base: KeyedSetting[], + override: KeyedSetting[], ): KeyedSetting[] => { - const result = new Map(a.map((setting) => [setting.key, setting])); - for (const setting of b) { + const result = new Map(base.map((setting) => [setting.key, setting])); + for (const setting of override) { result.set(setting.key, setting); } return Array.from(result.values()); }; export const mergeSettings = ( - a: Record, - b: Record, + base: Record, + override: Record, ): Record => { - const result = { ...a }; - for (const key of Object.keys(b)) { - const aVal = result[key]; - const bVal = b[key]; + const result = { ...base }; + for (const key of Object.keys(override)) { + const baseValue = result[key]; + const overrideValue = override[key]; if ( - key === "keymaps" && - isKeyedSettingsArray(aVal) && - isKeyedSettingsArray(bVal) + isKeyedSettingsArray(baseValue) && + isKeyedSettingsArray(overrideValue) ) { - result[key] = mergeKeyedSettings(aVal, bVal); - } else if (isRecord(aVal) && isRecord(bVal)) { - result[key] = mergeSettings(aVal, bVal); + result[key] = mergeKeyedSettings(baseValue, overrideValue); + } else if (isRecord(baseValue) && isRecord(overrideValue)) { + result[key] = mergeSettings(baseValue, overrideValue); } else { - result[key] = bVal; + result[key] = overrideValue; } } return result; diff --git a/lib/shared/src/config/test/merge.test.ts b/lib/shared/src/config/test/merge.test.ts deleted file mode 100644 index 846972f..0000000 --- a/lib/shared/src/config/test/merge.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mergeSettings } from "../merge"; - -describe("mergeSettings", () => { - test("should merge settings", () => { - const a = { - leader: "bidule", - keymaps: [ - { key: "f", value: "foo" }, - { key: "g", value: "bar" }, - ], - }; - const b = { - leader: "space", - keymaps: [{ key: "f", value: "baz" }], - }; - const result = mergeSettings(a, b); - expect(result).toEqual({ - leader: "space", - keymaps: [ - { key: "f", value: "baz" }, - { key: "g", value: "bar" }, - ], - }); - }); -}); From 815183574aec61e7161662f171a967b185a3f241 Mon Sep 17 00:00:00 2001 From: Vahor Date: Sat, 23 May 2026 16:26:53 +0200 Subject: [PATCH 3/5] refactor: split command runner internals --- lib/shared/src/runner/context.ts | 41 ++++ lib/shared/src/runner/index.ts | 272 ++++++++------------------- lib/shared/src/runner/interactive.ts | 106 +++++++++++ lib/shared/src/runner/renderer.ts | 1 - lib/shared/src/runner/spinner.ts | 39 ++++ lib/shared/src/runner/types.ts | 5 + packages/pi-hooks/src/index.ts | 2 +- 7 files changed, 267 insertions(+), 199 deletions(-) create mode 100644 lib/shared/src/runner/context.ts create mode 100644 lib/shared/src/runner/interactive.ts create mode 100644 lib/shared/src/runner/spinner.ts create mode 100644 lib/shared/src/runner/types.ts diff --git a/lib/shared/src/runner/context.ts b/lib/shared/src/runner/context.ts new file mode 100644 index 0000000..1be4829 --- /dev/null +++ b/lib/shared/src/runner/context.ts @@ -0,0 +1,41 @@ +import type { CommandResult } from "./types.js"; + +const MAX_CONTEXT_OUTPUT_LENGTH = 20_000; + +function truncateContextOutput(output: string): string { + if (output.length <= MAX_CONTEXT_OUTPUT_LENGTH) return output; + + const truncationNote = `…\n[output truncated to ${MAX_CONTEXT_OUTPUT_LENGTH} characters; showing tail]\n`; + return `${truncationNote}${output.slice( + -(MAX_CONTEXT_OUTPUT_LENGTH - truncationNote.length), + )}`; +} + +function escapeCodeFenceContent(output: string): string { + return output.replaceAll("```", "`" + "\u200b" + "``"); +} + +export function formatContextMessage( + command: string, + cwd: string, + result: CommandResult, +): string { + const sections = [`Ran \`${command}\``, `Working directory: \`${cwd}\``]; + const stdout = escapeCodeFenceContent(truncateContextOutput(result.stdout)); + const stderr = escapeCodeFenceContent(truncateContextOutput(result.stderr)); + + if (result.stdout) { + sections.push(`stdout:\n\`\`\`\n${stdout}\n\`\`\``); + } + if (result.stderr) { + sections.push(`stderr:\n\`\`\`\n${stderr}\n\`\`\``); + } + if (!result.stdout && !result.stderr) { + sections.push("(no output)"); + } + if (result.code !== 0) { + sections.push(`Command exited with code ${result.code}.`); + } + + return sections.join("\n\n"); +} diff --git a/lib/shared/src/runner/index.ts b/lib/shared/src/runner/index.ts index ca9b35d..d51fcd6 100644 --- a/lib/shared/src/runner/index.ts +++ b/lib/shared/src/runner/index.ts @@ -1,157 +1,27 @@ -import { spawn } from "node:child_process"; import { resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; +import type { CommandEntry } from "./config.js"; +import { formatContextMessage } from "./context.js"; +import { runInteractiveCommand } from "./interactive.js"; import { buildRenderedOutput, renderCommandResult } from "./renderer.js"; +import { startCommandSpinner } from "./spinner.js"; +import type { CommandResult } from "./types.js"; +export type { CommandEntry } from "./config.js"; export { registerCommandRenderer } from "./renderer.js"; +export type { CommandResult } from "./types.js"; -export interface CommandEntry { - command: string; - cwd?: string; - timeout?: number; - print?: boolean; - context?: boolean; - interactive?: boolean; -} - -const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -let spinnerId = 0; - -interface CommandResult { - stdout: string; - stderr: string; - code: number; -} - -const MAX_CONTEXT_OUTPUT_LENGTH = 20_000; - -function truncateOutput(output: string): string { - if (output.length <= MAX_CONTEXT_OUTPUT_LENGTH) return output; - - const truncationNote = `…\n[output truncated to ${MAX_CONTEXT_OUTPUT_LENGTH} characters; showing tail]\n`; - return `${truncationNote}${output.slice( - -(MAX_CONTEXT_OUTPUT_LENGTH - truncationNote.length), - )}`; -} - -function formatCommand(command: string): string { - return command.length > 60 ? `${command.slice(0, 57)}...` : command; -} - -function startCommandSpinner( - ctx: ExtensionContext, - command: string, - index: number, - silent: boolean, -): () => void { - if (!ctx.hasUI) return () => {}; - - const statusKey = `runner:${index}`; - const theme = ctx.ui.theme; - const displayCommand = silent - ? `${formatCommand(command)} (silent)` - : formatCommand(command); - const text = `${theme.fg("dim", `[${index}] `)}${theme.fg("bashMode", displayCommand)}`; - let frameIndex = 0; - - const render = (): void => { - const frame = theme.fg("accent", spinnerFrames[frameIndex] ?? "⠋"); - ctx.ui.setStatus(statusKey, `${frame} ${text}`); - frameIndex = (frameIndex + 1) % spinnerFrames.length; - }; - - render(); - const timer = setInterval(render, 100); - - return () => { - clearInterval(timer); - ctx.ui.setStatus(statusKey, undefined); +function resultFromError(error: unknown): CommandResult { + return { + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), }; } -async function runInteractiveCommand( - command: string, - cwd: string, - ctx: ExtensionContext, -): Promise { - if (!ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) { - const message = "interactive commands require pi's interactive TUI"; - return { - stdout: "", - stderr: message, - code: 1, - }; - } - - return ctx.ui.custom((tui, _theme, _kb, done) => { - const shell = process.env.SHELL || "/bin/sh"; - let completed = false; - - const finish = (result: CommandResult): void => { - if (completed) return; - completed = true; - tui.start(); - tui.requestRender(true); - done({ - ...result, - stderr: result.stderr || "interactive command output is not captured", - }); - }; - - tui.stop(); - - const child = spawn(shell, ["-c", command], { - cwd, - stdio: "inherit", - env: process.env, - }); - - child.on("error", (error) => { - finish({ code: 1, stderr: error.message, stdout: "" }); - }); - child.on("exit", (code, signal) => { - const message = signal ? `terminated by ${signal}` : undefined; - finish({ - code: code ?? (signal ? 1 : 0), - stderr: message ?? "", - stdout: "", - }); - }); - - return { render: () => [], invalidate: () => {} }; - }); -} - -function formatContextMessage( - command: string, - cwd: string, - stdout: string, - stderr: string, - code: number, -): string { - const sections = [`Ran \`${command}\``, `Working directory: \`${cwd}\``]; - const truncatedStdout = truncateOutput(stdout); - const truncatedStderr = truncateOutput(stderr); - - if (stdout) { - sections.push(`stdout:\n\`\`\`\n${truncatedStdout}\n\`\`\``); - } - if (stderr) { - sections.push(`stderr:\n\`\`\`\n${truncatedStderr}\n\`\`\``); - } - if (!stdout && !stderr) { - sections.push("(no output)"); - } - if (code !== 0) { - sections.push(`Command exited with code ${code}.`); - } - - return sections.join("\n\n"); -} - function handleCommandResult( pi: ExtensionAPI, ctx: ExtensionContext, @@ -165,31 +35,52 @@ function handleCommandResult( ): void { const { command, cwd, print, context, result } = options; - if (print || context) { - const content = - context === true - ? formatContextMessage( - command, - cwd, - result.stdout, - result.stderr, - result.code, - ) - : ""; - renderCommandResult( - pi, - ctx, - { - command, - cwd, - code: result.code, - output: buildRenderedOutput(result), - includeInContext: context === true, - silent: !print, - }, - { content, display: print }, - ); + if (!print && !context) return; + + const content = context ? formatContextMessage(command, cwd, result) : ""; + renderCommandResult( + pi, + ctx, + { + command, + cwd, + code: result.code, + output: buildRenderedOutput(result), + includeInContext: context === true, + silent: !print, + }, + { content, display: print }, + ); +} + +async function executeCommand( + entry: CommandEntry, + cwd: string, + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise<{ cwd: string; result: CommandResult }> { + const resolvedCwd = entry.cwd ? resolve(cwd, entry.cwd) : cwd; + + if (entry.interactive) { + return { + cwd: resolvedCwd, + result: await runInteractiveCommand( + entry.command, + resolvedCwd, + ctx, + entry.timeout ?? 30_000, + ), + }; } + + const shell = process.env.SHELL || "/bin/sh"; + return { + cwd: resolvedCwd, + result: await pi.exec(shell, ["-c", entry.command], { + cwd: resolvedCwd, + timeout: entry.timeout ?? 30_000, + }), + }; } export async function runCommands( @@ -197,47 +88,34 @@ export async function runCommands( cwd: string, pi: ExtensionAPI, ctx: ExtensionContext, - _label: string, ): Promise { - for (const { - command, - cwd: commandCwd, - timeout, - print = true, - context, - interactive, - } of commands) { - const index = ++spinnerId; - const resolvedCwd = commandCwd ? resolve(cwd, commandCwd) : cwd; - const shell = process.env.SHELL || "/bin/sh"; - - const stopSpinner = interactive + for (const entry of commands) { + const print = entry.print ?? true; + const stopSpinner = entry.interactive ? () => {} - : startCommandSpinner(ctx, command, index, !print); + : startCommandSpinner(ctx, entry.command, !print); try { - const result = interactive - ? await runInteractiveCommand(command, resolvedCwd, ctx) - : await pi.exec(shell, ["-c", command], { - cwd: resolvedCwd, - timeout: timeout ?? 30_000, - }); - + const { cwd: commandCwd, result } = await executeCommand( + entry, + cwd, + pi, + ctx, + ); handleCommandResult(pi, ctx, { - command, - cwd: resolvedCwd, + command: entry.command, + cwd: commandCwd, print, - context, + context: entry.context, result, }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); + } catch (error) { handleCommandResult(pi, ctx, { - command, - cwd: resolvedCwd, + command: entry.command, + cwd: entry.cwd ? resolve(cwd, entry.cwd) : cwd, print, - context, - result: { code: 1, stdout: "", stderr: message }, + context: entry.context, + result: resultFromError(error), }); } finally { stopSpinner(); diff --git a/lib/shared/src/runner/interactive.ts b/lib/shared/src/runner/interactive.ts new file mode 100644 index 0000000..3096575 --- /dev/null +++ b/lib/shared/src/runner/interactive.ts @@ -0,0 +1,106 @@ +import { spawn } from "node:child_process"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { CommandResult } from "./types.js"; + +export async function runInteractiveCommand( + command: string, + cwd: string, + ctx: ExtensionContext, + timeout = 30_000, +): Promise { + if (!ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) { + return { + stdout: "", + stderr: "interactive commands require pi's interactive TUI", + code: 1, + }; + } + + return ctx.ui.custom((tui, _theme, _kb, done) => { + const shell = process.env.SHELL || "/bin/sh"; + let completed = false; + let exited = false; + let timeoutTimer: ReturnType | undefined; + let killTimer: ReturnType | undefined; + + const clearTimeoutTimer = (): void => { + if (!timeoutTimer) return; + clearTimeout(timeoutTimer); + timeoutTimer = undefined; + }; + + const clearKillTimer = (): void => { + if (!killTimer) return; + clearTimeout(killTimer); + killTimer = undefined; + }; + + const finish = ( + result: CommandResult, + options: { clearKillTimer?: boolean } = {}, + ): void => { + if (completed) return; + completed = true; + clearTimeoutTimer(); + if (options.clearKillTimer ?? true) clearKillTimer(); + tui.start(); + tui.requestRender(true); + done({ + ...result, + stderr: result.stderr || "interactive command output is not captured", + }); + }; + + tui.stop(); + + const child = spawn(shell, ["-c", command], { + cwd, + stdio: "inherit", + env: process.env, + }); + + const killChild = (signal: NodeJS.Signals): void => { + if (child.pid === undefined || exited) return; + try { + child.kill(signal); + } catch { + // Ignore races where the child exits between the pid check and kill call. + } + }; + + if (timeout > 0) { + timeoutTimer = setTimeout(() => { + killChild("SIGTERM"); + killTimer = setTimeout(() => { + killTimer = undefined; + killChild("SIGKILL"); + }, 1000); + finish( + { + code: 124, + stderr: `interactive command timed out after ${timeout}ms`, + stdout: "", + }, + { clearKillTimer: false }, + ); + }, timeout); + } + + child.on("error", (error) => { + clearKillTimer(); + finish({ code: 1, stderr: error.message, stdout: "" }); + }); + child.on("exit", (code, signal) => { + exited = true; + clearKillTimer(); + const message = signal ? `terminated by ${signal}` : undefined; + finish({ + code: code ?? (signal ? 1 : 0), + stderr: message ?? "", + stdout: "", + }); + }); + + return { render: () => [], invalidate: () => {} }; + }); +} diff --git a/lib/shared/src/runner/renderer.ts b/lib/shared/src/runner/renderer.ts index 2876d77..030533b 100644 --- a/lib/shared/src/runner/renderer.ts +++ b/lib/shared/src/runner/renderer.ts @@ -6,7 +6,6 @@ import type { import { BashExecutionComponent } from "@earendil-works/pi-coding-agent"; const runnerCommandMessageType = "vahor.runner.command"; -export const maxRenderedOutputLength = 400; const registeredRenderers = new WeakSet(); type BashExecutionTui = ConstructorParameters[1]; diff --git a/lib/shared/src/runner/spinner.ts b/lib/shared/src/runner/spinner.ts new file mode 100644 index 0000000..df14c3b --- /dev/null +++ b/lib/shared/src/runner/spinner.ts @@ -0,0 +1,39 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +let spinnerId = 0; + +function formatCommand(command: string): string { + return command.length > 60 ? `${command.slice(0, 57)}...` : command; +} + +export function startCommandSpinner( + ctx: ExtensionContext, + command: string, + silent: boolean, +): () => void { + if (!ctx.hasUI) return () => {}; + + const index = ++spinnerId; + const statusKey = `runner:${index}`; + const theme = ctx.ui.theme; + const displayCommand = silent + ? `${formatCommand(command)} (silent)` + : formatCommand(command); + const text = `${theme.fg("dim", `[${index}] `)}${theme.fg("bashMode", displayCommand)}`; + let frameIndex = 0; + + const render = (): void => { + const frame = theme.fg("accent", spinnerFrames[frameIndex] ?? "⠋"); + ctx.ui.setStatus(statusKey, `${frame} ${text}`); + frameIndex = (frameIndex + 1) % spinnerFrames.length; + }; + + render(); + const timer = setInterval(render, 100); + + return () => { + clearInterval(timer); + ctx.ui.setStatus(statusKey, undefined); + }; +} diff --git a/lib/shared/src/runner/types.ts b/lib/shared/src/runner/types.ts new file mode 100644 index 0000000..1ab8861 --- /dev/null +++ b/lib/shared/src/runner/types.ts @@ -0,0 +1,5 @@ +export interface CommandResult { + stdout: string; + stderr: string; + code: number; +} diff --git a/packages/pi-hooks/src/index.ts b/packages/pi-hooks/src/index.ts index 2a62e5a..443e1d1 100644 --- a/packages/pi-hooks/src/index.ts +++ b/packages/pi-hooks/src/index.ts @@ -46,7 +46,7 @@ export default function (pi: ExtensionAPI) { if (!entries || entries.length === 0) continue; pi.on(eventName as Parameters[0], async (_event, ctx) => { - await runCommands(entries, cwd, pi, ctx, `Hook [${eventName}]`); + await runCommands(entries, cwd, pi, ctx); }); } } From 2ed14d5108a1e94a5efc0eb62ec8ff11a298c4b2 Mon Sep 17 00:00:00 2001 From: Vahor Date: Sat, 23 May 2026 16:27:07 +0200 Subject: [PATCH 4/5] refactor: split keymap extension internals --- packages/pi-keymap/src/actions.ts | 61 ++++++++ packages/pi-keymap/src/index.ts | 203 ++------------------------- packages/pi-keymap/src/shortcuts.ts | 80 +++++++++++ packages/pi-keymap/src/validation.ts | 92 ++++++++++++ 4 files changed, 243 insertions(+), 193 deletions(-) create mode 100644 packages/pi-keymap/src/actions.ts create mode 100644 packages/pi-keymap/src/shortcuts.ts create mode 100644 packages/pi-keymap/src/validation.ts diff --git a/packages/pi-keymap/src/actions.ts b/packages/pi-keymap/src/actions.ts new file mode 100644 index 0000000..f9b7c52 --- /dev/null +++ b/packages/pi-keymap/src/actions.ts @@ -0,0 +1,61 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { runCommands } from "@vahor/shared/runner"; +import type { KeymapEntry } from "./config.js"; + +function applyPrompt( + pi: ExtensionAPI, + ctx: ExtensionContext, + content: string, + send: boolean, +): void { + if (send) { + pi.sendUserMessage(content); + return; + } + + ctx.ui.setEditorText(content); +} + +async function runPrompt( + entry: KeymapEntry, + pi: ExtensionAPI, + ctx: ExtensionContext, +): Promise { + if (entry.prompt === undefined) return; + + if (entry.open === false) { + applyPrompt(pi, ctx, entry.prompt, entry.send ?? false); + return; + } + + const content = await ctx.ui.editor( + entry.description ?? "Editor", + entry.prompt, + ); + if (content === undefined) return; + + applyPrompt(pi, ctx, content, entry.send ?? false); +} + +export async function runEntry( + entry: KeymapEntry, + cwd: string, + pi: ExtensionAPI, + ctx: ExtensionContext, + label: string, +): Promise { + if (entry.commands?.length) { + await runCommands(entry.commands, cwd, pi, ctx); + return; + } + + if (entry.prompt !== undefined) { + await runPrompt(entry, pi, ctx); + return; + } + + ctx.ui.notify(`${label} has no commands or prompt`, "warning"); +} diff --git a/packages/pi-keymap/src/index.ts b/packages/pi-keymap/src/index.ts index 4e0c927..770bbad 100644 --- a/packages/pi-keymap/src/index.ts +++ b/packages/pi-keymap/src/index.ts @@ -1,17 +1,12 @@ -import type { - ExtensionAPI, - ExtensionContext, -} from "@earendil-works/pi-coding-agent"; -import { type KeyId, matchesKey } from "@earendil-works/pi-tui"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { FileNotFoundError, readConfig } from "@vahor/shared/config"; -import { registerCommandRenderer, runCommands } from "@vahor/shared/runner"; -import { buildTrie, type TrieNode } from "@vahor/shared/trie"; +import { registerCommandRenderer } from "@vahor/shared/runner"; import { Effect } from "effect"; -import type { KeymapEntry, KeymapsConfig } from "./config.js"; +import type { KeymapsConfig } from "./config.js"; import { EmptyKeymapsConfig, KeymapsConfigSchema } from "./config.js"; -import { isValidKey, parseLeaderKeySegments } from "./keys.js"; -import { showLevel } from "./ui.js"; -import { installUiActivityTracker } from "./ui-activity.js"; +import { isValidKey } from "./keys.js"; +import { registerDirectKeymaps, registerLeaderKeymaps } from "./shortcuts.js"; +import { validateKeymaps } from "./validation.js"; function loadConfig(cwd: string): KeymapsConfig | undefined { return Effect.runSync( @@ -26,135 +21,6 @@ function loadConfig(cwd: string): KeymapsConfig | undefined { ); } -interface ValidatedKeymaps { - direct: KeymapEntry[]; - leader: KeymapEntry[]; -} - -function hasAction(entry: KeymapEntry | undefined): boolean { - return Boolean(entry?.commands?.length || entry?.prompt !== undefined); -} - -function getEntryLabel(entry: KeymapEntry): string { - return ( - entry.description ?? entry.commands?.[0]?.command ?? entry.prompt ?? "" - ); -} - -function validateKeymaps( - entries: readonly KeymapEntry[], - ctx: ExtensionContext, -): ValidatedKeymaps { - const direct: KeymapEntry[] = []; - const leader: KeymapEntry[] = []; - - for (const km of entries) { - if (!km.leader) { - if (!isValidKey(km.leaderKey)) { - ctx.ui.notify(`keymap: key "${km.leaderKey}" is invalid`, "warning"); - continue; - } - if (!hasAction(km)) { - ctx.ui.notify( - `keymap: "${km.leaderKey}" has no commands or prompt (direct keymaps must have an action)`, - "warning", - ); - continue; - } - direct.push(km); - } else { - let invalid = false; - for (const segment of parseLeaderKeySegments(km.leaderKey)) { - if (!isValidKey(segment)) { - ctx.ui.notify( - `keymap: leader sub-key "${segment}" in "${km.leaderKey}" is invalid`, - "warning", - ); - invalid = true; - } - } - if (!invalid) leader.push(km); - } - } - - return { direct, leader }; -} - -function warnDeadEnds( - node: TrieNode, - path: string, - ctx: ExtensionContext, -): void { - for (const child of node.children.values()) { - const childPath = `${path}${child.segment}`; - if (!hasAction(child.payload) && child.children.size === 0) { - ctx.ui.notify( - `keymap: "${childPath}" has no commands, prompt, or children`, - "warning", - ); - } - warnDeadEnds(child, childPath, ctx); - } -} - -function applyPrompt( - pi: ExtensionAPI, - ctx: ExtensionContext, - content: string, - send: boolean, -): void { - if (send) { - pi.sendUserMessage(content); - return; - } - - ctx.ui.setEditorText(content); -} - -async function runPrompt( - entry: KeymapEntry, - pi: ExtensionAPI, - ctx: ExtensionContext, -): Promise { - if (entry.prompt === undefined) return; - - if (entry.open === false) { - if (entry.send) { - pi.sendUserMessage(entry.prompt); - } - ctx.ui.setEditorText(entry.prompt); - return; - } - - const content = await ctx.ui.editor( - entry.description ?? "Editor", - entry.prompt, - ); - if (content === undefined) return; - - applyPrompt(pi, ctx, content, entry.send ?? true); -} - -async function runEntry( - entry: KeymapEntry, - cwd: string, - pi: ExtensionAPI, - ctx: ExtensionContext, - label: string, -): Promise { - if (entry.commands?.length) { - await runCommands(entry.commands, cwd, pi, ctx, label); - return; - } - - if (entry.prompt !== undefined) { - await runPrompt(entry, pi, ctx); - return; - } - - ctx.ui.notify(`${label} has no commands or prompt`, "warning"); -} - export default function (pi: ExtensionAPI) { registerCommandRenderer(pi); @@ -169,7 +35,8 @@ export default function (pi: ExtensionAPI) { return; } - if (!isValidKey(config.leader)) { + const leaderIsValid = isValidKey(config.leader); + if (!leaderIsValid) { ctx.ui.notify( `keymap: leader key "${config.leader}" is invalid`, "warning", @@ -178,57 +45,7 @@ export default function (pi: ExtensionAPI) { const { direct, leader } = validateKeymaps(config.keymaps, ctx); - const { root, conflicts } = buildTrie( - leader.map((k) => ({ - key: k.leaderKey, - segments: parseLeaderKeySegments(k.leaderKey), - payload: k, - })), - ); - - warnDeadEnds(root, "", ctx); - - for (const km of direct) { - pi.registerShortcut(km.leaderKey as KeyId, { - description: getEntryLabel(km), - handler: () => runEntry(km, cwd, pi, ctx, "keymap"), - }); - } - - for (const conflict of conflicts) { - ctx.ui.notify(conflict, "warning"); - } - - if (root.children.size === 0) return; - - let leaderPressed = false; - const isUiActive = installUiActivityTracker(ctx.ui); - - const unsubscribe = ctx.ui.onTerminalInput((data: string) => { - if (leaderPressed) return; - if (!matchesKey(data, config.leader as KeyId)) return; - if (isUiActive()) return; - // Only trigger when editor is empty (not while typing) - if (ctx.hasUI && ctx.ui.getEditorText().trim() !== "") return; - - leaderPressed = true; - showLevel({ - ctx, - node: root, - prefixPath: "", - leaderKey: config.leader, - hasAction, - getEntryLabel, - onRun: (entry, label) => runEntry(entry, cwd, pi, ctx, label), - onDone: () => { - leaderPressed = false; - }, - }); - return { consume: true }; - }); - - pi.on("session_shutdown", () => { - unsubscribe(); - }); + registerDirectKeymaps(direct, cwd, pi, ctx); + if (leaderIsValid) registerLeaderKeymaps(leader, config, cwd, pi, ctx); }); } diff --git a/packages/pi-keymap/src/shortcuts.ts b/packages/pi-keymap/src/shortcuts.ts new file mode 100644 index 0000000..aaa7121 --- /dev/null +++ b/packages/pi-keymap/src/shortcuts.ts @@ -0,0 +1,80 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { type KeyId, matchesKey } from "@earendil-works/pi-tui"; +import { buildTrie } from "@vahor/shared/trie"; +import { runEntry } from "./actions.js"; +import type { KeymapEntry, KeymapsConfig } from "./config.js"; +import { parseLeaderKeySegments } from "./keys.js"; +import { showLevel } from "./ui.js"; +import { installUiActivityTracker } from "./ui-activity.js"; +import { getEntryLabel, hasAction, warnDeadEnds } from "./validation.js"; + +export function registerDirectKeymaps( + entries: readonly KeymapEntry[], + cwd: string, + pi: ExtensionAPI, + ctx: ExtensionContext, +): void { + for (const entry of entries) { + pi.registerShortcut(entry.leaderKey as KeyId, { + description: getEntryLabel(entry), + handler: () => runEntry(entry, cwd, pi, ctx, "keymap"), + }); + } +} + +export function registerLeaderKeymaps( + entries: readonly KeymapEntry[], + config: KeymapsConfig, + cwd: string, + pi: ExtensionAPI, + ctx: ExtensionContext, +): void { + const { root, conflicts } = buildTrie( + entries.map((entry) => ({ + key: entry.leaderKey, + segments: parseLeaderKeySegments(entry.leaderKey), + payload: entry, + })), + ); + + warnDeadEnds(root, "", ctx); + + for (const conflict of conflicts) { + ctx.ui.notify(conflict, "warning"); + } + + if (root.children.size === 0) return; + + let leaderPressed = false; + const isUiActive = installUiActivityTracker(ctx.ui); + + const unsubscribe = ctx.ui.onTerminalInput((data: string) => { + if (leaderPressed) return; + if (!matchesKey(data, config.leader as KeyId)) return; + if (isUiActive()) return; + // Only trigger when editor is empty (not while typing) + if (ctx.hasUI && ctx.ui.getEditorText().trim() !== "") return; + + leaderPressed = true; + showLevel({ + ctx, + node: root, + prefixPath: "", + leaderKey: config.leader, + hasAction, + getEntryLabel, + onRun: (entry, label) => runEntry(entry, cwd, pi, ctx, label), + onDone: () => { + leaderPressed = false; + }, + }); + return { consume: true }; + }); + + pi.on("session_shutdown", () => { + unsubscribe(); + }); +} diff --git a/packages/pi-keymap/src/validation.ts b/packages/pi-keymap/src/validation.ts new file mode 100644 index 0000000..ca71184 --- /dev/null +++ b/packages/pi-keymap/src/validation.ts @@ -0,0 +1,92 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { TrieNode } from "@vahor/shared/trie"; +import type { KeymapEntry } from "./config.js"; +import { isValidKey, parseLeaderKeySegments } from "./keys.js"; + +export interface ValidatedKeymaps { + direct: KeymapEntry[]; + leader: KeymapEntry[]; +} + +export function hasAction(entry: KeymapEntry | undefined): boolean { + return Boolean(entry?.commands?.length || entry?.prompt !== undefined); +} + +export function getEntryLabel(entry: KeymapEntry): string { + return ( + entry.description ?? entry.commands?.[0]?.command ?? entry.prompt ?? "" + ); +} + +export function validateKeymaps( + entries: readonly KeymapEntry[], + ctx: ExtensionContext, +): ValidatedKeymaps { + const direct: KeymapEntry[] = []; + const leader: KeymapEntry[] = []; + + for (const entry of entries) { + if (entry.leader) { + if (isValidLeaderEntry(entry, ctx)) leader.push(entry); + continue; + } + + if (isValidDirectEntry(entry, ctx)) direct.push(entry); + } + + return { direct, leader }; +} + +export function warnDeadEnds( + node: TrieNode, + path: string, + ctx: ExtensionContext, +): void { + for (const child of node.children.values()) { + const childPath = `${path}${child.segment}`; + if (!hasAction(child.payload) && child.children.size === 0) { + ctx.ui.notify( + `keymap: "${childPath}" has no commands, prompt, or children`, + "warning", + ); + } + warnDeadEnds(child, childPath, ctx); + } +} + +function isValidDirectEntry( + entry: KeymapEntry, + ctx: ExtensionContext, +): boolean { + if (!isValidKey(entry.leaderKey)) { + ctx.ui.notify(`keymap: key "${entry.leaderKey}" is invalid`, "warning"); + return false; + } + + if (!hasAction(entry)) { + ctx.ui.notify( + `keymap: "${entry.leaderKey}" has no commands or prompt (direct keymaps must have an action)`, + "warning", + ); + return false; + } + + return true; +} + +function isValidLeaderEntry( + entry: KeymapEntry, + ctx: ExtensionContext, +): boolean { + let valid = true; + for (const segment of parseLeaderKeySegments(entry.leaderKey)) { + if (!isValidKey(segment)) { + ctx.ui.notify( + `keymap: leader sub-key "${segment}" in "${entry.leaderKey}" is invalid`, + "warning", + ); + valid = false; + } + } + return valid; +} From 38d7e52749b14786f1bff8def0d0296687c104b8 Mon Sep 17 00:00:00 2001 From: Vahor Date: Sat, 23 May 2026 16:27:11 +0200 Subject: [PATCH 5/5] docs: document config merge and timeout behavior --- .changeset/maintainability-refactor.md | 6 ++++++ packages/pi-hooks/README.md | 6 +++--- packages/pi-keymap/README.md | 10 +++++----- 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 .changeset/maintainability-refactor.md diff --git a/.changeset/maintainability-refactor.md b/.changeset/maintainability-refactor.md new file mode 100644 index 0000000..39f151a --- /dev/null +++ b/.changeset/maintainability-refactor.md @@ -0,0 +1,6 @@ +--- +"@vahor/pi-hooks": patch +"@vahor/pi-keymap": patch +--- + +Refactor shared command config and runner/keymap internals for maintainability, with stricter config validation. diff --git a/packages/pi-hooks/README.md b/packages/pi-hooks/README.md index 69e1fd4..02b93f8 100644 --- a/packages/pi-hooks/README.md +++ b/packages/pi-hooks/README.md @@ -35,12 +35,12 @@ Each entry can be a string (just the command) or an object: |-------|------|----------|---------|-------------| | `command` | `string` | Yes | – | Shell command to run | | `cwd` | `string` | No | project root | Working directory (relative) | -| `timeout` | `number` | No | `30000` captured, none interactive | Timeout in milliseconds | -| `print` | `boolean` | No | `true` | Log stdout as info notification on success | +| `timeout` | `number` | No | `30000` | Timeout in milliseconds | +| `print` | `boolean` | No | `true` | Render command output in pi | | `context` | `boolean` | No | `false` | Add command output to the agent context | | `interactive` | `boolean` | No | `false` | Suspend pi's TUI and run with full terminal access. Use for commands like `lazygit`, `vim`, `htop`, or `fzf`. Output is not captured. | -Errors show as UI notifications with exit code and truncated output. Interactive commands restore and fully redraw pi after they exit. +Command output is rendered in pi when `print` or `context` is enabled. Interactive commands restore and fully redraw pi after they exit. ## Available hooks diff --git a/packages/pi-keymap/README.md b/packages/pi-keymap/README.md index 4b51b9b..af9b1b8 100644 --- a/packages/pi-keymap/README.md +++ b/packages/pi-keymap/README.md @@ -10,7 +10,7 @@ pi install npm:@vahor/pi-keymap ## Config -Configuration is loaded from both locations and merged recursively: +Configuration is loaded from both locations and merged recursively. `keymaps` arrays are merged by `key`, so project entries replace global entries with the same key: | Location | Scope | |----------|-------| @@ -64,7 +64,7 @@ If neither file exists, the extension creates `.pi/keymap.json` with an empty co | `keymaps[].commands` | `array` | No | Shell commands to run. Required for command keymaps. | | `keymaps[].prompt` | `string` | No | Default prompt text. | | `keymaps[].send` | `boolean` | No | For prompt keymaps, send saved content immediately instead of placing it in the input editor. Defaults `false` | -| `keymaps[].open` | `boolean` | No | For prompt keymaps, open editor before using the prompt. Defaults `true`; `false` places the prompt in the input area directly. | +| `keymaps[].open` | `boolean` | No | For prompt keymaps, open editor before using the prompt. Defaults `true`; `false` uses the prompt immediately according to `send`. | | `keymaps[].print` | `boolean` | No | Print command output in the UI. Defaults `true` | | `keymaps[].context` | `boolean` | No | Add command output to the agent context | | `keymaps[].interactive` | `boolean` | No | Default interactive mode for commands in this keymap | @@ -96,12 +96,12 @@ Each entry can be a string (just the command) or an object: |-------|------|----------|---------|-------------| | `command` | `string` | Yes | – | Shell command to run | | `cwd` | `string` | No | project root | Working directory (relative) | -| `timeout` | `number` | No | `30000` captured, none interactive | Timeout in milliseconds | -| `print` | `boolean` | No | `true` | Log stdout as info notification on success | +| `timeout` | `number` | No | `30000` | Timeout in milliseconds | +| `print` | `boolean` | No | `true` | Render command output in pi | | `context` | `boolean` | No | `false` | Add command output to the agent context | | `interactive` | `boolean` | No | `false` | Suspend pi's TUI and run with full terminal access. Use for commands like `lazygit`, `vim`, `htop`, or `fzf`. Output is not captured. | -Errors show as UI notifications with exit code and truncated output. Interactive commands restore and fully redraw pi after they exit. +Command output is rendered in pi when `print` or `context` is enabled. Interactive commands restore and fully redraw pi after they exit. ## Leader key & which-key overlay