@oh-my-pi/pi-tui 18.10.3 → 18.10.4

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.10.4] - 2026-10-13
6
+
7
+ ### Added
8
+
9
+ - Added `ApprovalDialogComponent` and the `app.approval.approve` keybinding (default `Ctrl+Enter`, `Ctrl+Q`) ([#15548](https://github.com/can1357/oh-my-pi/pull/15548) by [@H4vC](https://github.com/H4vC))
10
+ - Plan review overlay can take a remote answer: it shows the remotely chosen execution tier and locks further input like a terminal approval ([#15552](https://github.com/can1357/oh-my-pi/pull/15552) by [@Shadorain](https://github.com/Shadorain))
11
+
12
+ ### Fixed
13
+
14
+ - The model browser shows the price a model is billed at now, not the one current when its row was loaded: DeepSeek V4 Pro shows its $0.30 / $1.20 card in effect since 2026-09-14 instead of $1.32 / $3.96, and Gemini 4 Argon switches to $4 / $20 on 2026-12-01 ([#15620](https://github.com/can1357/oh-my-pi/pull/15620) by [@eggpeat](https://github.com/eggpeat))
15
+ - Clicking Copy answer in the `/btw` history (and Copy raw in the raw SSE viewer) on native terminals copies the answer instead of the button's label ([#15547](https://github.com/can1357/oh-my-pi/pull/15547) by [@H4vC](https://github.com/H4vC))
16
+ - Fixed image previews inside tmux showing as `SIXEL IMAGE (WxH)` placeholder text when the outer terminal lacks SIXEL; SIXEL is now enabled from tmux's DA1 reply only when tmux's terminal description for the attached client has `Sxl` ([#15513](https://github.com/can1357/oh-my-pi/issues/15513))
17
+
18
+ ### Removed
19
+
20
+ - Removed `tools/eval-format/code-mode-declarations` (`generateCodeModeDeclarations`), which only served Codex Code Mode.
21
+
5
22
  ## [18.10.3] - 2026-10-12
6
23
 
7
24
  ### Changed
@@ -2740,46 +2757,4 @@
2740
2757
  - Migrated key parsing and matching logic to native implementation for improved performance
2741
2758
  - Simplified `isKeyRelease()` and `isKeyRepeat()` to use regex pattern matching instead of string inclusion checks
2742
2759
 
2743
- ## [9.6.2] - 2026-02-01
2744
-
2745
- ### Changed
2746
-
2747
- - Renamed `EllipsisKind` enum to `Ellipsis` for clearer API naming
2748
- - Changed hardcoded ellipsis character from theme-configurable to literal "…" in editor truncation
2749
- - Refactored `visibleWidth` function to use caching wrapper around new `visibleWidthRaw` implementation for improved performance
2750
-
2751
- ### Removed
2752
-
2753
- - Removed `truncateToWidth`, `sliceWithWidth`, and `extractSegments` functions from public API (now re-exported directly from @oh-my-pi/pi-natives)
2754
- - Removed `ellipsis` property from `SymbolTheme` interface
2755
- - Removed `extractAnsiCode` function from public API
2756
-
2757
- ## [9.6.1] - 2026-02-01
2758
-
2759
- ### Changed
2760
-
2761
- - Improved performance of key ID parsing with optimized cache lookup strategy
2762
- - Simplified `visibleWidth` calculation to use consistent Bun.stringWidth approach for all string lengths
2763
-
2764
- ### Removed
2765
-
2766
- - Removed `visibleWidth` benchmark file in favor of Kitty sequence benchmarking
2767
-
2768
- ## [9.5.0] - 2026-02-01
2769
-
2770
- ### Changed
2771
-
2772
- - Improved fuzzy file search performance by using native implementation instead of spawning external process
2773
- - Replaced external `fd` binary with native fuzzy path search for `@`-prefixed autocomplete
2774
-
2775
- ## [9.4.0] - 2026-01-31
2776
-
2777
- ### Added
2778
-
2779
- - Exported `padding` utility function for creating space-padded strings efficiently
2780
-
2781
- ### Changed
2782
-
2783
- - Optimized padding operations across all components to use pre-allocated space buffer for better performance
2784
-
2785
- Older entries are archived in [packages/tui/CHANGELOG.md@e19bb3cb2344](https://github.com/can1357/oh-my-pi/blob/e19bb3cb2344a8953dc04aa398b810230f0e24c3/packages/tui/CHANGELOG.md).
2760
+ Older entries are archived in [packages/tui/CHANGELOG.md@e6b8074bbe4b](https://github.com/can1357/oh-my-pi/blob/e6b8074bbe4b0d459aa812948822c2a634c8e62d/packages/tui/CHANGELOG.md).
@@ -18,6 +18,7 @@ interface AppKeybindings {
18
18
  "app.tools.expand": true;
19
19
  "app.tools.toggleVisibility": true;
20
20
  "app.ask.hide": true;
21
+ "app.approval.approve": true;
21
22
  "app.editor.external": true;
22
23
  "app.message.followUp": true;
23
24
  "app.retry": true;
@@ -243,6 +244,10 @@ export declare const KEYBINDINGS: {
243
244
  readonly defaultKeys: "alt+h";
244
245
  readonly description: "Hide or show a pending ask dialog";
245
246
  };
247
+ readonly "app.approval.approve": {
248
+ readonly defaultKeys: ["ctrl+enter", "ctrl+q"];
249
+ readonly description: "Approve a pending tool call";
250
+ };
246
251
  readonly "app.editor.external": {
247
252
  readonly defaultKeys: "ctrl+g";
248
253
  readonly description: "Open external editor";
@@ -74,8 +74,8 @@ export declare class ReadToolGroupComponent extends Container implements ToolExe
74
74
  * The read run as one inline `tool` on terminals that list the kind, else
75
75
  * the fallback: a bare `card` (role `omp.tool.read`) with `Read <path>` or
76
76
  * `Read (N)` in the head, a `list` of path items (status tone, link,
77
- * correction and conflict detail, nested usage) and, with content previews
78
- * on, a `code` block per read clamped by the card's preview while collapsed.
77
+ * correction and conflict detail, nested usage) and a clamped preview per
78
+ * read when content previews are on.
79
79
  */
80
80
  describe(cx?: DescribeContext): NativeNode;
81
81
  }
@@ -36,3 +36,7 @@ export declare function matchesAppExternalEditor(data: string): boolean;
36
36
  * effective follow-up binding.
37
37
  */
38
38
  export declare function matchesAppFollowUp(data: string): boolean;
39
+ /** Effective tool-approval keys: the `app.approval.approve` bindings, or Ctrl+Enter/Ctrl+Q when unbound. */
40
+ export declare function appApproveKeys(): readonly KeyId[];
41
+ /** Match the tool-approval key, recognizing modifier-tagged LF as Ctrl+Enter like {@link matchesAppFollowUp}. */
42
+ export declare function matchesAppApprove(data: string): boolean;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tool-approval prompt in the composer's place, presented like the ask
3
+ * dialog: the approval title and its details over an action bar. Approving
4
+ * takes a deliberate chord (`app.approval.approve`, Ctrl+Enter by default),
5
+ * so an Enter typed for the composer as the prompt appears never approves a
6
+ * call, and the chord itself only arms {@link APPROVE_ARM_MS} after mount: it
7
+ * is also the follow-up key, pressed mid-run exactly when prompts appear.
8
+ * Esc denies.
9
+ */
10
+ import { type Component, type TUI } from "../index.js";
11
+ import type { NativeNode, NativeUiEvent } from "../native/node.js";
12
+ /** The approve key is ignored this long after mount, so a follow-up sent as the prompt appears cannot approve it. */
13
+ export declare const APPROVE_ARM_MS = 500;
14
+ interface ApprovalDialogCallbacks {
15
+ onApprove(): void;
16
+ onDeny(): void;
17
+ }
18
+ interface ApprovalDialogOptions {
19
+ tui?: TUI;
20
+ /** Wrap long details instead of clipping them to a row: set when the dialog is the only place they show. */
21
+ wrapDetails?: boolean;
22
+ /** Deny once this many ms pass without input (any key restarts the countdown). */
23
+ timeout?: number;
24
+ /** Called when {@link timeout} expires, before the dialog denies. */
25
+ onTimeout?: () => void;
26
+ /** Called when the countdown starts, as the dialog mounts. */
27
+ onTimeoutStart?: () => void;
28
+ /** Called each time a key restarts the countdown. */
29
+ onTimeoutReset?: () => void;
30
+ }
31
+ export declare class ApprovalDialogComponent implements Component {
32
+ #private;
33
+ private readonly callbacks;
34
+ private readonly options;
35
+ /** `prompt`: the approval title on its first line, the details after it. */
36
+ constructor(prompt: string, callbacks: ApprovalDialogCallbacks, options?: ApprovalDialogOptions);
37
+ invalidate(): void;
38
+ dispose(): void;
39
+ handleInput(keyData: string): void;
40
+ render(width: number): readonly string[];
41
+ /**
42
+ * In the dock, in the composer's place and framed as the composer is (an
43
+ * `omp.editor.*` root role), like the ask dialog, rather than a card over
44
+ * the transcript: the title, the details, then Deny and the primary
45
+ * Approve, whose keys show in their tooltips.
46
+ */
47
+ describe(): NativeNode;
48
+ /** Deny and Approve run exactly what Esc and the approve key run. */
49
+ handleNativeEvent(event: NativeUiEvent): void;
50
+ }
51
+ export {};
@@ -86,6 +86,10 @@ export declare class PlanReviewOverlay implements Component {
86
86
  * reset scroll/focus so the operator starts at the top. Does not emit
87
87
  * `onPlanEdited` (the editor round-trip already persisted the file). */
88
88
  setPlanContent(planContent: string): void;
89
+ /** Synchronize an external tier pick with the displayed slider before committing. */
90
+ setSliderIndex(index: number): void;
91
+ /** Commit an external answer through the same input-locking path as Enter; false when nothing was committed. */
92
+ selectOption(label: string): boolean;
89
93
  handleInput(keyData: string): void;
90
94
  /** The native sheet: large glass, titled by the plan's own title heading (the body never repeats it). */
91
95
  get nativeOverlay(): NonNullable<Component["nativeOverlay"]>;
@@ -19,3 +19,20 @@ export declare function setTmuxPaneApcTitles(on: boolean, env?: NodeJS.ProcessEn
19
19
  * query runs once per process and degrades to `null` when unavailable.
20
20
  */
21
21
  export declare function resolveTmuxClientTerminalName(env?: NodeJS.ProcessEnv): string | null;
22
+ /**
23
+ * Whether tmux draws SIXEL to the client attached to this session: its
24
+ * effective terminal description for that client carries `Sxl`.
25
+ *
26
+ * A tmux built with SIXEL answers every pane's DA1 with attribute 4 and draws a
27
+ * `SIXEL IMAGE (WxH)` text placeholder for clients without SIXEL, so the pane's
28
+ * DA1 says nothing about the outer terminal. tmux itself renders SIXEL only
29
+ * when the client's terminal has `Sxl`, whether from terminfo,
30
+ * `terminal-overrides`, or the `sixel` feature (DA1 or `terminal-features`),
31
+ * and `show-messages -T` reports that merged description. The TUI consults
32
+ * this before trusting the pane's DA1 attribute. Queried once per tmux
33
+ * session; `false` outside tmux, with no attached client, or when the query
34
+ * fails. Nested tmux usually reports no `Sxl`: the inner client's terminal is
35
+ * the outer tmux, whose DA1 starts with level 1, which tmux does not parse
36
+ * for features.
37
+ */
38
+ export declare function tmuxClientRendersSixel(env?: NodeJS.ProcessEnv): boolean;
@@ -114,6 +114,13 @@ export declare function pendingFileLinkPath(inputPath: string): string;
114
114
  * image reads only record the path in `meta.source`). URL/internal sources are
115
115
  * not fs paths, so only `type: "path"` qualifies. */
116
116
  export declare function readSourceFsPath(details: ReadToolDetails | undefined): string | undefined;
117
+ /**
118
+ * Whether a read result renders as a Markdown preview rather than numbered source:
119
+ * the read tool tagged it `text/markdown` (`read.renderMarkdown`, or a protocol
120
+ * resource) and the call did not ask for `:raw` source. Shared by the standalone
121
+ * read card and the read group's content previews so both honor the setting.
122
+ */
123
+ export declare function readResultIsMarkdown(details: ReadToolDetails | undefined, args: ReadRenderArgs | undefined): boolean;
117
124
  /**
118
125
  * Read content as numbered `code` (no path header: the head names the file).
119
126
  * Numbers come from `lineNumbers` (`displayContent`'s per-line numbers,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.10.3",
4
+ "version": "18.10.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.10.3",
45
- "@oh-my-pi/pi-agent-core": "18.10.3",
46
- "@oh-my-pi/pi-ai": "18.10.3",
47
- "@oh-my-pi/pi-catalog": "18.10.3",
48
- "@oh-my-pi/pi-natives": "18.10.3",
49
- "@oh-my-pi/pi-utils": "18.10.3",
50
- "@oh-my-pi/pi-wire": "18.10.3",
51
- "@oh-my-pi/snapcompact": "18.10.3"
44
+ "@oh-my-pi/omptype": "18.10.4",
45
+ "@oh-my-pi/pi-agent-core": "18.10.4",
46
+ "@oh-my-pi/pi-ai": "18.10.4",
47
+ "@oh-my-pi/pi-catalog": "18.10.4",
48
+ "@oh-my-pi/pi-natives": "18.10.4",
49
+ "@oh-my-pi/pi-utils": "18.10.4",
50
+ "@oh-my-pi/pi-wire": "18.10.4",
51
+ "@oh-my-pi/snapcompact": "18.10.4"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -38,6 +38,7 @@ interface AppKeybindings {
38
38
  "app.tools.expand": true;
39
39
  "app.tools.toggleVisibility": true;
40
40
  "app.ask.hide": true;
41
+ "app.approval.approve": true;
41
42
  "app.editor.external": true;
42
43
  "app.message.followUp": true;
43
44
  "app.retry": true;
@@ -143,6 +144,12 @@ export const KEYBINDINGS = {
143
144
  defaultKeys: "alt+h",
144
145
  description: "Hide or show a pending ask dialog",
145
146
  },
147
+ "app.approval.approve": {
148
+ // A chord, not Enter: an Enter typed for the composer as the prompt appears must not approve.
149
+ // Ctrl+Q backs up Ctrl+Enter, which Windows Terminal cannot deliver (#1903).
150
+ defaultKeys: ["ctrl+enter", "ctrl+q"],
151
+ description: "Approve a pending tool call",
152
+ },
146
153
  "app.editor.external": {
147
154
  defaultKeys: "ctrl+g",
148
155
  description: "Open external editor",
@@ -245,7 +245,7 @@ export class RawSseViewerComponent implements Component {
245
245
  /** The action bar's buttons run the same code as their keys. */
246
246
  handleNativeEvent(event: NativeUiEvent): void {
247
247
  if (event.type !== "action") return;
248
- if (event.act === "copy") this.#copyAll();
248
+ if (event.act === "copy-raw") this.#copyAll();
249
249
  else if (event.act === "close") this.#close();
250
250
  }
251
251
 
@@ -305,7 +305,7 @@ export class RawSseViewerComponent implements Component {
305
305
  "status",
306
306
  ),
307
307
  actionBar([
308
- actionButton("Copy raw", "copy", { keys: "ctrl+c", tone: "accent" }),
308
+ actionButton("Copy raw", "copy-raw", { keys: "ctrl+c", tone: "accent" }),
309
309
  null,
310
310
  actionButton("Close", "close", { keys: "escape" }),
311
311
  ]),
@@ -10,11 +10,12 @@ import {
10
10
  type ReadRenderArgs,
11
11
  type ReadToolDetails,
12
12
  readContentCode,
13
+ readResultIsMarkdown,
13
14
  readSourceFsPath,
14
15
  splitPathAndSel,
15
16
  } from "../tools/read";
16
17
  import { PREVIEW_LIMITS, shortenPath } from "../render/render-utils";
17
- import { fileHyperlink, renderCodeCell } from "../render";
18
+ import { fileHyperlink, renderCodeCell, renderMarkdownCell } from "../render";
18
19
  import { canonicalizeMessage } from "./thinking-display";
19
20
  import { internalUrlSchemeSpec, splitUrlScheme } from "../tools/url-scheme-host";
20
21
  import type { ToolExecutionHandle } from "./tool-execution";
@@ -22,7 +23,7 @@ import { formatUsageRow } from "../overlays/usage-row";
22
23
  import { formatCount } from "@oh-my-pi/pi-utils";
23
24
  import type { TspCardStatus, TspSpan, TspText } from "@oh-my-pi/pi-wire";
24
25
  import type { NativeToolHead } from "../tools/renderer";
25
- import { card, code, keyed, node, span, text, withHidden } from "../native/describe";
26
+ import { card, code, keyed, md, node, span, text, withHidden } from "../native/describe";
26
27
  import {
27
28
  type DescribeContext,
28
29
  type NativeChild,
@@ -121,11 +122,23 @@ type ReadEntry = {
121
122
  status: "pending" | "success" | "warning" | "error";
122
123
  correctedFrom?: string;
123
124
  contentText?: string;
125
+ /** The result's `details.contentType`; see {@link previewsMarkdown}. */
126
+ contentType?: string;
127
+ /** The call's `raw` arg, which keeps Markdown previews as source. */
128
+ raw?: boolean;
124
129
  conflictCount?: number;
125
130
  codeStartLine?: number;
126
131
  codeLineNumbers?: Array<number | null>;
127
132
  };
128
133
 
134
+ /**
135
+ * Whether an entry previews as rendered Markdown instead of numbered source.
136
+ * Derived on each render so `raw`/path args that land after the result still apply.
137
+ */
138
+ function previewsMarkdown(entry: ReadEntry): boolean {
139
+ return readResultIsMarkdown({ contentType: entry.contentType }, { path: entry.path, raw: entry.raw });
140
+ }
141
+
129
142
  type ReadUsageRow = {
130
143
  toolCallIds: readonly string[];
131
144
  usage: Usage;
@@ -135,13 +148,12 @@ type ReadUsageRow = {
135
148
  turnElapsedMs?: number;
136
149
  };
137
150
 
138
- /** Number of code lines to show in collapsed preview mode */
151
+ /** Number of preview rows to show per read while collapsed. */
139
152
  const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
140
153
 
141
154
  /**
142
- * Collapsed clamp for a data-first group whose previews are already trimmed
143
- * to {@link COLLAPSED_PREVIEW_LINES}: large enough never to cut the trimmed
144
- * body, so collapsed shows every file and the head of each preview.
155
+ * The outer data-first group must not cut off later files: code is trimmed by
156
+ * source line and Markdown is clamped by each file's native card after rendering.
145
157
  */
146
158
  const TRIMMED_BODY_PREVIEW = { lines: 1000 } as const;
147
159
 
@@ -429,6 +441,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
429
441
  status: "pending",
430
442
  };
431
443
  entry.path = rawPath;
444
+ entry.raw = args.raw === true;
432
445
  this.#entries.set(toolCallId, entry);
433
446
  this.#updateDisplay();
434
447
  }
@@ -485,6 +498,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
485
498
  typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined;
486
499
  entry.conflictCount = conflictCount;
487
500
  entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success";
501
+ entry.contentType = details?.contentType;
488
502
  // Store clean display content for preview/expanded display when the read
489
503
  // tool provides it; fall back to model-facing text for legacy results.
490
504
  const displayContent = details?.displayContent;
@@ -578,8 +592,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
578
592
  * The read run as one inline `tool` on terminals that list the kind, else
579
593
  * the fallback: a bare `card` (role `omp.tool.read`) with `Read <path>` or
580
594
  * `Read (N)` in the head, a `list` of path items (status tone, link,
581
- * correction and conflict detail, nested usage) and, with content previews
582
- * on, a `code` block per read clamped by the card's preview while collapsed.
595
+ * correction and conflict detail, nested usage) and a clamped preview per
596
+ * read when content previews are on.
583
597
  */
584
598
  override describe(cx?: DescribeContext): NativeNode {
585
599
  const dataFirst = cx?.supports("tool") === true;
@@ -593,8 +607,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
593
607
  * The data-first read (§7.3 read, read group, read error): one inline
594
608
  * `tool` (role `omp.tool.read`). One file: `Read path:13-36`, a failure's
595
609
  * message in the head. Several: `Read 3 files` over one 22px row per file
596
- * (glyph, dim dir, strong name, range). Content previews are numbered
597
- * `code` (a section per file in a group), trimmed while collapsed.
610
+ * (glyph, dim dir, strong name, range). Each content preview is numbered
611
+ * code or rendered Markdown, clamped per file while collapsed.
598
612
  */
599
613
  #describeTool(): NativeNode {
600
614
  const entries = [...this.#entries.values()];
@@ -715,8 +729,21 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
715
729
  );
716
730
  }
717
731
 
718
- /** One read's content as numbered `code`, trimmed to its first lines (plus a count) while collapsed. */
732
+ /** One read's content, clamped per file while the group is collapsed. */
719
733
  #nativePreview(entry: ReadEntry): NativeChild[] {
734
+ if (previewsMarkdown(entry)) {
735
+ return [
736
+ card(
737
+ {
738
+ variant: "bare",
739
+ collapsible: true,
740
+ collapsed: !this.#expanded,
741
+ preview: { lines: COLLAPSED_PREVIEW_LINES },
742
+ },
743
+ [md(entry.contentText ?? "")],
744
+ ),
745
+ ];
746
+ }
720
747
  const split = splitPathAndSel(entry.path);
721
748
  const lines = (entry.contentText ?? "").split("\n");
722
749
  const shown = this.#expanded ? lines.length : Math.min(lines.length, COLLAPSED_PREVIEW_LINES);
@@ -796,11 +823,13 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
796
823
  preview: { lines: COLLAPSED_PREVIEW_LINES },
797
824
  },
798
825
  [
799
- code(entry.contentText ?? "", {
800
- lang: getLanguageFromPath(split.path),
801
- start: entry.codeStartLine,
802
- numbers: true,
803
- }),
826
+ previewsMarkdown(entry)
827
+ ? md(entry.contentText ?? "")
828
+ : code(entry.contentText ?? "", {
829
+ lang: getLanguageFromPath(split.path),
830
+ start: entry.codeStartLine,
831
+ numbers: true,
832
+ }),
804
833
  ],
805
834
  `p${entry.toolCallId}`,
806
835
  ),
@@ -1124,7 +1153,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
1124
1153
  }
1125
1154
 
1126
1155
  /**
1127
- * Add a code-cell content preview below the entry summary.
1156
+ * Add a code-cell (or Markdown-cell) content preview below the entry summary.
1128
1157
  * When collapsed: shows first COLLAPSED_PREVIEW_LINES lines with a "… N more lines ⟨<key>: Expand⟩" hint.
1129
1158
  * When expanded: shows full content.
1130
1159
  */
@@ -1151,20 +1180,33 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
1151
1180
  const component: Component = {
1152
1181
  render: (width: number) => {
1153
1182
  if (cachedLines && cachedWidth === width) return cachedLines;
1154
- cachedLines = renderCodeCell(
1155
- {
1156
- code: entry.contentText ?? "",
1157
- language: lang,
1158
- title,
1159
- status: entry.status === "success" ? "complete" : entry.status,
1160
- expanded,
1161
- codeMaxLines: expanded ? undefined : COLLAPSED_PREVIEW_LINES,
1162
- codeStartLine: entry.codeStartLine,
1163
- codeLineNumbers: entry.codeLineNumbers,
1164
- width,
1165
- },
1166
- theme,
1167
- );
1183
+ const status = entry.status === "success" ? "complete" : entry.status;
1184
+ cachedLines = previewsMarkdown(entry)
1185
+ ? renderMarkdownCell(
1186
+ {
1187
+ content: entry.contentText ?? "",
1188
+ title,
1189
+ status,
1190
+ expanded,
1191
+ contentMaxLines: COLLAPSED_PREVIEW_LINES,
1192
+ width,
1193
+ },
1194
+ theme,
1195
+ )
1196
+ : renderCodeCell(
1197
+ {
1198
+ code: entry.contentText ?? "",
1199
+ language: lang,
1200
+ title,
1201
+ status,
1202
+ expanded,
1203
+ codeMaxLines: expanded ? undefined : COLLAPSED_PREVIEW_LINES,
1204
+ codeStartLine: entry.codeStartLine,
1205
+ codeLineNumbers: entry.codeLineNumbers,
1206
+ width,
1207
+ },
1208
+ theme,
1209
+ );
1168
1210
  cachedWidth = width;
1169
1211
  return cachedLines;
1170
1212
  },
@@ -136,6 +136,7 @@ function hotkeyGroups(bindings: HotkeysMarkdownBindings): HotkeyGroup[] {
136
136
  { keys: [act("app.tools.expand")], action: "Toggle tool output expansion" },
137
137
  { keys: [act("app.tools.toggleVisibility")], action: "Toggle tool activity visibility" },
138
138
  { keys: [act("app.ask.hide")], action: "Hide/show a pending ask question" },
139
+ { keys: [act("app.approval.approve")], action: "Approve a pending tool call" },
139
140
  { keys: [act("app.thinking.toggle")], action: "Toggle thinking block visibility" },
140
141
  { keys: [act("app.editor.external")], action: "Edit message in external editor" },
141
142
  { keys: [act("app.retry")], action: "Retry last failed assistant turn" },
@@ -101,3 +101,17 @@ export function matchesAppFollowUp(data: string): boolean {
101
101
  }
102
102
  return matchesEffectiveKeys(data, ["ctrl+enter", "ctrl+q"]);
103
103
  }
104
+
105
+ /** Approve-key defaults when `app.approval.approve` is unbound: approval must stay reachable by keyboard. */
106
+ const APPROVE_DEFAULT_KEYS: readonly KeyId[] = ["ctrl+enter", "ctrl+q"];
107
+
108
+ /** Effective tool-approval keys: the `app.approval.approve` bindings, or Ctrl+Enter/Ctrl+Q when unbound. */
109
+ export function appApproveKeys(): readonly KeyId[] {
110
+ const keys = getKeybindings().getKeys("app.approval.approve");
111
+ return keys.length > 0 ? keys : APPROVE_DEFAULT_KEYS;
112
+ }
113
+
114
+ /** Match the tool-approval key, recognizing modifier-tagged LF as Ctrl+Enter like {@link matchesAppFollowUp}. */
115
+ export function matchesAppApprove(data: string): boolean {
116
+ return matchesEffectiveKeys(data, appApproveKeys());
117
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Tool-approval prompt in the composer's place, presented like the ask
3
+ * dialog: the approval title and its details over an action bar. Approving
4
+ * takes a deliberate chord (`app.approval.approve`, Ctrl+Enter by default),
5
+ * so an Enter typed for the composer as the prompt appears never approves a
6
+ * call, and the chord itself only arms {@link APPROVE_ARM_MS} after mount: it
7
+ * is also the follow-up key, pressed mid-run exactly when prompts appear.
8
+ * Esc denies.
9
+ */
10
+ import { type Component, Ellipsis, ScrollView, type TUI, truncateToWidth, wrapTextWithAnsi } from "../index";
11
+ import { formatKeyHints, formatTooltipKey } from "../app-keybindings";
12
+ import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
13
+ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
14
+ import { getKeybindings } from "../keybindings";
15
+ import {
16
+ appApproveKeys,
17
+ matchesAppApprove,
18
+ matchesSelectCancel,
19
+ matchesSelectDown,
20
+ matchesSelectPageDown,
21
+ matchesSelectPageUp,
22
+ matchesSelectUp,
23
+ } from "../keybinding-matchers";
24
+ import { col, node } from "../native/describe";
25
+ import type { NativeNode, NativeUiEvent } from "../native/node";
26
+ import { actionBar, actionButton } from "../native/overlay";
27
+ import { PREVIEW_LIMITS, sanitizeDisplayLines } from "../render/render-utils";
28
+ import { theme } from "../theme/theme";
29
+ import { CountdownTimer } from "../chrome/countdown-timer";
30
+
31
+ /** Detail rows shown before the rest scrolls: the prompt stays compact, the transcript has the call in full. */
32
+ const MAX_DETAIL_ROWS: number = PREVIEW_LIMITS.COLLAPSED_ITEMS;
33
+ /** The approve key is ignored this long after mount, so a follow-up sent as the prompt appears cannot approve it. */
34
+ export const APPROVE_ARM_MS = 500;
35
+
36
+ interface ApprovalDialogCallbacks {
37
+ onApprove(): void;
38
+ onDeny(): void;
39
+ }
40
+
41
+ interface ApprovalDialogOptions {
42
+ tui?: TUI;
43
+ /** Wrap long details instead of clipping them to a row: set when the dialog is the only place they show. */
44
+ wrapDetails?: boolean;
45
+ /** Deny once this many ms pass without input (any key restarts the countdown). */
46
+ timeout?: number;
47
+ /** Called when {@link timeout} expires, before the dialog denies. */
48
+ onTimeout?: () => void;
49
+ /** Called when the countdown starts, as the dialog mounts. */
50
+ onTimeoutStart?: () => void;
51
+ /** Called each time a key restarts the countdown. */
52
+ onTimeoutReset?: () => void;
53
+ }
54
+
55
+ export class ApprovalDialogComponent implements Component {
56
+ readonly #title: string;
57
+ readonly #details: readonly string[];
58
+ readonly #panel: OverlayPanel;
59
+ readonly #body = new PanelRows();
60
+ readonly #footer = new PanelRows();
61
+ #scrollOffset = 0;
62
+ #bodyRows = MAX_DETAIL_ROWS;
63
+ #closed = false;
64
+ #native: NativeNode | undefined;
65
+ readonly #armedAt = performance.now() + APPROVE_ARM_MS;
66
+ readonly #countdown: CountdownTimer | undefined;
67
+
68
+ /** `prompt`: the approval title on its first line, the details after it. */
69
+ constructor(
70
+ prompt: string,
71
+ private readonly callbacks: ApprovalDialogCallbacks,
72
+ private readonly options: ApprovalDialogOptions = {},
73
+ ) {
74
+ const [title = "", ...details] = sanitizeDisplayLines(prompt);
75
+ while (details.length > 0 && details[details.length - 1]!.trim() === "") details.pop();
76
+ this.#title = title;
77
+ this.#details = details;
78
+ this.#panel = new OverlayPanel(title, "omp.overlay.approval");
79
+ this.#footer.setHeight(1);
80
+ this.#panel.addChild(this.#body);
81
+ this.#panel.addChild(new PanelDivider());
82
+ this.#panel.addChild(this.#footer);
83
+ if (options.timeout && options.timeout > 0) {
84
+ options.onTimeoutStart?.();
85
+ this.#countdown = new CountdownTimer(
86
+ options.timeout,
87
+ options.tui,
88
+ seconds => {
89
+ this.#panel.title = `${title} (${seconds}s)`;
90
+ },
91
+ () => {
92
+ options.onTimeout?.();
93
+ this.#finish(false);
94
+ },
95
+ );
96
+ }
97
+ }
98
+
99
+ invalidate(): void {
100
+ this.#native = undefined;
101
+ this.#panel.invalidate();
102
+ }
103
+
104
+ dispose(): void {
105
+ this.#closed = true;
106
+ this.#countdown?.dispose();
107
+ this.#panel.dispose();
108
+ }
109
+
110
+ handleInput(keyData: string): void {
111
+ if (this.#closed) return;
112
+ if (this.#countdown) {
113
+ this.#countdown.reset();
114
+ this.options.onTimeoutReset?.();
115
+ // The described countdown restarts with it.
116
+ this.#native = undefined;
117
+ }
118
+ if (matchesAppApprove(keyData)) {
119
+ if (performance.now() >= this.#armedAt) this.#finish(true);
120
+ return;
121
+ }
122
+ if (matchesSelectCancel(keyData)) {
123
+ this.#finish(false);
124
+ return;
125
+ }
126
+ const page = Math.max(1, this.#bodyRows - 1);
127
+ if (matchesSelectUp(keyData)) this.#scroll(-1);
128
+ else if (matchesSelectDown(keyData)) this.#scroll(1);
129
+ else if (matchesSelectPageUp(keyData)) this.#scroll(-page);
130
+ else if (matchesSelectPageDown(keyData)) this.#scroll(page);
131
+ }
132
+
133
+ render(width: number): readonly string[] {
134
+ const innerWidth = Math.max(1, width - 4);
135
+ let lines = this.#detailLines(innerWidth);
136
+ const rows = Math.max(1, Math.min(lines.length, MAX_DETAIL_ROWS));
137
+ const overflows = lines.length > rows;
138
+ // Leave a column for the scrollbar once the details overflow.
139
+ if (overflows && innerWidth > 1) lines = this.#detailLines(innerWidth - 1);
140
+ this.#bodyRows = rows;
141
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, lines.length - rows));
142
+ if (overflows) {
143
+ const view = new ScrollView(lines, {
144
+ height: rows,
145
+ scrollbar: "auto",
146
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
147
+ });
148
+ view.setScrollOffset(this.#scrollOffset);
149
+ this.#body.setLines(view.render(innerWidth));
150
+ } else {
151
+ this.#body.setLines(lines);
152
+ }
153
+ this.#body.setHeight(rows);
154
+ this.#footer.setLines([theme.fg("dim", this.#footerHint(overflows))]);
155
+ return this.#panel.render(width);
156
+ }
157
+
158
+ /**
159
+ * In the dock, in the composer's place and framed as the composer is (an
160
+ * `omp.editor.*` root role), like the ask dialog, rather than a card over
161
+ * the transcript: the title, the details, then Deny and the primary
162
+ * Approve, whose keys show in their tooltips.
163
+ */
164
+ describe(): NativeNode {
165
+ if (this.#native) return this.#native;
166
+ const cancelKey = getKeybindings().getKeys("tui.select.cancel")[0];
167
+ const approveKey = appApproveKeys()[0];
168
+ const deny = actionButton("Deny", "deny", {
169
+ ...(cancelKey ? { keys: cancelKey } : {}),
170
+ tone: "error",
171
+ title: `Deny this call${cancelKey ? ` ${formatTooltipKey(cancelKey)}` : ""}`,
172
+ });
173
+ const approve = actionButton("Approve", "approve", {
174
+ ...(approveKey ? { keys: approveKey } : {}),
175
+ tone: "accent",
176
+ title: `Approve this call${approveKey ? ` ${formatTooltipKey(approveKey)}` : ""}`,
177
+ });
178
+ const children: NativeNode[] = [
179
+ node("text", { text: this.#title, role: "omp.approval.title", tone: "accent" }, undefined, "title"),
180
+ ];
181
+ if (this.#details.length > 0) {
182
+ // One clipped row per detail like the text view, unless wrapping was asked for.
183
+ const wrap = this.options.wrapDetails === true;
184
+ const rows = this.#details.map((line, index) =>
185
+ node("text", wrap ? { text: line } : { text: line, truncate: "end" }, undefined, String(index)),
186
+ );
187
+ children.push(node("col", { role: "omp.approval.details" }, rows, "details"));
188
+ }
189
+ // The countdown, when one runs, leads the bar like the ask dialog's.
190
+ children.push(actionBar([...(this.#countdown ? [this.#countdown.describe()] : []), null, deny, approve]));
191
+ // Under the `omp.editor.ask` prefix: Tern's console layout sizes an ask's rows to the
192
+ // composer column by that prefix, where a bare `omp.editor.*` role spans the whole dock.
193
+ this.#native = col(children, { role: "omp.editor.ask.approval", gap: "md" });
194
+ return this.#native;
195
+ }
196
+
197
+ /** Deny and Approve run exactly what Esc and the approve key run. */
198
+ handleNativeEvent(event: NativeUiEvent): void {
199
+ if (this.#closed || event.type !== "action") return;
200
+ if (event.act === "approve") this.#finish(true);
201
+ else if (event.act === "deny") this.#finish(false);
202
+ }
203
+
204
+ /** Detail rows at `width`: one clipped row per detail, or wrapped rows with {@link ApprovalDialogOptions.wrapDetails}. */
205
+ #detailLines(width: number): string[] {
206
+ if (!this.options.wrapDetails) {
207
+ return this.#details.map(line => truncateToWidth(theme.fg("accent", line), width, Ellipsis.Unicode));
208
+ }
209
+ return this.#details.flatMap(line => (line === "" ? [""] : wrapTextWithAnsi(theme.fg("accent", line), width)));
210
+ }
211
+
212
+ #footerHint(overflows: boolean): string {
213
+ const approve = formatKeyHints(appApproveKeys());
214
+ const scroll = overflows ? ` · ${editorKeys("tui.select.up", "tui.select.down")} scroll` : "";
215
+ return `${approve} approve · ${editorKey("tui.select.cancel")} deny${scroll}`;
216
+ }
217
+
218
+ #scroll(delta: number): void {
219
+ this.#scrollOffset = Math.max(0, this.#scrollOffset + delta);
220
+ this.options.tui?.requestRender();
221
+ }
222
+
223
+ #finish(approved: boolean): void {
224
+ if (this.#closed) return;
225
+ this.#closed = true;
226
+ this.#countdown?.dispose();
227
+ if (approved) this.callbacks.onApprove();
228
+ else this.callbacks.onDeny();
229
+ }
230
+ }
@@ -514,7 +514,7 @@ export class BtwHistoryPanel implements Component, Focusable {
514
514
  case "close":
515
515
  this.#options.onClose();
516
516
  return;
517
- case "copy":
517
+ case "copy-answer":
518
518
  if (record && getBtwCopyText(record) !== undefined) this.#options.onCopy(record);
519
519
  break;
520
520
  case "follow-up":
@@ -661,7 +661,7 @@ export class BtwHistoryPanel implements Component, Focusable {
661
661
  const buttons: (NativeNode | null)[] = [];
662
662
  if (running) buttons.push(actionButton("Stop answer", "stop", { keys: hides ? "x" : "escape" }));
663
663
  if (record && getBtwCopyText(record) !== undefined) {
664
- buttons.push(actionButton(copied ? "Copy again" : "Copy answer", "copy", { keys: "c" }));
664
+ buttons.push(actionButton(copied ? "Copy again" : "Copy answer", "copy-answer", { keys: "c" }));
665
665
  if (copied) buttons.push(text([span("✓ Copied", "success")], { role: "omp.btw.copied" }));
666
666
  }
667
667
  buttons.push(null);
@@ -10,7 +10,7 @@
10
10
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
11
11
  import type { Model } from "@oh-my-pi/pi-ai";
12
12
  import { buildModel } from "@oh-my-pi/pi-catalog/build";
13
- import { getModelPricingStatus, modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
13
+ import { getEffectiveTokenCost, getModelPricingStatus, modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
14
14
  import { modelKind, type ModelKind, type ModelPricingStatus } from "@oh-my-pi/pi-catalog/types";
15
15
  import type { Component } from "../tui";
16
16
  import { FuzzyCorpus, fuzzyRank } from "../fuzzy";
@@ -742,7 +742,7 @@ function declaredPricingStatus(model: Model): Exclude<ModelPricingStatus, "fixed
742
742
  function isFreeModel(model: Model): boolean {
743
743
  const declared = declaredPricingStatus(model);
744
744
  if (declared !== undefined) return declared === "free";
745
- const cost = model.cost;
745
+ const cost = model.cost ? getEffectiveTokenCost(model.cost) : undefined;
746
746
  const credits = model.factoryDroidCredits;
747
747
  return (!cost || (cost.input === 0 && cost.output === 0)) && (credits === undefined || credits === 0);
748
748
  }
@@ -766,7 +766,8 @@ function withCreditBadge(model: Model, price: string): string {
766
766
  const credits = model.factoryDroidCredits;
767
767
  if (credits === undefined) return price;
768
768
  const badge = `${formatCostLeg(credits)}×`;
769
- return model.cost.input !== 0 || model.cost.output !== 0 ? `${price} ${badge}` : badge;
769
+ const cost = getEffectiveTokenCost(model.cost);
770
+ return cost.input !== 0 || cost.output !== 0 ? `${price} ${badge}` : badge;
770
771
  }
771
772
 
772
773
  const PRICING_STATUS_LABELS: Record<Exclude<ModelPricingStatus, "fixed">, { short: string; detail: string }> = {
@@ -781,7 +782,8 @@ function formatCostPair(model: Model): string {
781
782
  const declared = declaredPricingStatus(model);
782
783
  if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
783
784
  if (isFreeModel(model)) return "free";
784
- return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}/${formatCostLeg(model.cost.output)}`);
785
+ const cost = getEffectiveTokenCost(model.cost);
786
+ return withCreditBadge(model, `$${formatCostLeg(cost.input)}/${formatCostLeg(cost.output)}`);
785
787
  }
786
788
 
787
789
  /** Detail-pane price fact: `$3/15 per M`, or the declared pricing state in words. */
@@ -803,14 +805,15 @@ function pickerPrice(model: Model): string {
803
805
  const declared = declaredPricingStatus(model);
804
806
  if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
805
807
  if (isFreeModel(model)) return "free";
806
- return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}·${formatCostLeg(model.cost.output)}`);
808
+ const cost = getEffectiveTokenCost(model.cost);
809
+ return withCreditBadge(model, `$${formatCostLeg(cost.input)}·${formatCostLeg(cost.output)}`);
807
810
  }
808
811
 
809
812
  /** `$2 in · $10 out · $0.2 cache` for a model preview. */
810
813
  function previewPrice(model: Model): string {
811
814
  const declared = declaredPricingStatus(model);
812
815
  if (declared !== undefined) return PRICING_STATUS_LABELS[declared].detail;
813
- const cost = model.cost;
816
+ const cost = getEffectiveTokenCost(model.cost);
814
817
  const parts = [`$${formatCostLeg(cost.input)} in`, `$${formatCostLeg(cost.output)} out`];
815
818
  if (cost.cacheRead > 0) parts.push(`$${formatCostLeg(cost.cacheRead)} cache`);
816
819
  return withCreditBadge(model, parts.join(" · "));
@@ -606,6 +606,22 @@ export class PlanReviewOverlay implements Component {
606
606
  slider.onChange?.(next);
607
607
  }
608
608
 
609
+ /** Synchronize an external tier pick with the displayed slider before committing. */
610
+ setSliderIndex(index: number): void {
611
+ if (this.#committed || !this.#slider) return;
612
+ this.#moveSlider(index - this.#sliderIndex);
613
+ }
614
+
615
+ /** Commit an external answer through the same input-locking path as Enter; false when nothing was committed. */
616
+ selectOption(label: string): boolean {
617
+ if (this.#committed) return false;
618
+ const index = this.#options.indexOf(label);
619
+ if (index < 0 || this.#disabled.has(index)) return false;
620
+ this.#selectedIndex = index;
621
+ this.#confirmSelection();
622
+ return this.#committed;
623
+ }
624
+
609
625
  #confirmSelection(): void {
610
626
  const index = this.#selectedIndex;
611
627
  if (index >= 0 && index < this.#options.length && !this.#disabled.has(index)) {
package/src/tmux.ts CHANGED
@@ -86,3 +86,35 @@ export function resolveTmuxClientTerminalName(env: NodeJS.ProcessEnv = Bun.env):
86
86
  if (cachedClientTerminalName === undefined) cachedClientTerminalName = queryTmuxClientTerminalName(env);
87
87
  return cachedClientTerminalName;
88
88
  }
89
+
90
+ const SIXEL_CAPABILITY = /^\s*\d+: Sxl: \(flag\) true$/mu;
91
+ let cachedClientSixel: { tmux: string; renders: boolean } | undefined;
92
+
93
+ function queryTmuxClientRendersSixel(env: NodeJS.ProcessEnv): boolean {
94
+ const client = runTmux(["display-message", "-p", "#{client_name}"], env)?.trim();
95
+ if (!client) return false;
96
+ return SIXEL_CAPABILITY.test(runTmux(["show-messages", "-T", "-t", client], env) ?? "");
97
+ }
98
+
99
+ /**
100
+ * Whether tmux draws SIXEL to the client attached to this session: its
101
+ * effective terminal description for that client carries `Sxl`.
102
+ *
103
+ * A tmux built with SIXEL answers every pane's DA1 with attribute 4 and draws a
104
+ * `SIXEL IMAGE (WxH)` text placeholder for clients without SIXEL, so the pane's
105
+ * DA1 says nothing about the outer terminal. tmux itself renders SIXEL only
106
+ * when the client's terminal has `Sxl`, whether from terminfo,
107
+ * `terminal-overrides`, or the `sixel` feature (DA1 or `terminal-features`),
108
+ * and `show-messages -T` reports that merged description. The TUI consults
109
+ * this before trusting the pane's DA1 attribute. Queried once per tmux
110
+ * session; `false` outside tmux, with no attached client, or when the query
111
+ * fails. Nested tmux usually reports no `Sxl`: the inner client's terminal is
112
+ * the outer tmux, whose DA1 starts with level 1, which tmux does not parse
113
+ * for features.
114
+ */
115
+ export function tmuxClientRendersSixel(env: NodeJS.ProcessEnv = Bun.env): boolean {
116
+ const tmux = env.TMUX;
117
+ if (!tmux) return false;
118
+ if (cachedClientSixel?.tmux !== tmux) cachedClientSixel = { tmux, renders: queryTmuxClientRendersSixel(env) };
119
+ return cachedClientSixel.renders;
120
+ }
package/src/tools/read.ts CHANGED
@@ -341,6 +341,18 @@ function readRawPath(args: ReadRenderArgs | undefined): string {
341
341
  return typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
342
342
  }
343
343
 
344
+ /**
345
+ * Whether a read result renders as a Markdown preview rather than numbered source:
346
+ * the read tool tagged it `text/markdown` (`read.renderMarkdown`, or a protocol
347
+ * resource) and the call did not ask for `:raw` source. Shared by the standalone
348
+ * read card and the read group's content previews so both honor the setting.
349
+ */
350
+ export function readResultIsMarkdown(details: ReadToolDetails | undefined, args: ReadRenderArgs | undefined): boolean {
351
+ if (details?.contentType !== "text/markdown" || args?.raw === true) return false;
352
+ const sel = splitReadRenderPath(readRawPath(args)).sel;
353
+ return sel?.split(":").some(chunk => chunk.toLowerCase() === "raw") !== true;
354
+ }
355
+
344
356
  /**
345
357
  * Native read head (§7.3 read): `Read path:13-36` with the path as the
346
358
  * target (shortened, plus the selector / legacy offset range), a `file://`
@@ -600,9 +612,7 @@ export const readToolRenderer = {
600
612
  const n = details.conflictCount;
601
613
  title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
602
614
  }
603
- const rawRequested =
604
- args?.raw === true || renderPath.sel?.split(":").some(chunk => chunk.toLowerCase() === "raw") === true;
605
- const isMarkdown = details?.contentType === "text/markdown" && !rawRequested;
615
+ const isMarkdown = readResultIsMarkdown(details, args);
606
616
  let cachedWidth: number | undefined;
607
617
  let cachedExpanded: boolean | undefined;
608
618
  let cachedLines: string[] | undefined;
@@ -719,17 +729,14 @@ export const readToolRenderer = {
719
729
  }
720
730
 
721
731
  const renderPath = splitReadRenderPath(rawPath);
722
- const rawRequested =
723
- args?.raw === true || renderPath.sel?.split(":").some(chunk => chunk.toLowerCase() === "raw") === true;
724
- const content: NativeChild[] =
725
- details?.contentType === "text/markdown" && !rawRequested
726
- ? [md(contentText)]
727
- : readContentCode(
728
- contentText,
729
- details?.displayContent?.startLine ?? readRangeStart(rawPath, args),
730
- details?.displayContent?.lineNumbers,
731
- getLanguageFromPath(renderPath.path),
732
- );
732
+ const content: NativeChild[] = readResultIsMarkdown(details, args)
733
+ ? [md(contentText)]
734
+ : readContentCode(
735
+ contentText,
736
+ details?.displayContent?.startLine ?? readRangeStart(rawPath, args),
737
+ details?.displayContent?.lineNumbers,
738
+ getLanguageFromPath(renderPath.path),
739
+ );
733
740
  return { tool: head, inline: true, body: compact<NativeChild>([media, ...content, foot]) };
734
741
  },
735
742
  mergeCallAndResult: true,
package/src/tui.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  TERMINAL,
47
47
  } from "./terminal-capabilities";
48
48
  import { compositeLineAt } from "./render/composite";
49
+ import { tmuxMultiplexer } from "./multiplexers/tmux";
49
50
  import {
50
51
  Ellipsis,
51
52
  getWidthConfigEpoch,
@@ -56,6 +57,7 @@ import {
56
57
  truncateToWidth,
57
58
  visibleWidth,
58
59
  } from "./utils";
60
+ import { tmuxClientRendersSixel } from "./tmux";
59
61
 
60
62
  /** Full-attribute reset terminating each rendered content row. */
61
63
  export const SEGMENT_RESET = "\x1b[0m";
@@ -1527,6 +1529,9 @@ export class TUI extends Container {
1527
1529
  this.terminal.onSixelSupport?.(supported => {
1528
1530
  if (!supported || this.#stopped || this.#nativeLive || this.terminal.tspExpected) return;
1529
1531
  if (isImageProtocolForced()) return;
1532
+ // tmux answers DA1 for its own emulator; only its record of the
1533
+ // attached client says whether SIXEL reaches the outer terminal.
1534
+ if (tmuxMultiplexer.isInside() && !tmuxClientRendersSixel()) return;
1530
1535
  // Keep consuming an already-requested XTSMGRAPHICS reply until it
1531
1536
  // arrives or times out, even though DA1 has established support.
1532
1537
  this.#enableSixelProtocol();
@@ -1,5 +0,0 @@
1
- /** Format tool schemas as TypeScript declarations for code-mode discovery. */
2
- export declare function generateCodeModeDeclarations(tools: ReadonlyArray<{
3
- name: string;
4
- parameters: unknown;
5
- }>): string;
@@ -1,61 +0,0 @@
1
- /**
2
- * Generate the TypeScript method signatures advertising eval-bridged tools
3
- * under Codex Code Mode (spliced into the `eval-code-mode.md` prompt),
4
- * mirroring codex-rs augment_tool_spec_for_code_mode.
5
- */
6
- import { arkToWireSchema, isArkSchema } from "@oh-my-pi/pi-ai/utils/schema/wire";
7
-
8
- interface JsonSchema {
9
- type?: string;
10
- properties?: Record<string, JsonSchema>;
11
- required?: string[];
12
- items?: JsonSchema;
13
- enum?: unknown[];
14
- }
15
-
16
- const TS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
17
-
18
- function tsType(schema: JsonSchema | undefined, depth: number): string {
19
- if (!schema || depth > 2) return "unknown";
20
- if (Array.isArray(schema.enum) && schema.enum.every(value => typeof value === "string")) {
21
- return schema.enum.map(value => JSON.stringify(value)).join(" | ");
22
- }
23
- switch (schema.type) {
24
- case "string":
25
- return "string";
26
- case "number":
27
- case "integer":
28
- return "number";
29
- case "boolean":
30
- return "boolean";
31
- case "array": {
32
- const item = tsType(schema.items, depth + 1);
33
- // `"a" | "b"[]` parses as `"a" | ("b"[])`, so a union item needs parens.
34
- return /[|&]/.test(item) ? `(${item})[]` : `${item}[]`;
35
- }
36
- case "object": {
37
- if (!schema.properties) return "Record<string, unknown>";
38
- const required = new Set(schema.required ?? []);
39
- const entries = Object.entries(schema.properties).map(([key, value]) => {
40
- const printedKey = TS_IDENTIFIER.test(key) ? key : JSON.stringify(key);
41
- return `${printedKey}${required.has(key) ? "" : "?"}: ${tsType(value, depth + 1)}`;
42
- });
43
- return `{ ${entries.join("; ")} }`;
44
- }
45
- default:
46
- return "unknown";
47
- }
48
- }
49
-
50
- /** Format tool schemas as TypeScript declarations for code-mode discovery. */
51
- export function generateCodeModeDeclarations(tools: ReadonlyArray<{ name: string; parameters: unknown }>): string {
52
- const lines = tools.map(tool => {
53
- const printedName = TS_IDENTIFIER.test(tool.name) ? tool.name : JSON.stringify(tool.name);
54
- const wire = isArkSchema(tool.parameters)
55
- ? (arkToWireSchema(tool.parameters) as JsonSchema)
56
- : (tool.parameters as JsonSchema | undefined);
57
- const args = wire?.type === "object" && wire.properties ? tsType(wire, 0) : "unknown";
58
- return ` ${printedName}(args: ${args}): Promise<unknown>;`;
59
- });
60
- return lines.join("\n");
61
- }