@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
@@ -86,6 +86,8 @@ export interface InteractiveModeContext {
86
86
  toolOutputExpanded: boolean;
87
87
  todoExpanded: boolean;
88
88
  planModeEnabled: boolean;
89
+ loopModeEnabled: boolean;
90
+ loopPrompt?: string;
89
91
  planModePlanFilePath?: string;
90
92
  hideThinkingBlock: boolean;
91
93
  pendingImages: ImageContent[];
@@ -106,6 +108,7 @@ export interface InteractiveModeContext {
106
108
  unsubscribe?: () => void;
107
109
  onInputCallback?: (input: SubmittedUserInput) => void;
108
110
  optimisticUserMessageSignature: string | undefined;
111
+ locallySubmittedUserSignatures: Set<string>;
109
112
  lastSigintTime: number;
110
113
  lastEscapeTime: number;
111
114
  shutdownRequested: boolean;
@@ -233,6 +236,8 @@ export interface InteractiveModeContext {
233
236
  openExternalEditor(): void;
234
237
  registerExtensionShortcuts(): void;
235
238
  handlePlanModeCommand(initialPrompt?: string): Promise<void>;
239
+ handleLoopCommand(prompt?: string): Promise<void>;
240
+ disableLoopMode(options?: { silent?: boolean }): void;
236
241
  handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void>;
237
242
 
238
243
  // Hook UI methods
@@ -0,0 +1,16 @@
1
+ Run a recipe / script / target from the project's task runners.
2
+
3
+ <instruction>
4
+ - `op` is a single string: task name plus any args, e.g. `{op: "test"}` or `{op: "build --release"}`.
5
+ - In monorepos, package and Cargo target tasks are namespaced with `/`, e.g. `{op: "pkg-a/test"}` or `{op: "crate/bin/server"}`.
6
+ {{#if hasMultipleRunners}}- When the same task name exists in more than one runner, prefix with the runner id, e.g. `{op: "{{ambiguityExampleRunner}}:{{ambiguityExampleTask}}"}`. The available runner ids are: {{#each runners}}`{{id}}`{{#unless @last}}, {{/unless}}{{/each}}.
7
+ {{/if}}- Runs in the session's cwd. Output and exit code are returned in the same shape as `bash`.
8
+ </instruction>
9
+
10
+ {{#each runners}}
11
+ <runner id="{{id}}" label="{{label}}" command="{{commandPrefix}}">
12
+ {{#each tasks}}
13
+ - `{{name}}{{#if paramSig}} {{paramSig}}{{/if}}`{{#if doc}} — {{doc}}{{/if}}{{#if command}} (`{{command}}`){{/if}}
14
+ {{/each}}
15
+ </runner>
16
+ {{/each}}
@@ -121,6 +121,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
121
121
  runtime.ctx.editor.setText("");
122
122
  },
123
123
  },
124
+ {
125
+ name: "loop",
126
+ description: "Loop the agent: re-submit the same prompt every time it yields (Esc to stop)",
127
+ inlineHint: "<prompt>",
128
+ allowArgs: true,
129
+ handle: async (command, runtime) => {
130
+ await runtime.ctx.handleLoopCommand(command.args || undefined);
131
+ runtime.ctx.editor.setText("");
132
+ },
133
+ },
124
134
  {
125
135
  name: "model",
126
136
  aliases: ["models"],
package/src/tools/bash.ts CHANGED
@@ -222,15 +222,6 @@ function extractPartialBashEnv(partialJson: string | undefined): Record<string,
222
222
  return Object.keys(env).length > 0 ? env : undefined;
223
223
  }
224
224
 
225
- function getBashEnvForDisplay(args: BashRenderArgs): Record<string, string> | undefined {
226
- // During streaming, partial-json parsing often does not surface env values until the object closes.
227
- // Recover them from the raw JSON buffer so the pending bash preview can show `NAME="..." cmd` immediately,
228
- // instead of rendering only the command and making the env assignment appear at the very end.
229
- const partialEnv = extractPartialBashEnv(args.__partialJson);
230
- if (partialEnv && args.env) return { ...partialEnv, ...args.env };
231
- return args.env ?? partialEnv;
232
- }
233
-
234
225
  function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutSec: number): string | undefined {
235
226
  return requestedTimeoutSec !== effectiveTimeoutSec
236
227
  ? `Timeout clamped to ${effectiveTimeoutSec}s (requested ${requestedTimeoutSec}s; allowed range ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}s).`
@@ -688,8 +679,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
688
679
  // =============================================================================
689
680
  // TUI Renderer
690
681
  // =============================================================================
691
-
692
- interface BashRenderArgs {
682
+ export interface BashRenderArgs {
693
683
  command?: string;
694
684
  env?: Record<string, string>;
695
685
  timeout?: number;
@@ -698,7 +688,7 @@ interface BashRenderArgs {
698
688
  [key: string]: unknown;
699
689
  }
700
690
 
701
- interface BashRenderContext {
691
+ export interface BashRenderContext {
702
692
  /** Raw output text */
703
693
  output?: string;
704
694
  /** Whether output came from artifact storage */
@@ -711,7 +701,29 @@ interface BashRenderContext {
711
701
  timeout?: number;
712
702
  }
713
703
 
714
- function formatBashCommand(args: BashRenderArgs): string {
704
+ export interface ShellRendererConfig<TArgs> {
705
+ resolveTitle: (args: TArgs | undefined, options: RenderResultOptions) => string;
706
+ resolveCommand?: (args: TArgs | undefined) => string | undefined;
707
+ resolveCwd?: (args: TArgs | undefined) => string | undefined;
708
+ resolveEnv?: (args: TArgs | undefined) => Record<string, string> | undefined;
709
+ }
710
+
711
+ function getPartialJson<TArgs>(args: TArgs | undefined): string | undefined {
712
+ if (!args || typeof args !== "object" || !("__partialJson" in args)) return undefined;
713
+ const value = (args as { __partialJson?: unknown }).__partialJson;
714
+ return typeof value === "string" ? value : undefined;
715
+ }
716
+
717
+ export function getBashEnvForDisplay(args: BashRenderArgs): Record<string, string> | undefined {
718
+ // During streaming, partial-json parsing often does not surface env values until the object closes.
719
+ // Recover them from the raw JSON buffer so the pending bash preview can show `NAME="..." cmd` immediately,
720
+ // instead of rendering only the command and making the env assignment appear at the very end.
721
+ const partialEnv = extractPartialBashEnv(args.__partialJson);
722
+ if (partialEnv && args.env) return { ...partialEnv, ...args.env };
723
+ return args.env ?? partialEnv;
724
+ }
725
+
726
+ export function formatBashCommand(args: BashRenderArgs): string {
715
727
  const command = replaceTabs(args.command || "…");
716
728
  const prompt = "$";
717
729
  const cwd = getProjectDir();
@@ -720,113 +732,135 @@ function formatBashCommand(args: BashRenderArgs): string {
720
732
  return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`;
721
733
  }
722
734
 
723
- export const bashToolRenderer = {
724
- renderCall(args: BashRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
725
- const cmdText = formatBashCommand(args);
726
- const text = renderStatusLine({ icon: "pending", title: "Bash", description: cmdText }, uiTheme);
727
- return new Text(text, 0, 0);
728
- },
729
-
730
- renderResult(
731
- result: {
732
- content: Array<{ type: string; text?: string }>;
733
- details?: BashToolDetails;
734
- isError?: boolean;
735
+ function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
736
+ return {
737
+ command: config.resolveCommand?.(args),
738
+ cwd: config.resolveCwd?.(args),
739
+ env: config.resolveEnv?.(args),
740
+ __partialJson: getPartialJson(args),
741
+ };
742
+ }
743
+
744
+ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
745
+ return {
746
+ renderCall(args: TArgs, options: RenderResultOptions, uiTheme: Theme): Component {
747
+ const renderArgs = toBashRenderArgs(args, config);
748
+ const cmdText = formatBashCommand(renderArgs);
749
+ const title = config.resolveTitle(args, options);
750
+ const text = renderStatusLine({ icon: "pending", title, description: cmdText }, uiTheme);
751
+ return new Text(text, 0, 0);
735
752
  },
736
- options: RenderResultOptions & { renderContext?: BashRenderContext },
737
- uiTheme: Theme,
738
- args?: BashRenderArgs,
739
- ): Component {
740
- const cmdText = args ? formatBashCommand(args) : undefined;
741
- const isError = result.isError === true;
742
- const icon = options.isPartial ? "pending" : isError ? "error" : "success";
743
- const header = renderStatusLine({ icon, title: "Bash" }, uiTheme);
744
- const details = result.details;
745
- const outputBlock = new CachedOutputBlock();
746
753
 
747
- return {
748
- render: (width: number): string[] => {
749
- // REACTIVE: read mutable options at render time
750
- const { renderContext } = options;
751
- const expanded = renderContext?.expanded ?? options.expanded;
752
- const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
753
-
754
- // Get output from context (preferred) or fall back to result content
755
- const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
756
- const displayOutput = output.trimEnd();
757
- const showingFullOutput = expanded && renderContext?.isFullOutput === true;
758
-
759
- // Build truncation warning
760
- const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
761
- const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
762
- const timeoutLabel =
763
- typeof timeoutSeconds === "number"
764
- ? requestedTimeoutSeconds !== undefined && requestedTimeoutSeconds !== timeoutSeconds
765
- ? `Timeout: ${timeoutSeconds}s (requested ${requestedTimeoutSeconds}s clamped)`
766
- : `Timeout: ${timeoutSeconds}s`
767
- : undefined;
768
- const timeoutLine =
769
- timeoutLabel !== undefined
770
- ? uiTheme.fg("dim", `${uiTheme.format.bracketLeft}${timeoutLabel}${uiTheme.format.bracketRight}`)
771
- : undefined;
772
- let warningLine: string | undefined;
773
- if (details?.meta?.truncation && !showingFullOutput) {
774
- warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
775
- }
754
+ renderResult(
755
+ result: {
756
+ content: Array<{ type: string; text?: string }>;
757
+ details?: BashToolDetails;
758
+ isError?: boolean;
759
+ },
760
+ options: RenderResultOptions & { renderContext?: BashRenderContext },
761
+ uiTheme: Theme,
762
+ args?: TArgs,
763
+ ): Component {
764
+ const renderArgs = toBashRenderArgs(args, config);
765
+ const cmdText = args ? formatBashCommand(renderArgs) : undefined;
766
+ const isError = result.isError === true;
767
+ const icon = options.isPartial ? "pending" : isError ? "error" : "success";
768
+ const title = config.resolveTitle(args, options);
769
+ const header = renderStatusLine({ icon, title }, uiTheme);
770
+ const details = result.details;
771
+ const outputBlock = new CachedOutputBlock();
772
+
773
+ return {
774
+ render: (width: number): string[] => {
775
+ // REACTIVE: read mutable options at render time
776
+ const { renderContext } = options;
777
+ const expanded = renderContext?.expanded ?? options.expanded;
778
+ const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
779
+
780
+ // Get output from context (preferred) or fall back to result content
781
+ const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
782
+ const displayOutput = output.trimEnd();
783
+ const showingFullOutput = expanded && renderContext?.isFullOutput === true;
784
+
785
+ // Build truncation warning
786
+ const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
787
+ const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
788
+ const timeoutLabel =
789
+ typeof timeoutSeconds === "number"
790
+ ? requestedTimeoutSeconds !== undefined && requestedTimeoutSeconds !== timeoutSeconds
791
+ ? `Timeout: ${timeoutSeconds}s (requested ${requestedTimeoutSeconds}s clamped)`
792
+ : `Timeout: ${timeoutSeconds}s`
793
+ : undefined;
794
+ const timeoutLine =
795
+ timeoutLabel !== undefined
796
+ ? uiTheme.fg("dim", `${uiTheme.format.bracketLeft}${timeoutLabel}${uiTheme.format.bracketRight}`)
797
+ : undefined;
798
+ let warningLine: string | undefined;
799
+ if (details?.meta?.truncation && !showingFullOutput) {
800
+ warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
801
+ }
776
802
 
777
- const outputLines: string[] = [];
778
- const hasOutput = displayOutput.trim().length > 0;
779
- const rawOutputLines = displayOutput.split("\n");
780
- const sixelLineMask =
781
- TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
782
- const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
783
- if (hasOutput) {
784
- if (hasSixelOutput) {
785
- outputLines.push(
786
- ...rawOutputLines.map((line, index) =>
787
- sixelLineMask?.[index] ? line : uiTheme.fg("toolOutput", replaceTabs(line)),
788
- ),
789
- );
790
- } else if (expanded) {
791
- outputLines.push(...rawOutputLines.map(line => uiTheme.fg("toolOutput", replaceTabs(line))));
792
- } else {
793
- const styledOutput = rawOutputLines
794
- .map(line => uiTheme.fg("toolOutput", replaceTabs(line)))
795
- .join("\n");
796
- const textContent = styledOutput;
797
- const result = truncateToVisualLines(textContent, previewLines, width);
798
- if (result.skippedCount > 0) {
803
+ const outputLines: string[] = [];
804
+ const hasOutput = displayOutput.trim().length > 0;
805
+ const rawOutputLines = displayOutput.split("\n");
806
+ const sixelLineMask =
807
+ TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
808
+ const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
809
+ if (hasOutput) {
810
+ if (hasSixelOutput) {
799
811
  outputLines.push(
800
- uiTheme.fg(
801
- "dim",
802
- `… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`,
812
+ ...rawOutputLines.map((line, index) =>
813
+ sixelLineMask?.[index] ? line : uiTheme.fg("toolOutput", replaceTabs(line)),
803
814
  ),
804
815
  );
816
+ } else if (expanded) {
817
+ outputLines.push(...rawOutputLines.map(line => uiTheme.fg("toolOutput", replaceTabs(line))));
818
+ } else {
819
+ const styledOutput = rawOutputLines
820
+ .map(line => uiTheme.fg("toolOutput", replaceTabs(line)))
821
+ .join("\n");
822
+ const textContent = styledOutput;
823
+ const result = truncateToVisualLines(textContent, previewLines, width);
824
+ if (result.skippedCount > 0) {
825
+ outputLines.push(
826
+ uiTheme.fg(
827
+ "dim",
828
+ `… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`,
829
+ ),
830
+ );
831
+ }
832
+ outputLines.push(...result.visualLines);
805
833
  }
806
- outputLines.push(...result.visualLines);
807
834
  }
808
- }
809
- if (timeoutLine) outputLines.push(timeoutLine);
810
- if (warningLine) outputLines.push(warningLine);
811
-
812
- return outputBlock.render(
813
- {
814
- header,
815
- state: options.isPartial ? "pending" : isError ? "error" : "success",
816
- sections: [
817
- { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
818
- { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
819
- ],
820
- width,
821
- },
822
- uiTheme,
823
- );
824
- },
825
- invalidate: () => {
826
- outputBlock.invalidate();
827
- },
828
- };
829
- },
830
- mergeCallAndResult: true,
831
- inline: true,
832
- };
835
+ if (timeoutLine) outputLines.push(timeoutLine);
836
+ if (warningLine) outputLines.push(warningLine);
837
+
838
+ return outputBlock.render(
839
+ {
840
+ header,
841
+ state: options.isPartial ? "pending" : isError ? "error" : "success",
842
+ sections: [
843
+ { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
844
+ { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
845
+ ],
846
+ width,
847
+ },
848
+ uiTheme,
849
+ );
850
+ },
851
+ invalidate: () => {
852
+ outputBlock.invalidate();
853
+ },
854
+ };
855
+ },
856
+ mergeCallAndResult: true,
857
+ inline: true,
858
+ };
859
+ }
860
+
861
+ export const bashToolRenderer = createShellRenderer<BashRenderArgs>({
862
+ resolveTitle: () => "Bash",
863
+ resolveCommand: args => args?.command,
864
+ resolveCwd: args => args?.cwd,
865
+ resolveEnv: args => args?.env,
866
+ });
@@ -41,6 +41,7 @@ import { RenderMermaidTool } from "./render-mermaid";
41
41
  import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
42
42
  import { ResolveTool } from "./resolve";
43
43
  import { reportFindingTool } from "./review";
44
+ import { RunCommandTool } from "./run-command";
44
45
  import { SearchTool } from "./search";
45
46
  import { SearchToolBm25Tool } from "./search-tool-bm25";
46
47
  import { loadSshTool } from "./ssh";
@@ -79,6 +80,7 @@ export * from "./render-mermaid";
79
80
  export * from "./report-tool-issue";
80
81
  export * from "./resolve";
81
82
  export * from "./review";
83
+ export * from "./run-command";
82
84
  export * from "./search";
83
85
  export * from "./search-tool-bm25";
84
86
  export * from "./ssh";
@@ -224,6 +226,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
224
226
  rewind: RewindTool.createIf,
225
227
  task: TaskTool.create,
226
228
  job: JobTool.createIf,
229
+ run_command: RunCommandTool.createIf,
227
230
  irc: IrcTool.createIf,
228
231
  todo_write: s => new TodoWriteTool(s),
229
232
  web_search: s => new WebSearchTool(s),
@@ -370,6 +373,13 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
370
373
  ) {
371
374
  requestedTools.push("ast_edit");
372
375
  }
376
+ if (
377
+ requestedTools.includes("bash") &&
378
+ !requestedTools.includes("run_command") &&
379
+ session.settings.get("runCommand.enabled")
380
+ ) {
381
+ requestedTools.push("run_command");
382
+ }
373
383
  }
374
384
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
375
385
  const isToolAllowed = (name: string) => {
@@ -392,6 +402,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
392
402
  if (name === "browser") return session.settings.get("browser.enabled");
393
403
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
394
404
  if (name === "irc") return session.settings.get("irc.enabled");
405
+ if (name === "run_command") return session.settings.get("runCommand.enabled");
395
406
  if (name === "task") {
396
407
  const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
397
408
  const currentDepth = session.taskDepth ?? 0;
@@ -24,6 +24,7 @@ import { notebookToolRenderer } from "./notebook";
24
24
  import { pythonToolRenderer } from "./python";
25
25
  import { readToolRenderer } from "./read";
26
26
  import { resolveToolRenderer } from "./resolve";
27
+ import { runCommandToolRenderer } from "./run-command/render";
27
28
  import { searchToolRenderer } from "./search";
28
29
  import { searchToolBm25Renderer } from "./search-tool-bm25";
29
30
  import { sshToolRenderer } from "./ssh";
@@ -48,6 +49,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
48
49
  ast_grep: astGrepToolRenderer as ToolRenderer,
49
50
  ast_edit: astEditToolRenderer as ToolRenderer,
50
51
  bash: bashToolRenderer as ToolRenderer,
52
+ run_command: runCommandToolRenderer as ToolRenderer,
51
53
  debug: debugToolRenderer as ToolRenderer,
52
54
  python: pythonToolRenderer as ToolRenderer,
53
55
  calc: calculatorToolRenderer as ToolRenderer,
@@ -0,0 +1,80 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { prompt } from "@oh-my-pi/pi-utils";
4
+ import { type Static, Type } from "@sinclair/typebox";
5
+ import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
6
+ import type { Theme } from "../../modes/theme/theme";
7
+ import runCommandDescription from "../../prompts/tools/run-command.md" with { type: "text" };
8
+ import type { ToolSession } from "..";
9
+ import { type BashRenderContext, BashTool, type BashToolDetails } from "../bash";
10
+ import { createRunCommandToolRenderer, type RunCommandRenderArgs } from "./render";
11
+ import { buildPromptModel, type DetectedRunner, resolveCommand } from "./runner";
12
+ import { RUNNERS } from "./runners";
13
+
14
+ const runCommandSchema = Type.Object({
15
+ op: Type.String({
16
+ description: 'task name and args, e.g. "test" or "build --release"',
17
+ examples: ["test", "build --release", "pkg:test --watch"],
18
+ }),
19
+ });
20
+
21
+ type RunCommandParams = Static<typeof runCommandSchema>;
22
+
23
+ type RunCommandRenderResult = {
24
+ content: Array<{ type: string; text?: string }>;
25
+ details?: BashToolDetails;
26
+ isError?: boolean;
27
+ };
28
+
29
+ export class RunCommandTool implements AgentTool<typeof runCommandSchema, BashToolDetails, Theme> {
30
+ readonly name = "run_command";
31
+ readonly label = "Run";
32
+ readonly description: string;
33
+ readonly parameters = runCommandSchema;
34
+ readonly strict = true;
35
+ readonly concurrency = "exclusive";
36
+ readonly mergeCallAndResult = true;
37
+ readonly inline = true;
38
+ readonly renderCall: (args: RunCommandRenderArgs, options: RenderResultOptions, uiTheme: Theme) => Component;
39
+ readonly renderResult: (
40
+ result: RunCommandRenderResult,
41
+ options: RenderResultOptions & { renderContext?: BashRenderContext },
42
+ uiTheme: Theme,
43
+ args?: RunCommandRenderArgs,
44
+ ) => Component;
45
+
46
+ readonly #bash: BashTool;
47
+ readonly #runners: DetectedRunner[];
48
+
49
+ constructor(session: ToolSession, runners: DetectedRunner[]) {
50
+ this.#runners = runners;
51
+ this.#bash = new BashTool(session);
52
+ this.description = prompt.render(runCommandDescription, buildPromptModel(runners));
53
+ const renderer = createRunCommandToolRenderer(runners);
54
+ this.renderCall = renderer.renderCall;
55
+ this.renderResult = renderer.renderResult;
56
+ }
57
+
58
+ static async createIf(session: ToolSession): Promise<RunCommandTool | null> {
59
+ if (!session.settings.get("runCommand.enabled")) return null;
60
+ const detected = (await Promise.all(RUNNERS.map(runner => runner.detect(session.cwd)))).filter(
61
+ (runner): runner is DetectedRunner => runner !== null && runner.tasks.length > 0,
62
+ );
63
+ if (detected.length === 0) return null;
64
+ return new RunCommandTool(session, detected);
65
+ }
66
+
67
+ async execute(
68
+ toolCallId: string,
69
+ { op }: RunCommandParams,
70
+ signal?: AbortSignal,
71
+ onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
72
+ ctx?: AgentToolContext,
73
+ ): Promise<AgentToolResult<BashToolDetails>> {
74
+ const command = resolveCommand(op, this.#runners);
75
+ return await this.#bash.execute(toolCallId, { command }, signal, onUpdate, ctx);
76
+ }
77
+ }
78
+
79
+ export * from "./runner";
80
+ export { tasksFromCargoMetadata } from "./runners/cargo";
@@ -0,0 +1,18 @@
1
+ import { createShellRenderer } from "../bash";
2
+ import type { DetectedRunner } from "./runner";
3
+ import { commandFromOp, titleFromOp } from "./runner";
4
+
5
+ export interface RunCommandRenderArgs {
6
+ op?: string;
7
+ __partialJson?: string;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export function createRunCommandToolRenderer(runners: DetectedRunner[]) {
12
+ return createShellRenderer<RunCommandRenderArgs>({
13
+ resolveTitle: args => titleFromOp(args?.op, runners),
14
+ resolveCommand: args => commandFromOp(args?.op, runners),
15
+ });
16
+ }
17
+
18
+ export const runCommandToolRenderer = createRunCommandToolRenderer([]);