@oh-my-pi/pi-coding-agent 14.5.6 → 14.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +7 -7
  3. package/src/config/model-registry.ts +23 -1
  4. package/src/config/settings-schema.ts +24 -1
  5. package/src/config/settings.ts +16 -0
  6. package/src/edit/modes/atom.ts +3 -5
  7. package/src/modes/components/hook-editor.ts +2 -2
  8. package/src/modes/components/settings-defs.ts +10 -0
  9. package/src/modes/components/status-line/presets.ts +7 -7
  10. package/src/modes/components/status-line/segments.ts +16 -10
  11. package/src/modes/components/status-line/types.ts +3 -0
  12. package/src/modes/components/status-line-segment-editor.ts +1 -1
  13. package/src/modes/components/status-line.ts +6 -0
  14. package/src/modes/controllers/event-controller.ts +14 -9
  15. package/src/modes/controllers/input-controller.ts +15 -0
  16. package/src/modes/interactive-mode.ts +72 -0
  17. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  18. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  19. package/src/modes/theme/theme.ts +6 -0
  20. package/src/modes/types.ts +5 -0
  21. package/src/prompts/tools/run-command.md +16 -0
  22. package/src/slash-commands/builtin-registry.ts +10 -0
  23. package/src/tools/bash.ts +149 -115
  24. package/src/tools/index.ts +11 -0
  25. package/src/tools/renderers.ts +2 -0
  26. package/src/tools/run-command/index.ts +80 -0
  27. package/src/tools/run-command/render.ts +18 -0
  28. package/src/tools/run-command/runner.ts +198 -0
  29. package/src/tools/run-command/runners/cargo.ts +131 -0
  30. package/src/tools/run-command/runners/index.ts +8 -0
  31. package/src/tools/run-command/runners/just.ts +73 -0
  32. package/src/tools/run-command/runners/make.ts +101 -0
  33. package/src/tools/run-command/runners/pkg.ts +195 -0
  34. package/src/tools/run-command/runners/task.ts +72 -0
