@oh-my-pi/pi-coding-agent 14.5.7 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.8] - 2026-04-29
6
+ ### Breaking Changes
7
+
8
+ - Changed the task runner toggle from `just.enabled` to `runCommand.enabled`, so existing configurations using `just.enabled` must be migrated
9
+ - Removed the legacy `just` tool and replaced it with `run_command`
10
+ - Renamed the built-in tool API from `just` to `run_command`, so clients requesting/handling the old tool name must update
11
+
12
+ ### Added
13
+
14
+ - Added a new `run_command` tool that runs project tasks via a single `op` argument, auto-detecting and supporting recipes from justfiles, `package.json` scripts (including workspace packages), Cargo bin/example/test targets, Makefiles, and Taskfiles
15
+ - Added support for explicit runner-qualified tasks via `run_command` with `runnerId:task` syntax in the prompt guidance
16
+
17
+ ### Changed
18
+
19
+ - Changed automatic tool availability so requesting `bash` can now auto-include `run_command` when a supported task runner manifest is detected in the working directory
20
+ - Changed task resolution to disambiguate identical task names across multiple runners and show runner-aware command execution errors
21
+
22
+ ### Fixed
23
+
24
+ - Fixed editor draft being erased when a user message queued during streaming was eventually submitted; the queue/steer path now preserves any new prompt the user has typed since queuing, matching the existing optimistic-send protection.
25
+
5
26
  ## [14.5.7] - 2026-04-29
6
27
 
7
28
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.5.7",
4
+ "version": "14.5.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.5.7",
50
- "@oh-my-pi/pi-agent-core": "14.5.7",
51
- "@oh-my-pi/pi-ai": "14.5.7",
52
- "@oh-my-pi/pi-natives": "14.5.7",
53
- "@oh-my-pi/pi-tui": "14.5.7",
54
- "@oh-my-pi/pi-utils": "14.5.7",
49
+ "@oh-my-pi/omp-stats": "14.5.8",
50
+ "@oh-my-pi/pi-agent-core": "14.5.8",
51
+ "@oh-my-pi/pi-ai": "14.5.8",
52
+ "@oh-my-pi/pi-natives": "14.5.8",
53
+ "@oh-my-pi/pi-tui": "14.5.8",
54
+ "@oh-my-pi/pi-utils": "14.5.8",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -249,7 +249,7 @@ const ProviderDiscoverySchema = Type.Object({
249
249
  type: Type.Union([Type.Literal("ollama"), Type.Literal("llama.cpp"), Type.Literal("lm-studio")]),
250
250
  });
251
251
 
252
- const ProviderAuthSchema = Type.Union([Type.Literal("apiKey"), Type.Literal("none")]);
252
+ const ProviderAuthSchema = Type.Union([Type.Literal("apiKey"), Type.Literal("none"), Type.Literal("oauth")]);
253
253
 
