@oh-my-pi/pi-tui 18.12.3 → 18.12.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,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.4] - 2026-10-20
6
+
7
+ ### Added
8
+
9
+ - Added `mergeLineRanges` and an optional `BareLineMode` argument to `parseLineRanges` (`"single"` reads a lone bare `N` as that one line) ([#16435](https://github.com/can1357/oh-my-pi/pull/16435) by [@will-bogusz](https://github.com/will-bogusz))
10
+
11
+ ### Changed
12
+
13
+ - `selectorLineRanges()` reads a bare `N` compounded with `raw` (`17:raw`, `raw:17`) as the single line `N`; `parseLineRanges()` takes an optional `"single"` mode for the same rule ([#16438](https://github.com/can1357/oh-my-pi/pull/16438) by [@will-bogusz](https://github.com/will-bogusz))
14
+ - Collapsed `wait` cards show one row per finished job (or a single "waiting on N jobs" line) instead of re-printing the whole job tree on every poll; Ctrl+O shows the full tree ([#16486](https://github.com/can1357/oh-my-pi/pull/16486) by [@H4vC](https://github.com/H4vC))
15
+ - Bash cards show the elapsed time while a command runs, and wall time, timeout and backgrounded job in the bottom border instead of a separate `⟨…⟩` line ([#16486](https://github.com/can1357/oh-my-pi/pull/16486) by [@H4vC](https://github.com/H4vC))
16
+ - The terminal `/jobs` sheet shows the selected job's full command ([#16486](https://github.com/can1357/oh-my-pi/pull/16486) by [@H4vC](https://github.com/H4vC))
17
+ - Grep, glob, find, IRC message, and `proc://`/`agent://`/`cfg://` result rows start at the same column as other tool rows ([#16486](https://github.com/can1357/oh-my-pi/pull/16486) by [@H4vC](https://github.com/H4vC))
18
+
19
+ ### Fixed
20
+
21
+ - Fixed Tern sessions logging `TSP: terminal stopped acknowledging frames` every 5 seconds for as long as the terminal stops acknowledging frames; it now warns once per stall and logs the stall's length when acknowledgements resume ([#16393](https://github.com/can1357/oh-my-pi/pull/16393) by [@will-bogusz](https://github.com/will-bogusz))
22
+ - `splitPathAndSel` and `splitInternalUrlSel` peel comma line lists that include a tail chunk (`:1-12,-15`) or repeat the colon after a comma (`:1-30,:-30`, also beside `:raw`); `splitPathAndSel(path, { foldListColons: false })` keeps the repeated colon in the path ([#16435](https://github.com/can1357/oh-my-pi/pull/16435) by [@will-bogusz](https://github.com/will-bogusz))
23
+ - Generic tool cards now reveal the complete result when expanded ([#15335](https://github.com/can1357/oh-my-pi/pull/15335) by [@yuzu-octopus](https://github.com/yuzu-octopus))
24
+ - Added `footerMeta` to `OutputBlockOptions` and `ToolCardSnapshot` to draw short facts inside a frame's bottom border ([#16486](https://github.com/can1357/oh-my-pi/pull/16486) by [@H4vC](https://github.com/H4vC))
25
+ - Fixed markdown table rows shattering at narrow widths when a wide glyph sat in a one-cell column; the glyph is now clamped to its column instead of re-wrapping the row ([#16479](https://github.com/can1357/oh-my-pi/pull/16479) by [@adi-IL](https://github.com/adi-IL)).
26
+ - Fixed charts in answers drawing as blank bands, on screen and in scrollback, when iTerm2 drew them in a background tab; inline images now carry an explicit height in rows ([#16471](https://github.com/can1357/oh-my-pi/pull/16471) by [@galtamam-cmyk](https://github.com/galtamam-cmyk))
27
+ - Fixed an inline image (such as a table's chart) disappearing in iTerm2 when new output pushed it past the top of the screen; it now moves to scrollback whole ([#16467](https://github.com/can1357/oh-my-pi/pull/16467) by [@galtamam-cmyk](https://github.com/galtamam-cmyk))
28
+ - Fixed inline images, hyperlinks, notifications and styled underlines staying off in iTerm2 sessions reached over SSH, where the forwarded `LC_TERMINAL` marker was ignored ([#16450](https://github.com/can1357/oh-my-pi/pull/16450) by [@yuzu-octopus](https://github.com/yuzu-octopus))
29
+ - Added Eval `mcp_progress` status events, updated in place per call and evictable under the status-event cap, plus `formatMCPProgress()` and `MCPToolDetails.progress` for MCP progress display ([#16431](https://github.com/can1357/oh-my-pi/pull/16431) by [@alphastorm](https://github.com/alphastorm))
30
+
5
31
  ## [18.12.3] - 2026-10-19
6
32
 
7
33
  ### Added
@@ -2447,51 +2473,4 @@
2447
2473
  - Fixed `@` file mention autocomplete stalling for seconds when the query references something outside the project root (e.g. `@../`, `@~/`, `@/abs/`). `CombinedAutocompleteProvider` now short-circuits to plain immediate-directory prefix listing in those cases instead of dispatching a recursive `fuzzyFind` walk over a sibling directory full of unrelated projects. Inside-cwd queries keep the existing fuzzy-then-prefix behavior. ([#1395](https://github.com/can1357/oh-my-pi/issues/1395))
2448
2474
  - Gated the Hangul Compatibility Jamo width correction (U+3131..U+318E → 1 cell, originally landed in 15.0.1 for the IME / hardware-cursor displacement bug) behind `process.platform === "darwin"` in the TS path and `cfg!(target_os = "macos")` in the `pi-natives` Rust path. macOS terminals (Ghostty / Terminal.app / iTerm2) render jamo as 1 cell despite UAX#11 classifying them as Wide, but WezTerm and most Linux terminals honor UAX#11 and render them as 2 cells. The unconditional correction therefore desynced the TUI's column bookkeeping from the terminal's actual rendering off-darwin, producing corrupted layout and broken Korean input on Linux. On non-darwin the helpers now defer entirely to `Bun.stringWidth` / `UnicodeWidthStr` (also a small perf win on the multi-char-grapheme path). ([#1410](https://github.com/can1357/oh-my-pi/issues/1410))
2449
2475
 
2450
- ## [15.4.0] - 2026-05-26
2451
-
2452
- ### Fixed
2453
-
2454
- - Fixed terminal scrollback gaining duplicate copies of the welcome screen (and any other header content) when the bottom tool cell mutated across the previous viewport boundary. Once a row scrolls into terminal history it cannot be retracted, so a subsequent shrink that would re-expose that row in the repainted viewport now clears stale scrollback and replays the transcript, then suppresses one immediate suffix-scroll frame so live status/editor chrome is not deposited twice. Multiplexer panes ignore `\x1b[3J`, so the recovery is gated on `!isMultiplexerSession()`.
2455
- - Fixed the IME / hardware cursor sticking to the bottom of the terminal after a resize that grew the viewport taller than the rendered transcript. `#emitViewportRepaint` always writes one row per screen line (padding empty rows past the content), so the post-write hardware cursor sits at screen row `height - 1`. The bookkeeping previously clamped the tracked cursor row to `lines.length - 1`, making `#cursorControlSequence`'s relative `rowDelta` underestimate the upward move by `(height - lines.length)` rows and pinning the cursor at the viewport bottom even though the focused component's `CURSOR_MARKER` was on a content row.
2456
-
2457
- ## [15.3.2] - 2026-05-25
2458
-
2459
- ### Fixed
2460
-
2461
- - Fixed `matchesKey(data, "ctrl+m")` (and the other named-key collisions: `ctrl+h`/`ctrl+i`/`ctrl+j`/`ctrl+[`) returning true for the bare `\r`/`\x08`/`\t`/`\n`/`\x1b` byte terminals send for Enter/Backspace/Tab/Escape in legacy mode. Binding a command to `Ctrl+M` no longer fires when the user presses Enter — the named key wins, and `ctrl+<colliding-letter>` matches only when the terminal disambiguates via the Kitty keyboard protocol or `modifyOtherKeys`. ([#1354](https://github.com/can1357/oh-my-pi/issues/1354))
2462
-
2463
- ## [15.2.3] - 2026-05-22
2464
-
2465
- ### Added
2466
-
2467
- - Added `SettingsList#setItems` to replace the entire settings list with a new items array while automatically clamping selection to a valid index
2468
-
2469
- ### Changed
2470
-
2471
- - Updated `Loader` to drive renders at ~60fps (16ms tick) while keeping spinner-frame advancement at 80ms so shimmer/animated message colorizers update smoothly without altering spinner cadence
2472
-
2473
- ## [15.1.9] - 2026-05-21
2474
-
2475
- ### Fixed
2476
-
2477
- - Fixed terminal probe responses (DA1, kitty keyboard, Mode 2031) leaking into the prompt as keystrokes when the response is split across stdin reads. `ProcessTerminal` now reassembles `\x1b[?<digits>...` private CSI fragments and dispatches the complete response through the existing pattern handlers. ([#1238](https://github.com/can1357/oh-my-pi/issues/1238))
2478
-
2479
- ## [15.1.4] - 2026-05-19
2480
-
2481
- ### Fixed
2482
-
2483
- - Fixed `renderInlineMarkdown` crashing with `TypeError: undefined is not an object (evaluating 'e.replace')` when called with a non-string value during streaming — partial JSON parsing leaves option label fields temporarily unpopulated, causing the ask tool renderer to fail. ([#1176](https://github.com/can1357/oh-my-pi/issues/1176))
2484
-
2485
- ## [15.0.2] - 2026-05-15
2486
-
2487
- ### Added
2488
-
2489
- - Restored the `Key` runtime helper on `@oh-my-pi/pi-tui` to mirror upstream `@mariozechner/pi-tui`'s surface. `Key.enter`, `Key.escape`, `Key.tab`, … return the canonical key-name strings; modifier methods (`Key.ctrl(k)`, `Key.shift(k)`, `Key.ctrlShift(k)`, etc.) build precisely-typed `KeyId` literals like `"ctrl+c"`. Pure runtime convenience for typed key-id construction — plugins built against the upstream package surface that import `Key` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) load again now that the specifier shim remaps them onto this package.
2490
-
2491
- ## [15.0.1] - 2026-05-14
2492
-
2493
- ### Breaking Changes
2494
-
2495
- - Increased the minimum required Bun version for the TUI package from >=1.3.7 to >=1.3.14
2496
-
2497
- Older entries are archived in [packages/tui/CHANGELOG.md@04d4ab14b45f](https://github.com/can1357/oh-my-pi/blob/04d4ab14b45f8dbd4055b675519f8815a1eb1841/packages/tui/CHANGELOG.md).
2476
+ Older entries are archived in [packages/tui/CHANGELOG.md@ec051608a87a](https://github.com/can1357/oh-my-pi/blob/ec051608a87ad78d4930a46af7f5582192005ada/packages/tui/CHANGELOG.md).
@@ -10,6 +10,8 @@ import type { State } from "./types.js";
10
10
  export interface OutputBlockOptions {
11
11
  header?: string;
12
12
  headerMeta?: string;
13
+ /** Facts drawn inside the bottom border (`╰── Wall 0.16s · Timeout 300s ──╯`). */
14
+ footerMeta?: string;
13
15
  state?: State;
14
16
  sections?: Array<{
15
17
  label?: string;
@@ -22,6 +22,8 @@ export interface ToolCardSnapshot {
22
22
  /** Structured status header, rendered by the shared status-line primitive. */
23
23
  status?: StatusLineOptions;
24
24
  headerMeta?: string;
25
+ /** Short facts drawn inside the frame's bottom border (framed); a trailing row otherwise. */
26
+ footerMeta?: string;
25
27
  phase?: ToolCardPhase;
26
28
  body?: ToolCardContent;
27
29
  sections?: readonly ToolCardSection[];
@@ -389,6 +389,11 @@ export declare function encodeITerm2(base64Data: string, options?: {
389
389
  preserveAspectRatio?: boolean;
390
390
  inline?: boolean;
391
391
  }): string;
392
+ /**
393
+ * The encoded image `encodeITerm2` wrote into `line`, without the size
394
+ * parameters a rewrap changes; undefined for a line without one.
395
+ */
396
+ export declare function iterm2ImagePayload(line: string): string | undefined;
392
397
  export declare function calculateImageRows(imageDimensions: ImageDimensions, targetWidthCells: number, cellDimensions?: CellDimensions): number;
393
398
  export declare function getPngDimensions(base64Data: string): ImageDimensions | null;
394
399
  export declare function getJpegDimensions(base64Data: string): ImageDimensions | null;
@@ -117,6 +117,7 @@ export declare function createShellRenderer<TArgs>(config: ShellRendererConfig<T
117
117
  }, args?: TArgs): NativeToolView;
118
118
  mergeCallAndResult: true;
119
119
  inline: true;
120
+ tickElapsed: true;
120
121
  };
121
122
  /** Renders bash command previews and output. */
122
123
  export declare const bashToolRenderer: {
@@ -146,4 +147,5 @@ export declare const bashToolRenderer: {
146
147
  }, args?: BashRenderArgs | undefined): NativeToolView;
147
148
  mergeCallAndResult: true;
148
149
  inline: true;
150
+ tickElapsed: true;
149
151
  };
@@ -23,6 +23,9 @@ export declare function formatDefaultToolExecution(input: DefaultToolRenderInput
23
23
  /**
24
24
  * TSP view of the generic fallback card: the tool label with a one-line args
25
25
  * summary as the head target; the result as a JSON tree or raw `ansi` output.
26
+ * The complete output goes over the wire — the card-level disclosure clamp the
27
+ * terminal applies is the only bound, so an expanded card keeps every row. JSON
28
+ * the tree's depth, node or scalar caps would cut keeps the raw path instead.
26
29
  */
27
30
  export declare function describeDefaultToolExecution(input: DefaultToolRenderInput): NativeToolView;
28
31
  /** Render the generic fallback as the state-tinted card used by direct custom tools. */
@@ -61,7 +61,7 @@ export interface EvalCellResult {
61
61
  durationMs?: number;
62
62
  exitCode?: number;
63
63
  statusEvents?: EvalStatusEvent[];
64
- /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
64
+ /** Evictable events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
65
65
  statusEventsElided?: number;
66
66
  }
67
67
  /** Tool result detail object surfaced to the UI/transcript. */
@@ -69,7 +69,7 @@ export interface EvalToolDetails {
69
69
  cells?: EvalCellResult[];
70
70
  images?: ImageContent[];
71
71
  statusEvents?: EvalStatusEvent[];
72
- /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
72
+ /** Evictable events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
73
73
  statusEventsElided?: number;
74
74
  isError?: boolean;
75
75
  meta?: OutputMeta;
@@ -104,7 +104,7 @@ interface EvalRenderContext {
104
104
  timeout?: number;
105
105
  }
106
106
  /**
107
- * Coalescing key of a progress-snapshot event: `agent` and `judge_batch`
107
+ * Coalescing key of a progress-snapshot event: `agent`, `judge_batch`, and `mcp_progress`
108
108
  * events keyed by `id`, where only the newest snapshot matters. Discrete
109
109
  * actions (everything else) have no key. Shared by {@link upsertStatusEvent}
110
110
  * and the executors' display collectors so both coalesce identically.
@@ -114,12 +114,12 @@ export declare function statusEventKey(event: {
114
114
  [key: string]: unknown;
115
115
  }): string | undefined;
116
116
  /**
117
- * Discrete status events a log keeps. A cell polling a helper in a loop emits
117
+ * Evictable status events a log keeps. A cell polling a helper in a loop emits
118
118
  * one event per call (hundreds of thousands), and every live update and the
119
119
  * persisted tool result would otherwise carry them all.
120
120
  */
121
121
  export declare const MAX_STATUS_EVENTS = 200;
122
- /** A status event list plus the count of discrete events dropped from its front. */
122
+ /** A status event list plus the count of evictable events dropped from its front. */
123
123
  export type StatusEventLog = Pick<EvalCellResult, "statusEvents" | "statusEventsElided">;
124
124
  /**
125
125
  * Append or replace a status event. Progress snapshots (see
@@ -129,9 +129,10 @@ export type StatusEventLog = Pick<EvalCellResult, "statusEvents" | "statusEvents
129
129
  export declare function upsertStatusEvent(events: EvalStatusEvent[], event: EvalStatusEvent): void;
130
130
  /**
131
131
  * {@link upsertStatusEvent} into a bounded log. Past {@link MAX_STATUS_EVENTS},
132
- * the oldest discrete event is dropped and counted. Snapshots stay, since agent
133
- * cards read them, and so does the newest committed `todo` result, which the
134
- * session todo panel refreshes from. Renderers already show only the newest
132
+ * the oldest discrete event or MCP progress snapshot is dropped and counted.
133
+ * Agent/judgment-batch snapshots stay, since agent cards read them, and so does
134
+ * the newest committed `todo` result, which the session todo panel refreshes
135
+ * from. Renderers already show only the newest
135
136
  * events behind an "… N earlier" row.
136
137
  */
137
138
  export declare function recordStatusEvent(log: StatusEventLog, event: EvalStatusEvent): void;
@@ -70,6 +70,8 @@ export interface JsonTreeDescribeOptions {
70
70
  openDepth?: number;
71
71
  /** Root object keys omitted. Defaults to internal tool argument metadata. */
72
72
  hiddenRootKeys?: readonly string[];
73
+ /** Called after the tree is built if a depth, node or scalar cap left part of the value out. */
74
+ onTruncated?: () => void;
73
75
  }
74
76
  /**
75
77
  * TSP `tree` of a JSON value: containers become expandable nodes labelled with
@@ -10,12 +10,19 @@ export interface LineRange {
10
10
  export declare const LINE_RANGE_CHUNK_SOURCE: string;
11
11
  /** Parse a single `N`, `N-M`, `N-`, `N+K`, or `..`-aliased (`N..M`, `N..`) chunk. Throws via {@link ToolError} on invalid bounds. */
12
12
  export declare function parseLineRangeChunk(sel: string): LineRange | null;
13
+ /** What a bare `N` selects in a one-chunk list: lines `N` onward (`:50`), or the single line `N`. */
14
+ export type BareLineMode = "onward" | "single";
13
15
  /**
14
16
  * Parse a comma-separated list of line ranges (e.g. `5-16,960-973` or `19,59`). Returns
15
17
  * the ranges in ascending order with overlapping/adjacent ranges merged so
16
18
  * downstream consumers can stream the file in a single forward pass per range.
17
19
  */
18
- export declare function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null;
20
+ export declare function parseLineRanges(sel: string, bare?: BareLineMode): [LineRange, ...LineRange[]] | null;
21
+ /**
22
+ * Sort non-empty `ranges` by start line and merge the ones that overlap or touch;
23
+ * an open-ended range (`endLine` undefined) reaches EOF and absorbs every later one.
24
+ */
25
+ export declare function mergeLineRanges(ranges: readonly LineRange[]): [LineRange, ...LineRange[]];
19
26
  /**
20
27
  * Extract the line-range component from a read-tool selector that may also
21
28
  * carry a verbatim/index display mode (`raw`, `conflicts`) — alone or compounded
@@ -63,6 +63,27 @@ export interface MCPResourceContent {
63
63
  }
64
64
  /** Supported MCP result content blocks retained in display metadata. */
65
65
  export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent;
66
+ /** Server-reported progress of an in-flight MCP request. */
67
+ export interface MCPProgress {
68
+ progress: number;
69
+ total?: number;
70
+ message?: string;
71
+ }
72
+ /**
73
+ * Read MCP progress fields from an untrusted record: `progress` must be a
74
+ * finite number; `total` is kept only when finite and `message` only when a
75
+ * string. Shared by the transports, the Eval bridge, and the status renderer.
76
+ */
77
+ export declare function parseMCPProgress(value: unknown): MCPProgress | undefined;
78
+ /**
79
+ * One-line display text for MCP progress, at most `maxWidth` cells: the
80
+ * message, with `progress/total` when the server reports a total. The bare
81
+ * progress value appears only when there is no message: without a total it is
82
+ * an opaque counter, not a percentage. Home paths in the message show as `~`,
83
+ * and a long message is cut before the count, so the count stays visible.
84
+ * Every progress display (tool card and both Eval status renderers) uses it.
85
+ */
86
+ export declare function formatMCPProgress(progress: MCPProgress, maxWidth?: number): string;
66
87
  /** MCP result details shared by renderers and programmatic tool consumers. */
67
88
  export interface MCPToolDetails {
68
89
  /** Server name */
@@ -81,6 +102,8 @@ export interface MCPToolDetails {
81
102
  provider?: string;
82
103
  /** Provider display name (e.g., "Claude Code", "MCP Config") */
83
104
  providerName?: string;
105
+ /** Latest progress of a running call; present only on partial updates. */
106
+ progress?: MCPProgress;
84
107
  /** Structured output metadata (set by the spill wrapper when output is truncated to an artifact). */
85
108
  meta?: OutputMeta;
86
109
  }
@@ -71,8 +71,14 @@ export interface ReadToolDetails {
71
71
  */
72
72
  displayReadTargetLinks?: Array<string | null>;
73
73
  }
74
- /** Split a filesystem path from its trailing read selector. */
75
- export declare function splitPathAndSel(rawPath: string): {
74
+ /**
75
+ * Split a filesystem path from its trailing read selector. `foldListColons: false`
76
+ * keeps a colon repeated inside a list as part of the path (`a:1-30,:-30` peels
77
+ * only `:-30`), for callers checking whether that shorter split names a real file.
78
+ */
79
+ export declare function splitPathAndSel(rawPath: string, options?: {
80
+ foldListColons?: boolean;
81
+ }): {
76
82
  path: string;
77
83
  sel?: string;
78
84
  };
@@ -27,8 +27,9 @@ export interface RenderResultOptions {
27
27
  executionStarted?: boolean;
28
28
  /**
29
29
  * Milliseconds since this call began executing (`tool_execution_start`),
30
- * or its total once settled; undefined before it starts. Native describe
31
- * hooks only.
30
+ * or its total once settled; undefined before it starts. The terminal
31
+ * transcript's options object computes it on read, so a render-time read
32
+ * is live while the block's spinner ticks.
32
33
  */
33
34
  elapsedMs?: number;
34
35
  /** The call was abandoned without a final result (turn aborted, sealed). Native describe hooks only. */
@@ -177,6 +178,11 @@ export interface ToolRenderer<TArgs = unknown, TDetails = unknown> {
177
178
  * `options.spinnerFrame`.
178
179
  */
179
180
  animatedPartialResult?: boolean | ((args: TArgs) => boolean);
181
+ /**
182
+ * Repaint the block once a second while its call executes, for renderers
183
+ * that show live `options.elapsedMs` without consuming the spinner frame.
184
+ */
185
+ tickElapsed?: boolean;
180
186
  /**
181
187
  * Whether replacing a pending call render with the first result requires a
182
188
  * full viewport repaint. Use for merged renderers whose pending rows can be
@@ -16,5 +16,5 @@ export interface CardToolResult {
16
16
  export declare function firstText(result: CardToolResult): string;
17
17
  /** Sanitize one display line: tabs become spaces, carriage returns are dropped. */
18
18
  export declare function safe(value: string): string;
19
- /** Padded card whose lines are sanitized and truncated, re-rendered when the expanded state flips. */
19
+ /** Card whose lines are sanitized and truncated, re-rendered when the expanded state flips. */
20
20
  export declare function card(lines: (width: number, expanded: boolean) => string[], options: RenderResultOptions): Component;
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.12.3",
4
+ "version": "18.12.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.12.3",
45
- "@oh-my-pi/pi-agent-core": "18.12.3",
46
- "@oh-my-pi/pi-ai": "18.12.3",
47
- "@oh-my-pi/pi-catalog": "18.12.3",
48
- "@oh-my-pi/pi-natives": "18.12.3",
49
- "@oh-my-pi/pi-utils": "18.12.3",
50
- "@oh-my-pi/pi-wire": "18.12.3",
51
- "@oh-my-pi/snapcompact": "18.12.3"
44
+ "@oh-my-pi/omptype": "18.12.4",
45
+ "@oh-my-pi/pi-agent-core": "18.12.4",
46
+ "@oh-my-pi/pi-ai": "18.12.4",
47
+ "@oh-my-pi/pi-catalog": "18.12.4",
48
+ "@oh-my-pi/pi-natives": "18.12.4",
49
+ "@oh-my-pi/pi-utils": "18.12.4",
50
+ "@oh-my-pi/pi-wire": "18.12.4",
51
+ "@oh-my-pi/snapcompact": "18.12.4"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -438,6 +438,8 @@ export class ToolExecutionComponent extends Container {
438
438
  #spinnerActive = false;
439
439
  // Todo write completion strikethrough reveal animation
440
440
  #todoStrikeInterval?: NodeJS.Timeout;
441
+ // Once-a-second repaint for renderers that show live elapsed time (`tickElapsed`)
442
+ #elapsedTicker?: NodeJS.Timeout;
441
443
  // Track if args are still being streamed (for edit/write spinner)
442
444
  #argsComplete = false;
443
445
  #executionStarted = false;
@@ -474,6 +476,8 @@ export class ToolExecutionComponent extends Container {
474
476
  argsComplete?: boolean;
475
477
  executionStarted?: boolean;
476
478
  renderContext?: Record<string, unknown>;
479
+ /** Live: read at render time, so a ticking spinner advances it. */
480
+ readonly elapsedMs?: number;
477
481
  } = {
478
482
  expanded: false,
479
483
  isPartial: true,
@@ -491,6 +495,10 @@ export class ToolExecutionComponent extends Container {
491
495
  toolCallId?: string,
492
496
  ) {
493
497
  super();
498
+ Object.defineProperty(this.#renderState, "elapsedMs", {
499
+ get: () => this.#elapsedMs(this.#nativeStatus()),
500
+ enumerable: true,
501
+ });
494
502
  ensureThemeSync();
495
503
  this.#toolCallId = toolCallId;
496
504
  this.#toolName = toolName;
@@ -772,6 +780,32 @@ export class ToolExecutionComponent extends Container {
772
780
  this.#renderState.spinnerFrame = undefined;
773
781
  }
774
782
  }
783
+ this.#updateElapsedTicker(isBackgroundAsyncRunning);
784
+ }
785
+
786
+ /**
787
+ * Repaint once a second while a `tickElapsed` renderer's call executes, so a
788
+ * quiet command's elapsed time advances without the 30fps spinner ticker.
789
+ */
790
+ #updateElapsedTicker(isBackgroundAsyncRunning: boolean): void {
791
+ const needsTicker =
792
+ this.#renderer?.tickElapsed === true &&
793
+ this.#executionStarted &&
794
+ this.#isPartial &&
795
+ !this.#sealed &&
796
+ !isBackgroundAsyncRunning &&
797
+ !isNativeRendering();
798
+ if (needsTicker && !this.#elapsedTicker) {
799
+ this.#elapsedTicker = setInterval(() => this.#ui.requestComponentRender(this), 1000);
800
+ } else if (!needsTicker) {
801
+ this.#stopElapsedTicker();
802
+ }
803
+ }
804
+
805
+ #stopElapsedTicker(): void {
806
+ if (!this.#elapsedTicker) return;
807
+ clearInterval(this.#elapsedTicker);
808
+ this.#elapsedTicker = undefined;
775
809
  }
776
810
 
777
811
  /**
@@ -917,6 +951,7 @@ export class ToolExecutionComponent extends Container {
917
951
  this.#renderState.spinnerFrame = undefined;
918
952
  }
919
953
  this.#stopTodoStrikeAnimation();
954
+ this.#stopElapsedTicker();
920
955
  this.#previewReady?.resolve();
921
956
  }
922
957
 
@@ -7,6 +7,7 @@ import type { NativeNode } from "../native/node";
7
7
  import { isNativeSettled, settleNative } from "../native/settle";
8
8
  import { isUsageRowBlock } from "../overlays/usage-row";
9
9
  import { isToolActivityComponent } from "./tool-activity";
10
+ import { directPlacementLift, ImageProtocol, iterm2ImagePayload, TERMINAL } from "../terminal-capabilities";
10
11
 
11
12
  /** Shared animation time supplied by the constrained transcript root. */
12
13
  export interface AnimationFrame {
@@ -118,6 +119,14 @@ interface RowSplit {
118
119
  readonly width: number;
119
120
  readonly hash: number | bigint;
120
121
  readonly text: string;
122
+ /**
123
+ * Inline images in the committed rows, in order, each with its payload and
124
+ * the length of `text` before it. Their reserved rows carry no content, so
125
+ * relocating by `text` alone would stop above an image and make the
126
+ * archived image live again at another width; payload and offset tell the
127
+ * archived image apart from a replacement or one moved elsewhere.
128
+ */
129
+ readonly images: readonly { readonly payload: string; readonly offset: number }[];
121
130
  /** Seam of the last render it was located in; renders are memoized per frame. */
122
131
  located?: { readonly rendered: readonly string[]; readonly count: number };
123
132
  }
@@ -180,7 +189,33 @@ function contentOf(rows: readonly string[]): string {
180
189
  */
181
190
  function rowSplit(scrollback: readonly string[], rows: number, width: number): RowSplit {
182
191
  const committed = scrollback.slice(0, rows);
183
- return { rows, width, hash: Bun.hash(committed.join("\n")), text: contentOf(committed) };
192
+ const images: { payload: string; offset: number }[] = [];
193
+ let text = "";
194
+ for (const row of committed) {
195
+ if (TERMINAL.isImageLine(row)) images.push({ payload: iterm2ImagePayload(row) ?? row, offset: text.length });
196
+ text += contentOf([row]);
197
+ }
198
+ return { rows, width, hash: Bun.hash(committed.join("\n")), text, images };
199
+ }
200
+
201
+ /**
202
+ * `cut` moved to just below an image it would split, on terminals that cannot
203
+ * draw part of an image (iTerm2's inline images). There the image row draws
204
+ * the whole picture upward over its reserved rows; once some of those rows are
205
+ * in scrollback, the picture has nowhere to go and is dropped, so the image
206
+ * must reach scrollback whole. Kitty and SIXEL draw the visible part instead.
207
+ */
208
+ function cutBelowWholeImage(rows: readonly string[], cut: number): number {
209
+ const protocol = TERMINAL.imageProtocol;
210
+ if (protocol === null || protocol === ImageProtocol.Kitty || protocol === ImageProtocol.Sixel) return cut;
211
+ // Only reserved rows sit between a cut inside an image and its image row,
212
+ // so the first image row at or below the cut decides.
213
+ for (let index = Math.max(0, cut); index < rows.length; index++) {
214
+ const row = rows[index]!;
215
+ if (!TERMINAL.isImageLine(row)) continue;
216
+ return index - directPlacementLift(row) < cut ? index + 1 : cut;
217
+ }
218
+ return cut;
184
219
  }
185
220
 
186
221
  /** Whether `prefix` matches `rows` byte-for-byte from the top. */
@@ -819,13 +854,19 @@ export class TranscriptContainer extends Container {
819
854
  // screen; the rest stays live, so a later shrink of the chrome or the
820
855
  // tail refills the screen from live rows instead of opening a band no
821
856
  // frame can fill (rows still on screen cannot return from scrollback).
857
+ // One exception: on a terminal that cannot draw part of an image, a cut
858
+ // inside an image moves below it (cutBelowWholeImage), so up to that
859
+ // image's height of on-screen rows commits early. A later shrink can then
860
+ // open a band that tall, which beats losing the image.
822
861
  let split: { index: number; rows: readonly string[]; split: RowSplit } | undefined;
823
862
  if (policy === "pressure" && keep > 0 && limit === this.#frontier + keep) {
824
863
  const boundary = live[keep - 1]!;
825
864
  const visible = room - tailRows - (tailRows > 0 ? 1 : 0);
826
865
  if (boundary.entry.state === "settled" && visible > 0) {
827
866
  const offer = this.#splitOffer(boundary.entry, width, visible);
828
- if (offer !== undefined) {
867
+ if (offer === "whole") {
868
+ limit = boundary.index + 1;
869
+ } else if (offer !== undefined) {
829
870
  split = { index: boundary.index, ...offer };
830
871
  limit = boundary.index;
831
872
  }
@@ -868,18 +909,23 @@ export class TranscriptContainer extends Container {
868
909
  * The head rows of settled `entry` beyond its committed prefix that leave
869
910
  * `visible` rows live, or undefined when nothing would remain committed
870
911
  * past that prefix or the scrollback render would not line up row for row.
912
+ * `"whole"` when keeping an image whole moves the cut to the block's end:
913
+ * the block then retires in full, with the blank row that separates it from
914
+ * the next block, which a split never carries.
871
915
  */
872
916
  #splitOffer(
873
917
  entry: TranscriptEntry,
874
918
  width: number,
875
919
  visible: number,
876
- ): { rows: readonly string[]; split: RowSplit } | undefined {
920
+ ): { rows: readonly string[]; split: RowSplit } | "whole" | undefined {
877
921
  const measured = this.#measuredRows(entry, width);
878
922
  const start = this.#emittedRowCount(entry, width);
879
- const total = measured.length - visible;
880
- if (total <= start) return undefined;
923
+ const cut = measured.length - visible;
924
+ if (cut <= start) return undefined;
881
925
  const scrollback = renderForScrollback(() => this.#renderEntry(entry, width));
882
926
  if (scrollback.length !== measured.length) return undefined;
927
+ const total = cutBelowWholeImage(scrollback, cut);
928
+ if (total >= scrollback.length) return "whole";
883
929
  return { rows: scrollback.slice(start, total), split: rowSplit(scrollback, total, width) };
884
930
  }
885
931
 
@@ -1096,13 +1142,40 @@ export class TranscriptContainer extends Container {
1096
1142
  count = split.rows;
1097
1143
  } else {
1098
1144
  let consumed = 0;
1099
- while (count < rendered.length && consumed < split.text.length) {
1100
- const row = contentOf([rendered[count]!]);
1101
- // An empty-content row matches at any offset, so the walk cannot
1102
- // tell it apart from a revealed row history never received.
1103
- if (row.length === 0 || !split.text.startsWith(row, consumed)) break;
1104
- consumed += row.length;
1105
- count++;
1145
+ let image = 0;
1146
+ while (count < rendered.length && (consumed < split.text.length || image < split.images.length)) {
1147
+ const row = rendered[count]!;
1148
+ const content = contentOf([row]);
1149
+ if (content.length > 0) {
1150
+ if (!split.text.startsWith(content, consumed)) break;
1151
+ consumed += content.length;
1152
+ count++;
1153
+ continue;
1154
+ }
1155
+ // An empty-content row matches at any offset, so the walk cannot tell it
1156
+ // apart from a revealed row history never received; only the next
1157
+ // archived image carries the seam on, through its own reserved rows to
1158
+ // its row, whatever its height here.
1159
+ let index = count;
1160
+ while (
1161
+ index < rendered.length &&
1162
+ !TERMINAL.isImageLine(rendered[index]!) &&
1163
+ contentOf([rendered[index]!]).length === 0
1164
+ ) {
1165
+ index++;
1166
+ }
1167
+ const imageRow = rendered[index];
1168
+ if (
1169
+ imageRow === undefined ||
1170
+ !TERMINAL.isImageLine(imageRow) ||
1171
+ (iterm2ImagePayload(imageRow) ?? imageRow) !== split.images[image]?.payload ||
1172
+ consumed !== split.images[image]?.offset ||
1173
+ index - count !== directPlacementLift(imageRow)
1174
+ ) {
1175
+ break;
1176
+ }
1177
+ count = index + 1;
1178
+ image++;
1106
1179
  }
1107
1180
  }
1108
1181
  split.located = { rendered, count };
@@ -4420,7 +4420,7 @@ export class Markdown implements Component {
4420
4420
  * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
4421
4421
  * consistently with the rest of the renderer.
4422
4422
  */
4423
- #wrapCellText(text: string, maxWidth: number): string[] {
4423
+ #wrapCellText(text: string, maxWidth: number, clampOversized = true): string[] {
4424
4424
  const cellWidth = Math.max(1, maxWidth);
4425
4425
  // Wrap the whole cell in one call so wrapTextWithAnsi() balances OSC 8
4426
4426
  // hyperlink state across explicit newlines (e.g. `<br>` rendered as \n);
@@ -4435,7 +4435,14 @@ export class Markdown implements Component {
4435
4435
  // (e.g. mdCode) would bleed into the "│" and the following cells.
4436
4436
  // Terminate each line at default fg, clearing bold/italic but keeping
4437
4437
  // any ambient background (message-bg rendering) intact.
4438
- return wrapped.map(line => `${line}\x1b[22m\x1b[23m\x1b[39m`);
4438
+ // A wide glyph can exceed a one-cell column without wrapping (a 2-cell
4439
+ // emoji in a column squeezed to 1), so clamp here before the row is
4440
+ // spliced between borders; otherwise the overwide row is re-wrapped and
4441
+ // the trailing border lands on its own line.
4442
+ return wrapped.map(line => {
4443
+ const fitted = clampOversized && visibleWidth(line) > cellWidth ? truncateToWidth(line, cellWidth) : line;
4444
+ return `${fitted}\x1b[22m\x1b[23m\x1b[39m`;
4445
+ });
4439
4446
  }
4440
4447
 
4441
4448
  /**
@@ -4626,7 +4633,8 @@ export class Markdown implements Component {
4626
4633
  if (rowCellLines[openCell].length > settledLineCount) {
4627
4634
  const width = columnWidths[openCell];
4628
4635
  const kept = rowCellLines[openCell].slice(0, settledLineCount);
4629
- const last = width > 1 ? (this.#wrapCellText(kept[settledLineCount - 1], width - 1)[0] ?? "") : "";
4636
+ const last =
4637
+ width > 1 ? (this.#wrapCellText(kept[settledLineCount - 1], width - 1, false)[0] ?? "") : "";
4630
4638
  kept[settledLineCount - 1] = `${visibleWidth(last) < width ? last : ""}…`;
4631
4639
  rowCellLines[openCell] = kept;
4632
4640
  }