jeo-code 0.7.41 → 0.7.43
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 +27 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +36 -1
- package/src/ai/model-manager.ts +12 -3
- package/src/commands/launch.ts +5 -1
- package/src/tui/app.ts +53 -4
- package/src/tui/components/image-preview.ts +49 -0
- package/src/tui/components/width.ts +9 -0
- package/src/tui/terminal-image.ts +363 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.7.43] - 2026-07-06
|
|
10
|
+
_TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`src/tui/terminal-image.ts`** — protocol detection (`detectImageProtocol`, env/TTY-signal based: `KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`/`WEZTERM_PANE`/`ITERM_SESSION_ID`/`TERM_PROGRAM`, plus a `$TERM` fallback for tmux/screen-wrapped kitty sessions), dependency-free dimension parsers for PNG/JPEG/GIF/WEBP/BMP (own implementations against each format's public header spec — not ported from gjc's native Rust decoder), aspect-ratio-preserving cell fitting (`fitImageToCells`, never upscales), and encoders for both protocols (`encodeKittyImage` — chunked at the spec's 4096-byte boundary for large payloads; `encodeIterm2Image` — cell-based `width=`/`height=`, never `"auto"`, so jeo's fit math always agrees with what the terminal actually draws). `JEO_IMAGE_PROTOCOL=kitty|iterm2|none` forces a protocol; `JEO_NO_IMAGE_PREVIEW=1` disables inline rendering outright (same shape as `JEO_NO_MULTILINE`/`JEO_NO_MEMORY`).
|
|
14
|
+
- **`src/tui/components/image-preview.ts`** — bridges `ImageAttachment[]` to the protocol layer: renders each attachment as an inline picture when the terminal/format combination supports it, else a dim `⧉ [mediaType] WxH` text caption (kitty's `f=100` decodes PNG only — a JPEG/GIF/WEBP/BMP attachment on a kitty-protocol terminal falls back to caption; iTerm2 decodes any host-native format, so those always render).
|
|
15
|
+
- **`LaunchTui.flushUserCard(text, images?)`** (`src/tui/app.ts`) — the turn-starting user card now accepts the turn's image attachments and, in inline mode on a supporting terminal, flushes them into scrollback via a new `flushImageAttachments` that bypasses `appendLedger`'s width-wrap path entirely (a raw base64 image escape is not text — `wrapTextWithAnsi`/`visibleWidth` would measure its payload as thousands of display columns and mangle it mid-sequence). Wired from `runTurn` in `src/commands/launch.ts` for the real submitted prompt (a skill run's suppressed/compact card, which never carried the user's own attachments, is unaffected).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **`truncateToWidth`** (`src/tui/components/width.ts`) now treats an inline-image escape line (detected via the new `isImageEscapeLine`) as opaque and returns it unchanged — a defensive fix for every current and future call site in the truncation pipeline (`terminal.ts#truncate`, the REPL's `logLines`, `Renderer.render`'s live-frame diff): naive column-counting would otherwise slice into a base64 payload mid-sequence, corrupting the image or leaving an unterminated escape that hangs the terminal waiting for its `ESC \`/BEL terminator.
|
|
19
|
+
|
|
20
|
+
### Verified
|
|
21
|
+
- `bun run typecheck` clean; `bun test` 2424 pass / 0 fail (34 new: `test/terminal-image.test.ts` — protocol detection incl. env-override/disable/tmux-fallback matrix, all 5 dimension parsers against real per-format headers plus malformed-input rejection, cell-fit aspect-ratio math, both encoders incl. kitty's >4096-byte chunking, the `isImageEscapeLine` guard, and the full `renderInlineImage` row-cursor construction proved line-by-line for both protocols; `test/image-preview.test.ts` — multi-attachment stacking and mixed protocol-support fallback; 3 new `LaunchTui.flushUserCard` cases covering a rendered image, a suppressed-terminal fallback with zero escape leakage, and the already-finished no-op).
|
|
22
|
+
- Manual end-to-end smoke test against the real attached terminal (kitty): generated a genuine 4x4 PNG (real zlib-deflated IDAT, not just a magic-byte fixture), ran it through the actual `renderInlineImage` → wrote the resulting kitty APC sequence to `/dev/tty` — completed without hanging or corrupting the terminal. Repeated with an iTerm2 OSC 1337 sequence sent to the same (non-iTerm2) terminal to confirm an unsupported protocol degrades silently (BEL-terminated, ignored) rather than leaking garbage.
|
|
23
|
+
|
|
24
|
+
## [0.7.42] - 2026-07-06
|
|
25
|
+
_Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too._
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **The interactive streaming model call had no absolute wall-clock — a connected-but-never-terminating stream hung "thinking" forever.** Unlike `manager.call()`/the non-stream fallback (both wrapped in a hard ~300s `withTimeout`), `manager.stream()` (`src/ai/model-manager.ts`) only armed its overall deadline when `JEO_STREAM_MAX_MS` was explicitly set — off by default — and its idle watchdog re-arms on ANY wire activity, including SSE keepalive/ping bytes and reasoning deltas. A stream that stays connected and periodically emits *something* (provider/proxy keepalives, an endless-reasoning response, a proxy that never forwards the terminal stop event) could re-arm that watchdog forever and never resolve or reject. `streamMaxMs()` now defaults to the same ~300s ceiling `callTimeoutMs()` already uses when `JEO_STREAM_MAX_MS` is unset, giving the streaming path parity with the non-streaming path; explicit overrides (including `0` to disable, for genuinely long-running reasoning use cases) are unchanged.
|
|
29
|
+
- **The `JEO_TURN_MAX_MS` stall budget could never interrupt a hang inside a single blocked model call — the exact scenario it exists to catch.** It was implemented purely as a passive top-of-loop check (`Date.now() - lastProgressAt > turnBudgetMs`); with no armed timer anywhere in `engine.ts`, the check could only run *between* steps — a hang blocked inside one `await` on the model call (e.g. the non-terminating stream above) meant the loop never re-iterated and the budget never fired, even after 30+ minutes. `runAgentLoop` now arms a real `setTimeout` for the remaining budget immediately before each step's model call, aborting a dedicated controller (composed with the turn's existing external signal) when it fires; the timer is cleared and re-armed on every genuine progress reset (executed tool step, mid-turn steering) so a legitimately long multi-step turn is never prematurely cut off, and an internal stall-abort surfaces through the exact same error path as an external Esc/Ctrl-C cancel.
|
|
30
|
+
- **(Defensive, tmux-specific) Live TUI frame writes are now backpressure-aware.** `LaunchTui`'s ~120ms frame tick wrote directly to `process.stdout` every beat; under `--tmux`, `process.stdout` is the tmux pane pty drained by the tmux *server*, and POSIX TTY writes are synchronous — a backpressured/slow-draining tmux client could block the write and freeze the event loop entirely (spinner, timers, and the stall-budget check all stop simultaneously, on whatever phase — "thinking" or "executing" — was current). The frame tick now checks `write()`'s boolean return value and skips scheduled frames while backpressured, resuming automatically on the stream's `'drain'` event, instead of piling up or blocking on synchronous writes. No tmux-detection branching — this is a generic backpressure-safe write pattern that helps regardless of what's on the other end of stdout.
|
|
31
|
+
|
|
32
|
+
### Verified
|
|
33
|
+
- `bun run typecheck` clean; full suite green.
|
|
34
|
+
- New/updated coverage: `test/round-b.test.ts` (`streamMaxMs()` default/override/disable contract, plus a keepalive-forever stream aborted once the deadline elapses), `test/cycle-and-turn-budget.test.ts` (a never-resolving model call force-aborted once the remaining stall budget elapses, and a regression guard that a normal long multi-step turn is never prematurely aborted), `test/tui-app.test.ts` (the frame tick skips beats under simulated backpressure and resumes on `'drain'`).
|
|
35
|
+
|
|
9
36
|
## [0.7.41] - 2026-07-06
|
|
10
37
|
_Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success._
|
|
11
38
|
|
package/README.ja.md
CHANGED
|
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
259
259
|
## 変更履歴 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
263
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
262
264
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
263
265
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
264
266
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
265
|
-
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
266
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -259,11 +259,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
259
259
|
## 변경 이력 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
263
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
262
264
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
263
265
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
264
266
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
265
|
-
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
266
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -261,11 +261,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
261
261
|
## Changelog
|
|
262
262
|
|
|
263
263
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
264
|
+
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
265
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
264
266
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
265
267
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
266
268
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
267
|
-
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
268
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
269
269
|
|
|
270
270
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
271
271
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
259
259
|
## 更新日志 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
263
|
+
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
262
264
|
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
263
265
|
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
264
266
|
- **[0.7.39]** (2026-07-05) — Fixes an unrecovered Anthropic refusal on Claude Fable 5: the new `stop_details.category: "reasoning_extraction"` refusal shape wasn't recognized, so the turn died with a raw, unfriendly error instead of engaging jeo's existing context-reset refusal-recovery ladder.
|
|
265
|
-
- **[0.7.38]** (2026-07-05) — Ponytail review of 0.7.37's Telegram daemon: closes a real trust-boundary gap (any Telegram user could steer/cancel subagents, not just the paired chat) and a `getUpdates`-failure hot-loop, plus bloat/style cleanup — no new surface area, no config changes.
|
|
266
|
-
- **[0.7.37]** (2026-07-05) — Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface only — no forum topics, no inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -330,6 +330,18 @@ export function turnMaxMs(env: Record<string, string | undefined> = process.env)
|
|
|
330
330
|
}
|
|
331
331
|
return 30 * 60 * 1000;
|
|
332
332
|
}
|
|
333
|
+
/** Minimum delay (ms) before a re-armed stall-interrupt timer may fire — guards against
|
|
334
|
+
* a near-zero remaining budget scheduling a same-tick abort that would race the call
|
|
335
|
+
* it is meant to protect. */
|
|
336
|
+
const MIN_STALL_ARM_MS = 1_000;
|
|
337
|
+
|
|
338
|
+
/** Combine the turn's external cancel signal (Esc/Ctrl-C) with an internal one. Mirrors
|
|
339
|
+
* the composeAbort/boundedSignal pattern already used in src/ai/model-manager.ts and
|
|
340
|
+
* src/auth/flows/google-project.ts — kept local since neither is exported. */
|
|
341
|
+
function composeAbortSignal(outer: AbortSignal | undefined, inner: AbortSignal): AbortSignal {
|
|
342
|
+
if (!outer) return inner;
|
|
343
|
+
return typeof AbortSignal.any === "function" ? AbortSignal.any([outer, inner]) : inner;
|
|
344
|
+
}
|
|
333
345
|
|
|
334
346
|
|
|
335
347
|
/** Levenshtein distance (small inputs: tool/command names). */
|
|
@@ -408,6 +420,26 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
408
420
|
const turnBudgetMs = turnMaxMs();
|
|
409
421
|
// Reset on every executed tool step and on mid-turn steering.
|
|
410
422
|
let lastProgressAt = turnStartedAt;
|
|
423
|
+
// Real timer-based interrupt backing the passive check above: JEO_TURN_MAX_MS alone
|
|
424
|
+
// only re-evaluates at the TOP of the while loop, so it cannot fire while the loop is
|
|
425
|
+
// parked inside a single blocked model-call await (the exact "genuinely forever" hang
|
|
426
|
+
// this guards against — a stream that never resolves/rejects). Armed fresh before each
|
|
427
|
+
// model call for the REMAINING budget and disarmed as soon as that call settles, so it
|
|
428
|
+
// naturally re-arms on every lastProgressAt reset and never fires after the turn ends.
|
|
429
|
+
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
430
|
+
const armStallAbort = (): AbortSignal | undefined => {
|
|
431
|
+
if (turnBudgetMs <= 0) return opts.signal; // 0 disables the stall budget entirely
|
|
432
|
+
const ctrl = new AbortController();
|
|
433
|
+
const remaining = Math.max(MIN_STALL_ARM_MS, turnBudgetMs - (Date.now() - lastProgressAt));
|
|
434
|
+
stallTimer = setTimeout(() => {
|
|
435
|
+
ctrl.abort(new Error(`no tool progress for ${Math.round(turnBudgetMs / 60_000)}m — turn stall budget (JEO_TURN_MAX_MS) exceeded without done`));
|
|
436
|
+
}, remaining);
|
|
437
|
+
return composeAbortSignal(opts.signal, ctrl.signal);
|
|
438
|
+
};
|
|
439
|
+
const disarmStallAbort = () => {
|
|
440
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
441
|
+
stallTimer = undefined;
|
|
442
|
+
};
|
|
411
443
|
// "steps" | "time" — drives honest wording in the consolidation message.
|
|
412
444
|
let stopKind: "steps" | "time" = "steps";
|
|
413
445
|
let step = 1;
|
|
@@ -624,6 +656,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
624
656
|
ev.onReasoningArtifactStream?.(a);
|
|
625
657
|
};
|
|
626
658
|
let responseText: string;
|
|
659
|
+
const stepSignal = armStallAbort();
|
|
627
660
|
try {
|
|
628
661
|
responseText = await invokeCallLlm(history, {
|
|
629
662
|
jsonMode: true,
|
|
@@ -636,7 +669,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
636
669
|
model: opts.model,
|
|
637
670
|
maxTokens: opts.maxTokens,
|
|
638
671
|
reasoningEffort: opts.reasoningEffort,
|
|
639
|
-
signal:
|
|
672
|
+
signal: stepSignal,
|
|
640
673
|
sessionKey: opts.sessionKey,
|
|
641
674
|
onUsage: u => { acc.inputTokens += u.inputTokens ?? 0; acc.outputTokens += u.outputTokens ?? 0; sawUsage = true; },
|
|
642
675
|
onToken,
|
|
@@ -652,7 +685,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
652
685
|
ev.onNotice?.(`${what} — auto-retry #${attempt} in ${wait}s`);
|
|
653
686
|
},
|
|
654
687
|
});
|
|
688
|
+
disarmStallAbort(); // call settled successfully — the stall interrupt no longer applies
|
|
655
689
|
} catch (err) {
|
|
690
|
+
disarmStallAbort(); // clear before classifying: a fired stall-abort must not linger past this call
|
|
656
691
|
// Reactive context recovery: trim older tool results in place and retry the
|
|
657
692
|
// SAME step once. The provider's overflow signal beats the local estimate;
|
|
658
693
|
// a second overflow (or nothing left to trim) surfaces the friendly error.
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -576,11 +576,20 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, watchdog?: Promise<nev
|
|
|
576
576
|
return Promise.race([iter.next(), watchdog]);
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
-
/**
|
|
579
|
+
/** Overall stream wall-clock from the environment. Defaults to the same hard bound as
|
|
580
|
+
* callTimeoutMs() (DEFAULT_CALL_TIMEOUT_MS) so the streaming path has parity with the
|
|
581
|
+
* non-streaming call()/fallback path — a stream that stays connected and keeps emitting
|
|
582
|
+
* SOMETHING (SSE keepalives, reasoning deltas) re-arms the per-chunk idle watchdog
|
|
583
|
+
* forever and would otherwise never resolve or reject. JEO_STREAM_MAX_MS explicitly set
|
|
584
|
+
* to a positive number overrides the default; explicitly set to 0 disables the overall
|
|
585
|
+
* deadline entirely (mirrors JEO_TURN_MAX_MS's own 0-disables convention) for advanced
|
|
586
|
+
* users with genuinely long-running legitimate reasoning models. */
|
|
580
587
|
export function streamMaxMs(env?: Record<string, string | undefined>): number | undefined {
|
|
581
588
|
const raw = jeoEnv("STREAM_MAX_MS", env);
|
|
582
|
-
|
|
583
|
-
|
|
589
|
+
if (raw === undefined || raw === "") return DEFAULT_CALL_TIMEOUT_MS;
|
|
590
|
+
const n = parseInt(raw, 10);
|
|
591
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT_CALL_TIMEOUT_MS;
|
|
592
|
+
return n === 0 ? undefined : n;
|
|
584
593
|
}
|
|
585
594
|
|
|
586
595
|
/** Per-chunk idle cap (ms) from the environment, falling back to the built-in default.
|
package/src/commands/launch.ts
CHANGED
|
@@ -938,7 +938,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
938
938
|
// in scrollback. opts.userCard overrides it: null suppresses the card entirely
|
|
939
939
|
// (skill runs), a string shows a compact label instead of the raw input.
|
|
940
940
|
const cardText = opts?.userCard === undefined ? userInput : opts.userCard;
|
|
941
|
-
|
|
941
|
+
// Images render alongside the card only for the real prompt (undefined
|
|
942
|
+
// userCard) — a skill run's compact/suppressed card never carried the
|
|
943
|
+
// user's own attachments in the first place (buildSkillTask calls above
|
|
944
|
+
// never pass `images`), so there is nothing to show either way.
|
|
945
|
+
if (tui && cardText && cardText.trim()) tui.flushUserCard(cardText, opts?.userCard === undefined ? images : undefined);
|
|
942
946
|
tui?.setContextUsage(historyTokens(history), contextTokens);
|
|
943
947
|
|
|
944
948
|
// Per-turn steering inbox (gjc parity): additional queries typed mid-turn land
|
package/src/tui/app.ts
CHANGED
|
@@ -38,6 +38,9 @@ import { formatDuration, formatUsage } from "./components/duration";
|
|
|
38
38
|
import { renderHud, type JeoPhase } from "./components/hud";
|
|
39
39
|
import { formatTodoWriteCard } from "./components/todo-card";
|
|
40
40
|
import { renderInputBox, type HighlightRange } from "./components/input-box";
|
|
41
|
+
import { renderImageAttachments } from "./components/image-preview";
|
|
42
|
+
import { detectImageProtocol } from "./terminal-image";
|
|
43
|
+
import type { ImageAttachment } from "../ai/types";
|
|
41
44
|
import { jeoEnv } from "../util/env";
|
|
42
45
|
import chalk from "chalk";
|
|
43
46
|
|
|
@@ -54,7 +57,7 @@ export interface LaunchTuiOptions {
|
|
|
54
57
|
/** Resolved provider name for the footer (anthropic / openai / gemini / ollama). */
|
|
55
58
|
provider?: string;
|
|
56
59
|
sessionId?: string;
|
|
57
|
-
write?: (s: string) => void;
|
|
60
|
+
write?: (s: string) => void | boolean;
|
|
58
61
|
/** Step budget for this turn; drives the footer's `step N/M` denominator. */
|
|
59
62
|
maxSteps?: number;
|
|
60
63
|
/** Whether to treat the output as a TTY (drives alt-screen use). Defaults to isTTY(). */
|
|
@@ -213,6 +216,13 @@ export class LaunchTui {
|
|
|
213
216
|
private mutationGuarded = false;
|
|
214
217
|
private finished = false;
|
|
215
218
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
219
|
+
// Backpressure-aware live frame emission: process.stdout.write() returns false when the
|
|
220
|
+
// internal buffer is over the high-water mark (e.g. a tmux client draining slowly, or
|
|
221
|
+
// any slow consumer on the other end of stdout). Skipping scheduled frames while this is
|
|
222
|
+
// true — instead of piling up an unbounded queue of synchronous writes or blocking the
|
|
223
|
+
// event loop waiting on 'drain' — keeps the render loop responsive and non-blocking.
|
|
224
|
+
// Generic and tmux-agnostic: helps regardless of what's downstream of stdout.
|
|
225
|
+
private stdoutBackpressured = false;
|
|
216
226
|
// Resize handling (gjc-style responsiveness): repaint IMMEDIATELY on the first event
|
|
217
227
|
// so a deliberate resize never lags, then cap follow-ups to ~30fps so a drag-resize
|
|
218
228
|
// tracks the cursor live (instead of staying stale until the drag pauses — the lag a
|
|
@@ -345,7 +355,19 @@ export class LaunchTui {
|
|
|
345
355
|
private readonly thinkingLevel?: string;
|
|
346
356
|
|
|
347
357
|
constructor(opts: LaunchTuiOptions) {
|
|
348
|
-
|
|
358
|
+
// Backpressure-aware wrapper around the underlying writer (real stdout, or the
|
|
359
|
+
// caller-injected `write` used by tests). An explicit `false` return (Node/Bun stream
|
|
360
|
+
// convention: internal buffer over the high-water mark) arms the flag the live-frame
|
|
361
|
+
// interval checks; a real 'drain' event (asynchronous — never awaited here) clears it.
|
|
362
|
+
// A test double returning `undefined` (the existing convention throughout this file's
|
|
363
|
+
// tests) never trips the check, so non-backpressure-aware callers are unaffected.
|
|
364
|
+
const rawWrite = opts.write ?? ((s: string) => process.stdout.write(s));
|
|
365
|
+
this.write = (s: string) => {
|
|
366
|
+
if (rawWrite(s) === false && !this.stdoutBackpressured) {
|
|
367
|
+
this.stdoutBackpressured = true;
|
|
368
|
+
process.stdout.once("drain", () => { this.stdoutBackpressured = false; });
|
|
369
|
+
}
|
|
370
|
+
};
|
|
349
371
|
this.tty = opts.tty ?? isTTY();
|
|
350
372
|
this.inline = this.tty && jeoEnv("TUI_ALT_SCREEN") !== "1";
|
|
351
373
|
// Row reservation is only needed (and only safe) for the inline main-buffer frame;
|
|
@@ -768,13 +790,35 @@ export class LaunchTui {
|
|
|
768
790
|
|
|
769
791
|
/** Flush a `user` card into scrollback so a submitted query stays visible there
|
|
770
792
|
* (gjc parity), instead of only as the transient HUD turn-title / a status notice.
|
|
771
|
-
* Shared by the prompt that STARTS a turn and the mid-turn steering flush.
|
|
772
|
-
|
|
793
|
+
* Shared by the prompt that STARTS a turn and the mid-turn steering flush.
|
|
794
|
+
* `images` (clipboard/drag-drop/file-path attachments on THIS prompt) render as
|
|
795
|
+
* an inline picture directly below the card on a terminal that speaks the kitty
|
|
796
|
+
* or iTerm2 graphics protocol (gjc TUI-image parity); every other terminal, the
|
|
797
|
+
* alt screen, and non-TTY output keep the existing text-only card unchanged. */
|
|
798
|
+
flushUserCard(text: string, images?: ImageAttachment[]): void {
|
|
773
799
|
const t = (text ?? "").trim();
|
|
774
800
|
if (!t || this.finished) return;
|
|
775
801
|
const cols = Math.max(20, size().cols - 1);
|
|
776
802
|
const lines = this.renderUserCard(t, cols);
|
|
777
803
|
if (lines.length) this.appendLedger(lines.join("\n"), "card");
|
|
804
|
+
if (images?.length) this.flushImageAttachments(images, cols);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Render `images` (see {@link flushUserCard}) straight into scrollback via
|
|
808
|
+
* `insertAbove`, bypassing `appendLedger`'s width-wrap path: an inline image
|
|
809
|
+
* escape sequence is not text — `wrapTextWithAnsi`/`visibleWidth` would measure
|
|
810
|
+
* its base64 payload as thousands of display columns and mangle it. Inline mode
|
|
811
|
+
* only (alt screen has no scrollback to flush into; non-TTY has no terminal to
|
|
812
|
+
* draw into) — every other mode already shows the "N image(s) attached" note
|
|
813
|
+
* inline in the prompt text itself via `[image #N]` tags, unchanged. */
|
|
814
|
+
private flushImageAttachments(images: ImageAttachment[], cols: number): void {
|
|
815
|
+
if (!this.inline || this.finished) return;
|
|
816
|
+
const protocol = detectImageProtocol(process.env, this.tty);
|
|
817
|
+
const muted = this.theme.color ? chalk.dim : (s: string) => s;
|
|
818
|
+
const lines = renderImageAttachments(images, { cols, protocol, muted });
|
|
819
|
+
if (lines.length === 0) return;
|
|
820
|
+
this.recordActivity(`⧉ ${images.length} image(s) rendered`);
|
|
821
|
+
this.renderer.insertAbove(`${lines.join("\n")}\n`);
|
|
778
822
|
}
|
|
779
823
|
|
|
780
824
|
/** Mid-turn steering query → a `user` card in scrollback (accepted input that is
|
|
@@ -1040,6 +1084,11 @@ export class LaunchTui {
|
|
|
1040
1084
|
this.timer = setInterval(() => {
|
|
1041
1085
|
try {
|
|
1042
1086
|
this.tickCount++;
|
|
1087
|
+
// Backpressure: skip this scheduled frame entirely rather than piling up writes
|
|
1088
|
+
// or blocking on 'drain' — the tick simply resumes once the writer drains (see
|
|
1089
|
+
// the this.write wrapper above). The spinner/tickCount stay static for the
|
|
1090
|
+
// skipped beat(s), matching the frozen frame this defers presenting.
|
|
1091
|
+
if (this.stdoutBackpressured) return;
|
|
1043
1092
|
// Self-healing resync: every ~3s drop the differential baseline so the next
|
|
1044
1093
|
// draw rewrites EVERY line. Any screen corruption (stray child output, wheel
|
|
1045
1094
|
// noise, terminal glitches) is repaired automatically without user action.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-attached image → scrollback block. Bridges `ImageAttachment[]` (the
|
|
3
|
+
* clipboard/drag-drop/file-path attachments already supported by
|
|
4
|
+
* `util/file-attachment.ts` and `util/clipboard-image.ts`) to the inline-image
|
|
5
|
+
* escape sequences in `terminal-image.ts`, producing one stacked block ready for
|
|
6
|
+
* `Renderer.insertAbove` — gjc TUI-parity: jeo could already ATTACH an image to a
|
|
7
|
+
* turn, this is the missing DISPLAY half (the transcript previously showed only a
|
|
8
|
+
* `⧉ N image(s) attached` count, never the picture itself).
|
|
9
|
+
*/
|
|
10
|
+
import type { ImageAttachment } from "../../ai/types";
|
|
11
|
+
import { detectImageProtocol, renderInlineImage, type ImageProtocol } from "../terminal-image";
|
|
12
|
+
|
|
13
|
+
export interface ImagePreviewOptions {
|
|
14
|
+
/** Terminal columns available for the block (already margin-adjusted by the
|
|
15
|
+
* caller, same convention as every other card renderer in `tui/app.ts`). */
|
|
16
|
+
cols: number;
|
|
17
|
+
/** Cap on how many terminal rows a SINGLE image may occupy — keeps one huge
|
|
18
|
+
* screenshot from pushing the whole scrollback off-screen. */
|
|
19
|
+
maxRowsPerImage?: number;
|
|
20
|
+
/** Pre-detected protocol (tests / callers that already resolved it once per
|
|
21
|
+
* process). Defaults to live detection against `process.env`/`process.stdout`. */
|
|
22
|
+
protocol?: ImageProtocol;
|
|
23
|
+
/** Dim/muted painter applied to text captions (fallback path) so they read as
|
|
24
|
+
* secondary content, matching the rest of the TUI's caption styling. */
|
|
25
|
+
muted?: (s: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render every attachment as a stacked block of lines: an inline picture when the
|
|
30
|
+
* terminal protocol + format support it, else a dim text caption. Multiple
|
|
31
|
+
* attachments stack top-to-bottom, each keeping its own row reservation — see
|
|
32
|
+
* `terminal-image.ts#renderInlineImage` for why each image is self-contained
|
|
33
|
+
* (no dependency on being first/last in the stack).
|
|
34
|
+
*/
|
|
35
|
+
export function renderImageAttachments(images: ImageAttachment[], opts: ImagePreviewOptions): string[] {
|
|
36
|
+
if (images.length === 0) return [];
|
|
37
|
+
const protocol = opts.protocol ?? detectImageProtocol(process.env, !!process.stdout.isTTY);
|
|
38
|
+
const maxColumns = Math.max(10, Math.min(60, opts.cols - 4));
|
|
39
|
+
const maxRows = Math.max(4, opts.maxRowsPerImage ?? 20);
|
|
40
|
+
const muted = opts.muted ?? ((s: string) => s);
|
|
41
|
+
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
for (const img of images) {
|
|
44
|
+
const result = renderInlineImage(img, protocol, { maxColumns, maxRows });
|
|
45
|
+
if (result.rendered) lines.push(...result.lines);
|
|
46
|
+
else lines.push(...result.lines.map(muted));
|
|
47
|
+
}
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* color escapes verbatim (never counting them) so colored/CJK lines truncate and
|
|
10
10
|
* wrap without tearing an escape or miscounting a wide glyph.
|
|
11
11
|
*/
|
|
12
|
+
import { isImageEscapeLine } from "../terminal-image";
|
|
12
13
|
|
|
13
14
|
const TAB_STOP = 8;
|
|
14
15
|
// Sticky SGR matcher: scan a heavily color-escaped line in O(n) without slicing.
|
|
@@ -96,6 +97,14 @@ export function visibleWidth(s: string): number {
|
|
|
96
97
|
export function truncateToWidth(s: string, cols: number): string {
|
|
97
98
|
const limit = Math.max(0, cols);
|
|
98
99
|
if (limit === 0) return "";
|
|
100
|
+
// An inline terminal-image escape (Kitty APC / iTerm2 OSC 1337, see
|
|
101
|
+
// `../terminal-image.ts#isImageEscapeLine`) is not text: its "width" by a naive
|
|
102
|
+
// column count is the base64 payload length (thousands of columns), and slicing
|
|
103
|
+
// into the payload corrupts the image or leaves an unterminated escape that
|
|
104
|
+
// hangs the terminal waiting for its `ESC \\`/BEL terminator. The image was
|
|
105
|
+
// already fit to the caller's column budget when the sequence was BUILT, so the
|
|
106
|
+
// correct "truncation" here is always a no-op.
|
|
107
|
+
if (isImageEscapeLine(s)) return s;
|
|
99
108
|
// Fast path: no escapes, no wide chars, no tabs → plain slice by length.
|
|
100
109
|
if (!s.includes("\x1b") && !/[\t\u0300-\uffff]/.test(s) && !/[\u{10000}-\u{10ffff}]/u.test(s)) {
|
|
101
110
|
return s.length <= limit ? s : s.slice(0, limit);
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline terminal image protocols (gjc TUI-parity): detect whether the attached
|
|
3
|
+
* terminal can render a real bitmap inline, and encode `ImageAttachment` bytes into
|
|
4
|
+
* the matching escape sequence — Kitty graphics protocol (also implemented by
|
|
5
|
+
* Ghostty and WezTerm) or iTerm2's OSC 1337 inline-image protocol. Sixel is
|
|
6
|
+
* intentionally NOT implemented: producing a sixel stream from arbitrary PNG/JPEG
|
|
7
|
+
* bytes needs a pixel quantizer, which would be jeo's first native/binary
|
|
8
|
+
* dependency — every terminal jeo targets (kitty/ghostty/wezterm/iTerm2) already
|
|
9
|
+
* speaks one of the two protocols below, so a text caption is the correct fallback
|
|
10
|
+
* everywhere else (Terminal.app, most Linux console/VTE terminals, CI/non-TTY).
|
|
11
|
+
*
|
|
12
|
+
* Callers never need to know which protocol was chosen: {@link renderInlineImage}
|
|
13
|
+
* returns either a ready-to-write escape block or `null` (caller shows the caption).
|
|
14
|
+
*/
|
|
15
|
+
// Inlined (not imported from `./terminal`) to keep this module free to be imported
|
|
16
|
+
// FROM `./components/width` (see `isImageEscapeLine` below) without a cycle:
|
|
17
|
+
// `./terminal` itself imports `truncateToWidth` from `./components/width`.
|
|
18
|
+
function cursorUp(n: number): string {
|
|
19
|
+
return n > 0 ? `\x1b[${n}A` : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export enum ImageProtocol {
|
|
23
|
+
Kitty = "kitty",
|
|
24
|
+
Iterm2 = "iterm2",
|
|
25
|
+
None = "none",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type EnvLike = Record<string, string | undefined>;
|
|
29
|
+
|
|
30
|
+
export interface PixelDimensions {
|
|
31
|
+
widthPx: number;
|
|
32
|
+
heightPx: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CellPixelSize {
|
|
36
|
+
widthPx: number;
|
|
37
|
+
heightPx: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `JEO_IMAGE_PROTOCOL=kitty|iterm2|none` forces the protocol regardless of
|
|
41
|
+
* detection — the escape hatch for a misdetected terminal or a multiplexer that
|
|
42
|
+
* doesn't forward `TERM_PROGRAM`. Unrecognized values are ignored (fall through
|
|
43
|
+
* to detection) rather than silently disabling images. */
|
|
44
|
+
function forcedProtocol(env: EnvLike): ImageProtocol | undefined {
|
|
45
|
+
const raw = env.JEO_IMAGE_PROTOCOL?.trim().toLowerCase();
|
|
46
|
+
if (!raw) return undefined;
|
|
47
|
+
if (raw === "kitty") return ImageProtocol.Kitty;
|
|
48
|
+
if (raw === "iterm2" || raw === "iterm") return ImageProtocol.Iterm2;
|
|
49
|
+
if (raw === "none" || raw === "off" || raw === "0" || raw === "false") return ImageProtocol.None;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `JEO_NO_IMAGE_PREVIEW=1` disables inline image rendering outright (same shape
|
|
54
|
+
* as `JEO_NO_MULTILINE`/`JEO_NO_MEMORY`) — every attachment falls back to its text
|
|
55
|
+
* caption. Distinct from `JEO_IMAGE_PROTOCOL=none` only in intent/discoverability;
|
|
56
|
+
* both end at {@link ImageProtocol.None}. */
|
|
57
|
+
export function imagePreviewDisabled(env: EnvLike = process.env): boolean {
|
|
58
|
+
const v = env.JEO_NO_IMAGE_PREVIEW;
|
|
59
|
+
return v === "1" || v === "true";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect the inline-image protocol the attached terminal speaks, from environment
|
|
64
|
+
* signals alone (no terminal query round-trip — keeps startup synchronous and
|
|
65
|
+
* side-effect-free, matching how `detectColorLevel` works). `env`/`isTty` are
|
|
66
|
+
* injectable for tests.
|
|
67
|
+
*/
|
|
68
|
+
export function detectImageProtocol(env: EnvLike = process.env, isTty = false): ImageProtocol {
|
|
69
|
+
if (imagePreviewDisabled(env)) return ImageProtocol.None;
|
|
70
|
+
const forced = forcedProtocol(env);
|
|
71
|
+
if (forced !== undefined) return forced;
|
|
72
|
+
if (!isTty) return ImageProtocol.None;
|
|
73
|
+
|
|
74
|
+
if (env.KITTY_WINDOW_ID) return ImageProtocol.Kitty;
|
|
75
|
+
if (env.GHOSTTY_RESOURCES_DIR) return ImageProtocol.Kitty; // Ghostty speaks the kitty graphics protocol
|
|
76
|
+
if (env.WEZTERM_PANE) return ImageProtocol.Kitty; // WezTerm speaks the kitty graphics protocol
|
|
77
|
+
if (env.ITERM_SESSION_ID) return ImageProtocol.Iterm2;
|
|
78
|
+
|
|
79
|
+
const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
80
|
+
if (termProgram === "kitty") return ImageProtocol.Kitty;
|
|
81
|
+
if (termProgram === "ghostty" || termProgram === "wezterm") return ImageProtocol.Kitty;
|
|
82
|
+
if (termProgram === "iterm.app") return ImageProtocol.Iterm2;
|
|
83
|
+
|
|
84
|
+
// tmux/screen strip TERM_PROGRAM but usually preserve $TERM as "tmux-256color" /
|
|
85
|
+
// "screen.xterm-kitty" etc. — fall back to sniffing $TERM for a kitty signature
|
|
86
|
+
// (gjc parity: the same fallback it applies for tmux-wrapped kitty sessions).
|
|
87
|
+
const term = (env.TERM ?? "").toLowerCase();
|
|
88
|
+
if (term.includes("kitty")) return ImageProtocol.Kitty;
|
|
89
|
+
if ((term.startsWith("tmux") || term.startsWith("screen")) && env.KITTY_LISTEN_ON) return ImageProtocol.Kitty;
|
|
90
|
+
|
|
91
|
+
return ImageProtocol.None;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let cellPixelSizeOverride: CellPixelSize | undefined;
|
|
95
|
+
|
|
96
|
+
/** Cell pixel size used to convert a target column/row box into scaled pixels for
|
|
97
|
+
* the fit computation. jeo does not query the terminal for real font metrics (that
|
|
98
|
+
* needs a synchronous escape round-trip on startup, which risks hanging on a
|
|
99
|
+
* terminal that never answers) — 9x18 is the common monospace-at-default-size
|
|
100
|
+
* figure (matches gjc's default) and only affects the ASPECT-RATIO fit, not
|
|
101
|
+
* correctness: a wrong guess makes the preview a bit larger/smaller, never
|
|
102
|
+
* corrupts output. Overridable for unusually large/small fonts. */
|
|
103
|
+
export function getCellPixelSize(env: EnvLike = process.env): CellPixelSize {
|
|
104
|
+
if (cellPixelSizeOverride) return cellPixelSizeOverride;
|
|
105
|
+
const w = Number.parseInt(env.JEO_CELL_WIDTH_PX ?? "", 10);
|
|
106
|
+
const h = Number.parseInt(env.JEO_CELL_HEIGHT_PX ?? "", 10);
|
|
107
|
+
return {
|
|
108
|
+
widthPx: Number.isFinite(w) && w > 0 ? w : 9,
|
|
109
|
+
heightPx: Number.isFinite(h) && h > 0 ? h : 18,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Test-only: pin the cell size so fit-math assertions don't depend on env parsing. */
|
|
114
|
+
export function setCellPixelSizeForTest(size: CellPixelSize | undefined): void {
|
|
115
|
+
cellPixelSizeOverride = size;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** PNG: 8-byte signature + 4-byte length + "IHDR" (4 bytes) precede a big-endian
|
|
119
|
+
* `{width, height}` u32 pair — the format's own spec, fixed offsets 16 and 20. */
|
|
120
|
+
export function getPngDimensions(bytes: Uint8Array): PixelDimensions | null {
|
|
121
|
+
if (bytes.length < 24) return null;
|
|
122
|
+
if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return null;
|
|
123
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
124
|
+
return { widthPx: buf.readUInt32BE(16), heightPx: buf.readUInt32BE(20) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** JPEG: scan markers for the first SOFn (0xC0-0xC3, 0xC5-0xC7, 0xC9-0xCB, 0xCD-0xCF —
|
|
128
|
+
* excludes 0xC4/0xC8/0xCC, which are DHT/JPG/DAC, not frame headers); height/width
|
|
129
|
+
* are big-endian u16 at offsets 5/7 into that marker's segment. */
|
|
130
|
+
export function getJpegDimensions(bytes: Uint8Array): PixelDimensions | null {
|
|
131
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
|
132
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
133
|
+
let offset = 2;
|
|
134
|
+
while (offset + 9 < buf.length) {
|
|
135
|
+
if (buf[offset] !== 0xff) { offset++; continue; }
|
|
136
|
+
const marker = buf[offset + 1]!;
|
|
137
|
+
const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
138
|
+
if (isSof) return { heightPx: buf.readUInt16BE(offset + 5), widthPx: buf.readUInt16BE(offset + 7) };
|
|
139
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { offset += 2; continue; } // no-length markers
|
|
140
|
+
const segLen = buf.readUInt16BE(offset + 2);
|
|
141
|
+
if (segLen < 2) return null;
|
|
142
|
+
offset += 2 + segLen;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** GIF87a/89a: little-endian u16 width/height at fixed offsets 6/8 in the Logical
|
|
148
|
+
* Screen Descriptor, right after the 6-byte signature. */
|
|
149
|
+
export function getGifDimensions(bytes: Uint8Array): PixelDimensions | null {
|
|
150
|
+
if (bytes.length < 10) return null;
|
|
151
|
+
const sig = Buffer.from(bytes.slice(0, 6)).toString("ascii");
|
|
152
|
+
if (sig !== "GIF87a" && sig !== "GIF89a") return null;
|
|
153
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
154
|
+
return { widthPx: buf.readUInt16LE(6), heightPx: buf.readUInt16LE(8) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** WebP: RIFF container: dimensions live in the first sub-chunk, whose layout
|
|
158
|
+
* differs by codec — VP8 (lossy, 10-bit width/height at a fixed offset), VP8L
|
|
159
|
+
* (lossless, 14-bit packed into a little-endian u32), VP8X (extended, 24-bit
|
|
160
|
+
* little-endian triples, stored as size-1). */
|
|
161
|
+
export function getWebpDimensions(bytes: Uint8Array): PixelDimensions | null {
|
|
162
|
+
if (bytes.length < 30) return null;
|
|
163
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
164
|
+
if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP") return null;
|
|
165
|
+
const chunk = buf.toString("ascii", 12, 16);
|
|
166
|
+
if (chunk === "VP8 ") {
|
|
167
|
+
return { widthPx: buf.readUInt16LE(26) & 0x3fff, heightPx: buf.readUInt16LE(28) & 0x3fff };
|
|
168
|
+
}
|
|
169
|
+
if (chunk === "VP8L") {
|
|
170
|
+
if (bytes.length < 25) return null;
|
|
171
|
+
const bits = buf.readUInt32LE(21);
|
|
172
|
+
return { widthPx: (bits & 0x3fff) + 1, heightPx: ((bits >> 14) & 0x3fff) + 1 };
|
|
173
|
+
}
|
|
174
|
+
if (chunk === "VP8X") {
|
|
175
|
+
return {
|
|
176
|
+
widthPx: (buf[24]! | (buf[25]! << 8) | (buf[26]! << 16)) + 1,
|
|
177
|
+
heightPx: (buf[27]! | (buf[28]! << 8) | (buf[29]! << 16)) + 1,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** BMP: "BM" signature, then a DIB header whose size (u32 at offset 14) picks the
|
|
184
|
+
* layout — the legacy 12-byte BITMAPCOREHEADER stores 16-bit dimensions at
|
|
185
|
+
* offset 18/20; every later header (BITMAPINFOHEADER=40 and newer) stores 32-bit
|
|
186
|
+
* signed dimensions at 18/22 (a negative height means top-down — take `abs`). */
|
|
187
|
+
export function getBmpDimensions(bytes: Uint8Array): PixelDimensions | null {
|
|
188
|
+
if (bytes.length < 26 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null;
|
|
189
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
190
|
+
const dibSize = buf.readUInt32LE(14);
|
|
191
|
+
if (dibSize === 12) {
|
|
192
|
+
if (bytes.length < 22) return null;
|
|
193
|
+
return { widthPx: buf.readUInt16LE(18), heightPx: buf.readUInt16LE(20) };
|
|
194
|
+
}
|
|
195
|
+
if (bytes.length < 26) return null;
|
|
196
|
+
return { widthPx: buf.readInt32LE(18), heightPx: Math.abs(buf.readInt32LE(22)) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Dispatch to the matching parser by IANA media type; `null` when unrecognized or
|
|
200
|
+
* the bytes are too short/malformed to carry a header. */
|
|
201
|
+
export function getImageDimensions(bytes: Uint8Array, mediaType: string): PixelDimensions | null {
|
|
202
|
+
switch (mediaType) {
|
|
203
|
+
case "image/png": return getPngDimensions(bytes);
|
|
204
|
+
case "image/jpeg": return getJpegDimensions(bytes);
|
|
205
|
+
case "image/gif": return getGifDimensions(bytes);
|
|
206
|
+
case "image/webp": return getWebpDimensions(bytes);
|
|
207
|
+
case "image/bmp": return getBmpDimensions(bytes);
|
|
208
|
+
default: return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ImageFit {
|
|
213
|
+
columns: number;
|
|
214
|
+
rows: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Scale `dims` to fit within `maxColumns` x `maxRows` terminal cells, preserving
|
|
219
|
+
* aspect ratio (letterboxed, never cropped/stretched). Falls back to a 1:1
|
|
220
|
+
* guess (one column per ~1 cell-width of pixels) when `dims` is unknown so a
|
|
221
|
+
* dimension-less image (parser miss) still gets a sane box instead of a crash.
|
|
222
|
+
*/
|
|
223
|
+
export function fitImageToCells(dims: PixelDimensions | null, maxColumns: number, maxRows: number, cell: CellPixelSize): ImageFit {
|
|
224
|
+
const maxCols = Math.max(1, Math.floor(maxColumns));
|
|
225
|
+
const maxRowsClamped = Math.max(1, Math.floor(maxRows));
|
|
226
|
+
if (!dims || dims.widthPx <= 0 || dims.heightPx <= 0) {
|
|
227
|
+
return { columns: maxCols, rows: maxRowsClamped };
|
|
228
|
+
}
|
|
229
|
+
const maxWidthPx = maxCols * cell.widthPx;
|
|
230
|
+
const maxHeightPx = maxRowsClamped * cell.heightPx;
|
|
231
|
+
const scale = Math.min(maxWidthPx / dims.widthPx, maxHeightPx / dims.heightPx, 1);
|
|
232
|
+
const fittedWidthPx = dims.widthPx * scale;
|
|
233
|
+
const fittedHeightPx = dims.heightPx * scale;
|
|
234
|
+
return {
|
|
235
|
+
columns: Math.max(1, Math.min(maxCols, Math.round(fittedWidthPx / cell.widthPx))),
|
|
236
|
+
rows: Math.max(1, Math.min(maxRowsClamped, Math.ceil(fittedHeightPx / cell.heightPx))),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const KITTY_CHUNK_SIZE = 4096;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Encode PNG bytes (base64) as a kitty graphics protocol APC sequence, chunked at
|
|
244
|
+
* 4096 base64 bytes per the spec (a payload above that MUST be split across
|
|
245
|
+
* multiple `m=1`-continued transmissions, terminated by one `m=0`). `a=T`
|
|
246
|
+
* (transmit-and-display in one step), `f=100` (PNG — the only compressed format
|
|
247
|
+
* the protocol decodes itself), `q=2` (suppress the terminal's OK/error response,
|
|
248
|
+
* which jeo never reads).
|
|
249
|
+
*/
|
|
250
|
+
export function encodeKittyImage(base64Png: string, fit: ImageFit): string {
|
|
251
|
+
const params = `a=T,f=100,q=2,c=${fit.columns},r=${fit.rows}`;
|
|
252
|
+
if (base64Png.length <= KITTY_CHUNK_SIZE) {
|
|
253
|
+
return `\x1b_G${params};${base64Png}\x1b\\`;
|
|
254
|
+
}
|
|
255
|
+
const parts: string[] = [];
|
|
256
|
+
for (let offset = 0; offset < base64Png.length; offset += KITTY_CHUNK_SIZE) {
|
|
257
|
+
const chunk = base64Png.slice(offset, offset + KITTY_CHUNK_SIZE);
|
|
258
|
+
const isFirst = offset === 0;
|
|
259
|
+
const isLast = offset + KITTY_CHUNK_SIZE >= base64Png.length;
|
|
260
|
+
const head = isFirst ? `${params},m=${isLast ? 0 : 1}` : `m=${isLast ? 0 : 1}`;
|
|
261
|
+
parts.push(`\x1b_G${head};${chunk}\x1b\\`);
|
|
262
|
+
}
|
|
263
|
+
return parts.join("");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Encode arbitrary image bytes (base64, any format iTerm2's host OS decodes —
|
|
268
|
+
* PNG/JPEG/GIF all included) as an OSC 1337 inline-image sequence. `inline=1`
|
|
269
|
+
* renders in place instead of downloading; `width`/`height` are given in CELLS
|
|
270
|
+
* (not "auto") so iTerm2's own scaling always agrees with jeo's precomputed
|
|
271
|
+
* {@link ImageFit} — the row-accounting in the caller depends on that agreement.
|
|
272
|
+
*/
|
|
273
|
+
export function encodeIterm2Image(base64Data: string, fit: ImageFit, name?: string): string {
|
|
274
|
+
const params = [`inline=1`, `width=${fit.columns}`, `height=${fit.rows}`, "preserveAspectRatio=1"];
|
|
275
|
+
if (name) params.push(`name=${Buffer.from(name).toString("base64")}`);
|
|
276
|
+
return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** True when `line` contains an inline-image escape sequence (Kitty APC or
|
|
280
|
+
* iTerm2 OSC 1337) within its first 96 characters — a possible leading
|
|
281
|
+
* `cursorUp(...)` prefix (see {@link renderInlineImage}) means the marker is not
|
|
282
|
+
* always at offset 0. Callers that measure/truncate/wrap terminal lines by
|
|
283
|
+
* DISPLAY WIDTH (`visibleWidth`, `truncateToWidth`, `wrapTextWithAnsi` in
|
|
284
|
+
* `components/width.ts`) MUST treat a line matching this as opaque and pass it
|
|
285
|
+
* through unchanged: the base64 payload is thousands of columns "wide" by a
|
|
286
|
+
* naive count, and slicing mid-sequence corrupts or hangs the terminal (an
|
|
287
|
+
* unterminated Kitty APC / OSC waits indefinitely for its `ESC \\` / BEL
|
|
288
|
+
* terminator). Not currently wired into `width.ts` — every current image call
|
|
289
|
+
* site (`insertAbove`) bypasses that pipeline entirely; exported so a future
|
|
290
|
+
* call site can opt in without re-deriving the detection. */
|
|
291
|
+
export function isImageEscapeLine(line: string): boolean {
|
|
292
|
+
const head = line.slice(0, 96);
|
|
293
|
+
return head.includes("\x1b_G") || head.includes("\x1b]1337;File=");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Human-readable stand-in for an attachment that can't be rendered inline
|
|
297
|
+
* (unsupported terminal, unsupported format for the detected protocol, or an
|
|
298
|
+
* undecodable dimension header). Mirrors the existing `⧉ N image(s) attached`
|
|
299
|
+
* wording style used elsewhere in the TUI. */
|
|
300
|
+
export function imageCaption(mediaType: string, dims: PixelDimensions | null, filename?: string): string {
|
|
301
|
+
const parts = [filename, `[${mediaType}]`, dims ? `${dims.widthPx}x${dims.heightPx}` : undefined].filter(Boolean);
|
|
302
|
+
return `⧉ ${parts.join(" ")}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface InlineImageResult {
|
|
306
|
+
/** Lines to feed into `Renderer.insertAbove` (or join with "\n" for a plain
|
|
307
|
+
* `console.log`/pipe path). The array length equals the image's rendered
|
|
308
|
+
* terminal-row footprint — every element is one physical row — so a caller
|
|
309
|
+
* computing "rows written" from `lines.length` (as `insertAbove` does by
|
|
310
|
+
* splitting on "\n") gets the correct height. */
|
|
311
|
+
lines: string[];
|
|
312
|
+
/** True when `lines` is a real inline image; false when it is a 1-line text
|
|
313
|
+
* caption (unsupported terminal/format, or an unparsable header). */
|
|
314
|
+
rendered: boolean;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Build the escape sequence (or text caption) for one image attachment, sized to
|
|
319
|
+
* fit `maxColumns` x `maxRows` cells.
|
|
320
|
+
*
|
|
321
|
+
* Row bookkeeping (why this shape, not just "the sequence + rows-1 blank lines"):
|
|
322
|
+
* both protocols draw the image ANCHORED AT THE CURSOR'S ROW at the moment the
|
|
323
|
+
* escape is processed and expand DOWNWARD — they do not retroactively fill rows
|
|
324
|
+
* already scrolled past. So the cursor must already sit at the image's TOP row
|
|
325
|
+
* before the escape is written. `lines` therefore reserves `fit.rows - 1` blank
|
|
326
|
+
* rows FIRST (each later joined with "\n", which is what actually advances the
|
|
327
|
+
* cursor down — matches every other multi-row block `insertAbove` is fed), then a
|
|
328
|
+
* final row that rewinds the cursor back up to the reserved block's top
|
|
329
|
+
* (`cursorUp(fit.rows - 1)`) immediately before the escape sequence.
|
|
330
|
+
*
|
|
331
|
+
* After drawing, Kitty/kitty-compatible terminals leave the REAL cursor
|
|
332
|
+
* `fit.rows - 1` rows below the top (i.e. on the image's last row) — added to the
|
|
333
|
+
* caller's mandatory trailing "\n" (one per `InlineImageResult`, not emitted
|
|
334
|
+
* here), that lands the cursor exactly `fit.rows` rows below where it started,
|
|
335
|
+
* matching a plain `fit.rows`-line block of text. iTerm2 instead auto-advances
|
|
336
|
+
* PAST the image (`fit.rows` rows below the top), so a synthetic `cursorUp(1)`
|
|
337
|
+
* is appended after its escape to cancel that extra row and reach the same
|
|
338
|
+
* `fit.rows - 1` offset — keeping both protocols' net row math identical.
|
|
339
|
+
*/
|
|
340
|
+
export function renderInlineImage(
|
|
341
|
+
attachment: { mediaType: string; data: string },
|
|
342
|
+
protocol: ImageProtocol,
|
|
343
|
+
opts: { maxColumns: number; maxRows: number; filename?: string } = { maxColumns: 60, maxRows: 20 },
|
|
344
|
+
): InlineImageResult {
|
|
345
|
+
const bytes = Buffer.from(attachment.data, "base64");
|
|
346
|
+
const dims = getImageDimensions(bytes, attachment.mediaType);
|
|
347
|
+
const caption = () => ({ lines: [imageCaption(attachment.mediaType, dims, opts.filename)], rendered: false });
|
|
348
|
+
|
|
349
|
+
if (protocol === ImageProtocol.None) return caption();
|
|
350
|
+
if (protocol === ImageProtocol.Kitty && attachment.mediaType !== "image/png") return caption(); // f=100 decodes PNG only
|
|
351
|
+
if (!dims) return caption(); // no protocol can size an image it can't measure
|
|
352
|
+
|
|
353
|
+
const cell = getCellPixelSize();
|
|
354
|
+
const fit = fitImageToCells(dims, opts.maxColumns, opts.maxRows, cell);
|
|
355
|
+
const seq =
|
|
356
|
+
protocol === ImageProtocol.Kitty
|
|
357
|
+
? encodeKittyImage(attachment.data, fit)
|
|
358
|
+
: encodeIterm2Image(attachment.data, fit, opts.filename) + cursorUp(1); // cancel iTerm2's extra auto-advance row
|
|
359
|
+
|
|
360
|
+
const blankRows = Math.max(0, fit.rows - 1);
|
|
361
|
+
const lines = [...Array(blankRows).fill(""), cursorUp(blankRows) + seq];
|
|
362
|
+
return { lines, rendered: true };
|
|
363
|
+
}
|