@@ -0,0 +1,198 @@
1
+ import { ToolError } from "../tool-errors";
2
+
3
+ export interface RunnerTask {
4
+ name: string;
5
+ doc?: string;
6
+ /** Parameter names only; used for the `name foo bar` signature line in the description. */
7
+ parameters: string[];
8
+ /** Override for this specific task, e.g. `cargo run --package crate --bin`. */
9
+ commandPrefix?: string;
10
+ /** Token passed to the runner command; defaults to `name`. Used when display names are namespaced. */
11
+ commandName?: string;
12
+ }
13
+
14
+ export interface DetectedRunner {
15
+ id: string;
16
+ label: string;
17
+ /** Resolved shell prefix, e.g. "just" or "bun run" or "make". */
18
+ commandPrefix: string;
19
+ tasks: RunnerTask[];
20
+ }
21
+
22
+ export interface TaskRunner {
23
+ id: string;
24
+ label: string;
25
+ /**
26
+ * Probe `cwd` for the manifest, the binary, and the task list.
27
+ * Returns null when this runner does not apply.
28
+ */
29
+ detect(cwd: string): Promise<DetectedRunner | null>;
30
+ }
31
+
32
+ interface ParsedOp {
33
+ head: string;
34
+ tail: string;
35
+ }
36
+
37
+ interface PromptTaskModel {
38
+ name: string;
39
+ paramSig?: string;
40
+ command?: string;
41
+ doc?: string;
42
+ }
43
+
44
+ interface PromptRunnerModel {
45
+ id: string;
46
+ label: string;
47
+ commandPrefix: string;
48
+ tasks: PromptTaskModel[];
49
+ }
50
+
51
+ export interface RunCommandPromptModel {
52
+ [key: string]: unknown;
53
+ hasMultipleRunners: boolean;
54
+ ambiguityExampleRunner?: string;
55
+ ambiguityExampleTask?: string;
56
+ runners: PromptRunnerModel[];
57
+ }
58
+
59
+ function parseOp(op: string): ParsedOp {
60
+ const trimmedStart = op.trimStart();
61
+ if (trimmedStart.length === 0) return { head: "", tail: "" };
62
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/u.exec(trimmedStart);
63
+ return { head: match?.[1] ?? "", tail: match?.[2] ?? "" };
64
+ }
65
+
66
+ function findRunnerById(id: string, runners: DetectedRunner[]): DetectedRunner | undefined {
67
+ return runners.find(runner => runner.id === id);
68
+ }
69
+
70
+ function hasTask(runner: DetectedRunner, taskName: string): boolean {
71
+ return runner.tasks.some(task => task.name === taskName);
72
+ }
73
+
74
+ function findMatchingRunners(taskName: string, runners: DetectedRunner[]): DetectedRunner[] {
75
+ return runners.filter(runner => hasTask(runner, taskName));
76
+ }
77
+
78
+ function formatAvailableTasks(runners: DetectedRunner[]): string {
79
+ return runners
80
+ .map(runner => {
81
+ const names = runner.tasks.map(task => task.name).join(", ");
82
+ return `- ${runner.id}: ${names || "(no tasks)"}`;
83
+ })
84
+ .join("\n");
85
+ }
86
+
87
+ function formatRunnerIds(runners: DetectedRunner[]): string {
88
+ return runners.map(runner => runner.id).join(", ");
89
+ }
90
+
91
+ function buildCommand(commandPrefix: string, taskName: string, tail: string): string {
92
+ return [commandPrefix, taskName, tail]
93
+ .filter(part => part.trim().length > 0)
94
+ .join(" ")
95
+ .trim();
96
+ }
97
+
98
+ function resolveRunnerAndTask(
99
+ op: string,
100
+ runners: DetectedRunner[],
101
+ ): { runner: DetectedRunner; task: RunnerTask; tail: string } {
102
+ const { head, tail } = parseOp(op);
103
+ if (!head) {
104
+ throw new ToolError(`run_command op is empty. Available tasks:\n${formatAvailableTasks(runners)}`);
105
+ }
106
+
107
+ const colonIndex = head.indexOf(":");
108
+ if (colonIndex > 0) {
109
+ const maybeRunnerId = head.slice(0, colonIndex);
110
+ const explicitRunner = findRunnerById(maybeRunnerId, runners);
111
+ if (explicitRunner) {
112
+ const taskName = head.slice(colonIndex + 1);
113
+ const explicitTask = explicitRunner.tasks.find(task => task.name === taskName);
114
+ if (!taskName || !explicitTask) {
115
+ throw new ToolError(
116
+ `Task \`${taskName || "(empty)"}\` not found in runner \`${explicitRunner.id}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
117
+ );
118
+ }
119
+ return { runner: explicitRunner, task: explicitTask, tail };
120
+ }
121
+ }
122
+
123
+ const matches = findMatchingRunners(head, runners);
124
+ if (matches.length === 1) {
125
+ return { runner: matches[0]!, task: matches[0]!.tasks.find(task => task.name === head)!, tail };
126
+ }
127
+ if (matches.length > 1) {
128
+ const ids = matches.map(runner => runner.id).join(", ");
129
+ throw new ToolError(
130
+ `Task \`${head}\` exists in multiple runners (${ids}). Use \`<runner-id>:<task>\`, for example \`${matches[0]!.id}:${head}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
131
+ );
132
+ }
133
+
134
+ throw new ToolError(
135
+ `No runner task named \`${head}\`. Use one of the available runner ids (${formatRunnerIds(runners)}) as a prefix when needed, e.g. \`pkg:${head}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
136
+ );
137
+ }
138
+
139
+ export function resolveCommand(op: string, runners: DetectedRunner[]): string {
140
+ const { runner, task, tail } = resolveRunnerAndTask(op, runners);
141
+ return buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, tail);
142
+ }
143
+
144
+ export function commandFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
145
+ if (!op) return undefined;
146
+ try {
147
+ return resolveCommand(op, runners);
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ export function titleFromOp(op: string | undefined, runners: DetectedRunner[]): string {
154
+ if (!op) return "Run";
155
+ const { head } = parseOp(op);
156
+ if (!head) return "Run";
157
+ const colonIndex = head.indexOf(":");
158
+ if (colonIndex > 0) {
159
+ const runner = findRunnerById(head.slice(0, colonIndex), runners);
160
+ return runner?.label ?? "Run";
161
+ }
162
+ const matches = findMatchingRunners(head, runners);
163
+ return matches.length === 1 ? matches[0]!.label : "Run";
164
+ }
165
+
166
+ function findAmbiguityExample(runners: DetectedRunner[]): { runner: string; task: string } | undefined {
167
+ const seen = new Map<string, string>();
168
+ for (const runner of runners) {
169
+ for (const task of runner.tasks) {
170
+ const previousRunner = seen.get(task.name);
171
+ if (previousRunner) return { runner: previousRunner, task: task.name };
172
+ seen.set(task.name, runner.id);
173
+ }
174
+ }
175
+ const firstRunner = runners[0];
176
+ const firstTask = firstRunner?.tasks[0];
177
+ return firstRunner && firstTask ? { runner: firstRunner.id, task: firstTask.name } : undefined;
178
+ }
179
+
180
+ export function buildPromptModel(runners: DetectedRunner[]): RunCommandPromptModel {
181
+ const ambiguityExample = findAmbiguityExample(runners);
182
+ return {
183
+ hasMultipleRunners: runners.length > 1,
184
+ ambiguityExampleRunner: ambiguityExample?.runner,
185
+ ambiguityExampleTask: ambiguityExample?.task,
186
+ runners: runners.map(runner => ({
187
+ id: runner.id,
188
+ label: runner.label,
189
+ commandPrefix: runner.commandPrefix,
190
+ tasks: runner.tasks.map(task => ({
191
+ name: task.name,
192
+ paramSig: task.parameters.length > 0 ? task.parameters.join(" ") : undefined,
193
+ command: buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, ""),
194
+ doc: task.doc,
195
+ })),
196
+ })),
197
+ };
198
+ }
@@ -0,0 +1,131 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+ import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
5
+
6
+ export interface CargoMetadataTarget {
7
+ kind?: string[];
8
+ name?: string;
9
+ }
10
+
11
+ export interface CargoMetadataPackage {
12
+ id?: string;
13
+ name?: string;
14
+ targets?: CargoMetadataTarget[];
15
+ }
16
+
17
+ export interface CargoMetadata {
18
+ packages?: CargoMetadataPackage[];
19
+ workspace_members?: string[];
20
+ }
21
+
22
+ type CargoTargetKind = "bin" | "example" | "test";
23
+
24
+ async function hasCargoManifest(cwd: string): Promise<boolean> {
25
+ try {
26
+ const stat = await fs.stat(path.join(cwd, "Cargo.toml"));
27
+ return stat.isFile();
28
+ } catch (err) {
29
+ if (isEnoent(err)) return false;
30
+ throw err;
31
+ }
32
+ }
33
+
34
+ function shellQuote(value: string): string {
35
+ return `'${value.replaceAll("'", `'\\''`)}'`;
36
+ }
37
+
38
+ function cargoTargetKind(target: CargoMetadataTarget): CargoTargetKind | undefined {
39
+ if (target.kind?.includes("bin")) return "bin";
40
+ if (target.kind?.includes("example")) return "example";
41
+ if (target.kind?.includes("test")) return "test";
42
+ return undefined;
43
+ }
44
+
45
+ function commandPrefixForTarget(packageName: string, kind: CargoTargetKind): string {
46
+ const packageFlag = `--package ${shellQuote(packageName)}`;
47
+ switch (kind) {
48
+ case "bin":
49
+ return `cargo run ${packageFlag} --bin`;
50
+ case "example":
51
+ return `cargo run ${packageFlag} --example`;
52
+ case "test":
53
+ return `cargo test ${packageFlag} --test`;
54
+ }
55
+ }
56
+
57
+ function taskNameForTarget(
58
+ packageName: string,
59
+ kind: CargoTargetKind,
60
+ targetName: string,
61
+ isWorkspace: boolean,
62
+ ): string {
63
+ const category = kind === "bin" ? "bin" : kind;
64
+ return isWorkspace ? `${packageName}/${category}/${targetName}` : `${category}/${targetName}`;
65
+ }
66
+
67
+ export function tasksFromCargoMetadata(metadata: CargoMetadata): RunnerTask[] {
68
+ const workspaceMembers = new Set(metadata.workspace_members ?? []);
69
+ const workspacePackages = (metadata.packages ?? []).filter(pkg => pkg.id && workspaceMembers.has(pkg.id));
70
+ const packages = workspacePackages.length > 0 ? workspacePackages : (metadata.packages ?? []);
71
+ const isWorkspace = packages.length > 1;
72
+ const tasks: RunnerTask[] = [];
73
+ const seen = new Set<string>();
74
+
75
+ for (const pkg of packages) {
76
+ if (!pkg.name) continue;
77
+ for (const target of pkg.targets ?? []) {
78
+ if (!target.name) continue;
79
+ const kind = cargoTargetKind(target);
80
+ if (!kind) continue;
81
+ const name = taskNameForTarget(pkg.name, kind, target.name, isWorkspace);
82
+ if (seen.has(name)) continue;
83
+ seen.add(name);
84
+ tasks.push({
85
+ name,
86
+ doc: `${pkg.name} ${kind} target ${target.name}`,
87
+ parameters: [],
88
+ commandPrefix: commandPrefixForTarget(pkg.name, kind),
89
+ commandName: shellQuote(target.name),
90
+ });
91
+ }
92
+ }
93
+
94
+ return tasks;
95
+ }
96
+
97
+ async function readCargoMetadata(cwd: string): Promise<CargoMetadata | null> {
98
+ try {
99
+ const proc = Bun.spawn(["cargo", "metadata", "--no-deps", "--format-version=1"], {
100
+ cwd,
101
+ stdin: "ignore",
102
+ stdout: "pipe",
103
+ stderr: "pipe",
104
+ });
105
+ const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
106
+ if (exit !== 0) return null;
107
+ return JSON.parse(stdout) as CargoMetadata;
108
+ } catch (err) {
109
+ logger.debug("cargo metadata failed", { error: err instanceof Error ? err.message : String(err) });
110
+ return null;
111
+ }
112
+ }
113
+
114
+ export const cargoRunner: TaskRunner = {
115
+ id: "cargo",
116
+ label: "Cargo",
117
+ async detect(cwd: string): Promise<DetectedRunner | null> {
118
+ try {
119
+ if (!$which("cargo")) return null;
120
+ if (!(await hasCargoManifest(cwd))) return null;
121
+ const metadata = await readCargoMetadata(cwd);
122
+ if (!metadata) return null;
123
+ const tasks = tasksFromCargoMetadata(metadata);
124
+ if (tasks.length === 0) return null;
125
+ return { id: "cargo", label: "Cargo", commandPrefix: "cargo", tasks };
126
+ } catch (err) {
127
+ logger.debug("cargo runner probe failed", { error: err instanceof Error ? err.message : String(err) });
128
+ return null;
129
+ }
130
+ },
131
+ };
@@ -0,0 +1,8 @@
1
+ import type { TaskRunner } from "../runner";
2
+ import { cargoRunner } from "./cargo";
3
+ import { justRunner } from "./just";
4
+ import { makeRunner } from "./make";
5
+ import { pkgRunner } from "./pkg";
6
+ import { taskRunner } from "./task";
7
+
8
+ export const RUNNERS: TaskRunner[] = [justRunner, pkgRunner, cargoRunner, makeRunner, taskRunner];
@@ -0,0 +1,73 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+ import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
5
+
6
+ interface JustDumpRecipeRaw {
7
+ name?: string;
8
+ doc?: string | null;
9
+ private?: boolean;
10
+ parameters?: Array<{ name?: string }>;
11
+ }
12
+
13
+ interface JustDump {
14
+ recipes?: Record<string, JustDumpRecipeRaw>;
15
+ }
16
+
17
+ const JUSTFILE_NAMES = ["justfile", "Justfile", ".justfile"] as const;
18
+
19
+ async function hasJustfile(cwd: string): Promise<boolean> {
20
+ for (const name of JUSTFILE_NAMES) {
21
+ try {
22
+ const stat = await fs.stat(path.join(cwd, name));
23
+ if (stat.isFile()) return true;
24
+ } catch (err) {
25
+ if (!isEnoent(err)) throw err;
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+
31
+ async function dumpJustTasks(cwd: string): Promise<RunnerTask[] | null> {
32
+ try {
33
+ const proc = Bun.spawn(["just", "--dump", "--dump-format=json"], {
34
+ cwd,
35
+ stdin: "ignore",
36
+ stdout: "pipe",
37
+ stderr: "pipe",
38
+ });
39
+ const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
40
+ if (exit !== 0) return null;
41
+ const dump = JSON.parse(stdout) as JustDump;
42
+ const tasks: RunnerTask[] = [];
43
+ for (const recipe of Object.values(dump.recipes ?? {})) {
44
+ if (!recipe.name || recipe.private) continue;
45
+ const parameters = (recipe.parameters ?? [])
46
+ .map(parameter => parameter.name)
47
+ .filter((name): name is string => typeof name === "string" && name.length > 0);
48
+ const doc = typeof recipe.doc === "string" && recipe.doc.length > 0 ? recipe.doc : undefined;
49
+ tasks.push({ name: recipe.name, doc, parameters });
50
+ }
51
+ return tasks;
52
+ } catch (err) {
53
+ logger.debug("just task detection failed", { error: err instanceof Error ? err.message : String(err) });
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export const justRunner: TaskRunner = {
59
+ id: "just",
60
+ label: "Just",
61
+ async detect(cwd: string): Promise<DetectedRunner | null> {
62
+ try {
63
+ if (!$which("just")) return null;
64
+ if (!(await hasJustfile(cwd))) return null;
65
+ const tasks = await dumpJustTasks(cwd);
66
+ if (!tasks || tasks.length === 0) return null;
67
+ return { id: "just", label: "Just", commandPrefix: "just", tasks };
68
+ } catch (err) {
69
+ logger.debug("just runner probe failed", { error: err instanceof Error ? err.message : String(err) });
70
+ return null;
71
+ }
72
+ },
73
+ };
@@ -0,0 +1,101 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+ import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
5
+
6
+ const MAKEFILE_NAMES = ["Makefile", "makefile", "GNUmakefile"] as const;
7
+ const TARGET_PATTERN = /^(?<name>[A-Za-z_][A-Za-z0-9_-]*)\s*:(?!=).*?(?:##\s*(?<doc>.+))?$/u;
8
+ const PHONY_PATTERN = /^\.PHONY\s*:\s*(?<targets>.*)$/u;
9
+
10
+ interface MakeTargetInfo {
11
+ name: string;
12
+ doc?: string;
13
+ order: number;
14
+ phony: boolean;
15
+ }
16
+
17
+ async function findMakefile(cwd: string): Promise<string | null> {
18
+ for (const name of MAKEFILE_NAMES) {
19
+ const candidate = path.join(cwd, name);
20
+ try {
21
+ const stat = await fs.stat(candidate);
22
+ if (stat.isFile()) return candidate;
23
+ } catch (err) {
24
+ if (!isEnoent(err)) throw err;
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function isVariableAssignment(line: string, name: string): boolean {
31
+ return new RegExp(`^\\s*${name}\\s*[:?+]?=`, "u").test(line);
32
+ }
33
+
34
+ function parsePhonyTargets(line: string): string[] {
35
+ const match = PHONY_PATTERN.exec(line);
36
+ if (!match?.groups?.targets) return [];
37
+ return match.groups.targets
38
+ .split(/\s+/u)
39
+ .map(target => target.trim())
40
+ .filter(target => /^[A-Za-z_][A-Za-z0-9_-]*$/u.test(target));
41
+ }
42
+
43
+ function parseMakeTargets(text: string): RunnerTask[] {
44
+ const targets = new Map<string, MakeTargetInfo>();
45
+ const phonyTargets: string[] = [];
46
+ let order = 0;
47
+
48
+ for (const line of text.split("\n")) {
49
+ for (const target of parsePhonyTargets(line)) {
50
+ if (!phonyTargets.includes(target)) phonyTargets.push(target);
51
+ }
52
+
53
+ const match = TARGET_PATTERN.exec(line);
54
+ const name = match?.groups?.name;
55
+ if (!name || name === ".PHONY" || isVariableAssignment(line, name)) continue;
56
+ if (targets.has(name)) continue;
57
+ const rawDoc = match?.groups?.doc?.trim();
58
+ const doc = rawDoc && rawDoc.length > 0 ? rawDoc : undefined;
59
+ targets.set(name, { name, doc, order, phony: false });
60
+ order += 1;
61
+ }
62
+
63
+ for (const phony of phonyTargets) {
64
+ const existing = targets.get(phony);
65
+ if (existing) {
66
+ existing.phony = true;
67
+ continue;
68
+ }
69
+ targets.set(phony, { name: phony, order, phony: true });
70
+ order += 1;
71
+ }
72
+
73
+ const hasPhonyTargets = phonyTargets.length > 0;
74
+ return [...targets.values()]
75
+ .sort((left, right) => left.order - right.order)
76
+ .flatMap(target => {
77
+ if (!hasPhonyTargets || target.phony) {
78
+ return [{ name: target.name, doc: target.doc, parameters: [] }];
79
+ }
80
+ if (!target.doc) return [];
81
+ return [{ name: target.name, doc: `${target.doc} (file target)`, parameters: [] }];
82
+ });
83
+ }
84
+
85
+ export const makeRunner: TaskRunner = {
86
+ id: "make",
87
+ label: "Make",
88
+ async detect(cwd: string): Promise<DetectedRunner | null> {
89
+ try {
90
+ if (!$which("make")) return null;
91
+ const makefile = await findMakefile(cwd);
92
+ if (!makefile) return null;
93
+ const tasks = parseMakeTargets(await Bun.file(makefile).text());
94
+ if (tasks.length === 0) return null;
95
+ return { id: "make", label: "Make", commandPrefix: "make", tasks };
96
+ } catch (err) {
97
+ logger.debug("make runner probe failed", { error: err instanceof Error ? err.message : String(err) });
98
+ return null;
99
+ }
100
+ },
101
+ };