254
254
  const ProviderConfigSchema = Type.Object({
255
255
  baseUrl: Type.Optional(Type.String({ minLength: 1 })),
@@ -643,6 +643,7 @@ type CustomModelOverlay = {
643
643
  compat?: Model<Api>["compat"];
644
644
  contextPromotionTarget?: string;
645
645
  premiumMultiplier?: number;
646
+ isOAuth?: boolean;
646
647
  };
647
648
 
648
649
  function mergeCustomModelHeaders(
@@ -661,6 +662,22 @@ function mergeCustomModelHeaders(
661
662
  return headers;
662
663
  }
663
664
 
665
+ /**
666
+ * Decide whether a custom-yaml model should force OAuth-style request shaping.
667
+ * - Explicit `auth: oauth` → force on.
668
+ * - Explicit `auth: apiKey` / `auth: none` → leave unset (auto-detect by key prefix).
669
+ * - No `auth` specified and `api: anthropic-messages` → default on. Custom Anthropic
670
+ * endpoints are typically Claude-Code-style proxies (e.g. CLIProxyAPI) that expect
671
+ * the cloaked request shape regardless of how the proxy itself is authenticated.
672
+ * - Otherwise → unset.
673
+ */
674
+ function resolveCustomModelIsOAuth(api: Api, providerAuth: ProviderAuthMode | undefined): boolean | undefined {
675
+ if (providerAuth === "oauth") return true;
676
+ if (providerAuth !== undefined) return undefined;
677
+ if (api === "anthropic-messages") return true;
678
+ return undefined;
679
+ }
680
+
664
681
  function buildCustomModelOverlay(
665
682
  providerName: string,
666
683
  providerBaseUrl: string,
@@ -669,6 +686,7 @@ function buildCustomModelOverlay(
669
686
  providerApiKey: string | undefined,
670
687
  authHeader: boolean | undefined,
671
688
  providerCompat: Model<Api>["compat"] | undefined,
689
+ providerAuth: ProviderAuthMode | undefined,
672
690
  modelDef: CustomModelDefinitionLike,
673
691
  ): CustomModelOverlay | undefined {
674
692
  const api = modelDef.api ?? providerApi;
@@ -689,6 +707,7 @@ function buildCustomModelOverlay(
689
707
  compat: mergeCompat(providerCompat, modelDef.compat),
690
708
  contextPromotionTarget: modelDef.contextPromotionTarget,
691
709
  premiumMultiplier: modelDef.premiumMultiplier,
710
+ isOAuth: resolveCustomModelIsOAuth(api, providerAuth),
692
711
  };
693
712
  }
694
713
 
@@ -720,6 +739,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
720
739
  compat: resolvedModel.compat,
721
740
  contextPromotionTarget: resolvedModel.contextPromotionTarget,
722
741
  premiumMultiplier: resolvedModel.premiumMultiplier,
742
+ isOAuth: resolvedModel.isOAuth,
723
743
  } as Model<Api>);
724
744
  }
725
745
 
@@ -1735,6 +1755,7 @@ export class ModelRegistry {
1735
1755
  providerConfig.apiKey,
1736
1756
  providerConfig.authHeader,
1737
1757
  providerConfig.compat,
1758
+ (providerConfig.auth as ProviderAuthMode | undefined) ?? undefined,
1738
1759
  modelDef as CustomModelDefinitionLike,
1739
1760
  );
1740
1761
  if (!model) continue;
@@ -2069,6 +2090,7 @@ export class ModelRegistry {
2069
2090
  config.apiKey,
2070
2091
  config.authHeader,
2071
2092
  config.compat,
2093
+ undefined,
2072
2094
  modelDef as CustomModelDefinitionLike,
2073
2095
  );
2074
2096
  if (!overlay) {
@@ -608,6 +608,18 @@ export const SETTINGS_SCHEMA = {
608
608
  },
609
609
  },
610
610
 
611
+ "loop.mode": {
612
+ type: "enum",
613
+ values: ["prompt", "compact", "reset"] as const,
614
+ default: "prompt",
615
+ ui: {
616
+ tab: "interaction",
617
+ label: "Loop Mode",
618
+ description: "What happens between /loop iterations before re-submitting the prompt",
619
+ submenu: true,
620
+ },
621
+ },
622
+
611
623
  // Input and startup
612
624
  doubleEscapeAction: {
613
625
  type: "enum",
@@ -1294,6 +1306,17 @@ export const SETTINGS_SCHEMA = {
1294
1306
  },
1295
1307
  },
1296
1308
 
1309
+ "runCommand.enabled": {
1310
+ type: "boolean",
1311
+ default: true,
1312
+ ui: {
1313
+ tab: "tools",
1314
+ label: "Run command",
1315
+ description:
1316
+ "Enable the run_command tool when a justfile / package.json / Cargo.toml / Makefile / Taskfile is present",
1317
+ },
1318
+ },
1319
+
1297
1320
  "inspect_image.enabled": {
1298
1321
  type: "boolean",
1299
1322
  default: false,
@@ -450,6 +450,16 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
450
450
  { value: "none", label: "None", description: "Space only" },
451
451
  { value: "ascii", label: "ASCII", description: "Greater-than signs" },
452
452
  ],
453
+ // Loop mode
454
+ "loop.mode": [
455
+ {
456
+ value: "prompt",
457
+ label: "Prompt",
458
+ description: "Re-submit the prompt as a follow-up message (current behavior)",
459
+ },
460
+ { value: "compact", label: "Compact", description: "Compact the session context, then re-submit the prompt" },
461
+ { value: "reset", label: "Reset", description: "Start a new session, then re-submit the prompt" },
462
+ ],
453
463
  };
454
464
 
455
465
  function createSubmenuSettingDef(base: Omit<SettingDef, "type" | "options">, provider: OptionProvider): SettingDef {
@@ -174,18 +174,23 @@ export class EventController {
174
174
 
175
175
  this.#resetReadGroup();
176
176
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
177
+ const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
177
178
  if (!wasOptimistic) {
178
179
  this.ctx.addMessageToChat(event.message);
179
180
  }
180
- this.ctx.optimisticUserMessageSignature = undefined;
181
-
182
- // Clear the editor only when the submission did not originate from this
183
- // session's optimistic flow (which already cleared the editor at submit
184
- // time). Clearing here on the optimistic path would race with the user
185
- // typing the next prompt while the previous large redraw lands and erase
186
- // their in-progress draft (#783).
187
- if (!event.message.synthetic && !wasOptimistic) {
188
- this.ctx.editor.setText("");
181
+ if (wasOptimistic) {
182
+ this.ctx.optimisticUserMessageSignature = undefined;
183
+ }
184
+
185
+ // Clear the editor only when the submission did not originate from a
186
+ // local submission (optimistic or queued-while-streaming). Both local
187
+ // paths already cleared the editor at submit time; clearing again here
188
+ // would race with the user typing the next prompt while the previous
189
+ // large redraw lands and erase their in-progress draft (#783).
190
+ if (!event.message.synthetic) {
191
+ if (!wasLocallySubmitted) {
192
+ this.ctx.editor.setText("");
193
+ }
189
194
  this.ctx.updatePendingMessagesDisplay();
190
195
  }
191
196
  this.ctx.ui.requestRender();
@@ -334,6 +334,11 @@ export class InputController {
334
334
  this.ctx.editor.setText("");
335
335
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
336
336
  this.ctx.pendingImages = [];
337
+ // Record the signature so the queued message's eventual delivery
338
+ // (a user-role `message_start` event) leaves any draft the user has
339
+ // typed since queuing intact. Same protection as #783, applied to
340
+ // the streaming/queue path.
341
+ this.ctx.locallySubmittedUserSignatures.add(`${text}\u0000${images?.length ?? 0}`);
337
342
  await this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
338
343
  this.ctx.updatePendingMessagesDisplay();
339
344
  this.ctx.ui.requestRender();
@@ -443,6 +448,7 @@ export class InputController {
443
448
  }
444
449
 
445
450
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
451
+ this.ctx.locallySubmittedUserSignatures.clear();
446
452
  const { steering, followUp } = this.ctx.session.clearQueue();
447
453
  const allQueued = [...steering, ...followUp];
448
454
  if (allQueued.length === 0) {
@@ -170,6 +170,7 @@ export class InteractiveMode implements InteractiveModeContext {
170
170
  unsubscribe?: () => void;
171
171
  onInputCallback?: (input: SubmittedUserInput) => void;
172
172
  optimisticUserMessageSignature: string | undefined = undefined;
173
+ locallySubmittedUserSignatures: Set<string> = new Set();
173
174
  #pendingSubmittedInput: SubmittedUserInput | undefined;
174
175
  lastSigintTime = 0;
175
176
  lastEscapeTime = 0;
@@ -497,14 +498,25 @@ export class InteractiveMode implements InteractiveModeContext {
497
498
  }
498
499
  if (!this.loopModeEnabled || !this.loopPrompt) return;
499
500
  const prompt = this.loopPrompt;
501
+ const loopAction = settings.get("loop.mode");
500
502
  // Brief delay so the user has a chance to press Esc between iterations.
501
503
  this.#loopAutoSubmitTimer = setTimeout(() => {
502
504
  this.#loopAutoSubmitTimer = undefined;
503
505
  if (!this.loopModeEnabled || !this.onInputCallback) return;
504
- this.onInputCallback(this.startPendingSubmission({ text: prompt }));
506
+ void this.#runLoopIteration(loopAction, prompt);
505
507
  }, 800);
506
508
  }
507
509
 
510
+ async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
511
+ if (action === "compact") {
512
+ await this.handleCompactCommand();
513
+ } else if (action === "reset") {
514
+ await this.handleClearCommand();
515
+ }
516
+ if (!this.loopModeEnabled || !this.onInputCallback) return;
517
+ this.onInputCallback(this.startPendingSubmission({ text: prompt }));
518
+ }
519
+
508
520
  disableLoopMode(options?: { silent?: boolean }): void {
509
521
  const wasEnabled = this.loopModeEnabled;
510
522
  this.loopModeEnabled = false;
@@ -553,6 +565,7 @@ export class InteractiveMode implements InteractiveModeContext {
553
565
  };
554
566
  this.#pendingSubmittedInput = submission;
555
567
  this.optimisticUserMessageSignature = `${submission.text}\u0000${submission.images?.length ?? 0}`;
568
+ this.locallySubmittedUserSignatures.add(this.optimisticUserMessageSignature);
556
569
  this.addMessageToChat({
557
570
  role: "user",
558
571
  content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
@@ -574,6 +587,7 @@ export class InteractiveMode implements InteractiveModeContext {
574
587
  submission.cancelled = true;
575
588
  this.#pendingSubmittedInput = undefined;
576
589
  this.optimisticUserMessageSignature = undefined;
590
+ this.locallySubmittedUserSignatures.delete(`${submission.text}\u0000${submission.images?.length ?? 0}`);
577
591
  this.#pendingWorkingMessage = undefined;
578
592
  if (this.loadingAnimation) {
579
593
  this.loadingAnimation.stop();
@@ -108,6 +108,7 @@ export interface InteractiveModeContext {
108
108
  unsubscribe?: () => void;
109
109
  onInputCallback?: (input: SubmittedUserInput) => void;
110
110
  optimisticUserMessageSignature: string | undefined;
111
+ locallySubmittedUserSignatures: Set<string>;
111
112
  lastSigintTime: number;
112
113
  lastEscapeTime: number;
113
114
  shutdownRequested: boolean;
@@ -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}}
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
+ });