Skip to content

feat(cli): add TUI image display tool - #8217

Merged
DragonnZhang merged 22 commits into
QwenLM:mainfrom
DragonnZhang:dragon/tui-display-image
Aug 1, 2026
Merged

feat(cli): add TUI image display tool#8217
DragonnZhang merged 22 commits into
QwenLM:mainfrom
DragonnZhang:dragon/tui-display-image

Conversation

@DragonnZhang

@DragonnZhang DragonnZhang commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a model-invocable display_image tool exclusively to the main interactive TUI. The tool validates an absolute workspace PNG path, regular-file status, the PNG signature, and an 8 MiB limit, then persists only a structured path and MIME type rather than image bytes or terminal escape sequences.

Direct Kitty and Ghostty sessions use native virtual-image placement with Unicode placeholders. Warp, iTerm2, tmux/SSH sessions, and terminals without a compatible native path use chafa ANSI symbol output when it is installed. If no renderer is available, the tool reports failure to both the TUI and model instead of claiming that the image was displayed. Warp is deliberately excluded from direct Kitty placement because its image layer drifts away from Ink content during scrollback and terminal reflow.

Previews preserve aspect ratio, stay within the terminal, default to at most 72 columns by 24 rows, and use a conservative natural-size estimate so small images are not deliberately enlarged. The tool is not registered in headless, SDK, screen-reader, bare, or subagent contexts.

Why it's needed

Repositories frequently contain image assets that users need to inspect while coding. Without a terminal preview tool, users must leave the TUI, and a model can incorrectly assume that an attempted image display succeeded even when the terminal could not render it. This change keeps image inspection inside the interactive workflow while making renderer success and failure explicit.

Reviewer Test Plan

How to verify

  1. Start the interactive TUI in a direct Kitty or Ghostty session and ask the model to display a PNG inside the workspace. Confirm that the tool succeeds and the native image appears inline.
  2. Start the TUI in Warp with chafa installed and display a colorful PNG. Confirm that ANSI symbol output retains color, occupies normal terminal rows, and remains aligned with the transcript after scrolling and resizing.
  3. Start the TUI in Warp with chafa absent from PATH. Confirm that the tool reports an explicit “image was not displayed” failure and the model does not claim success.
  4. Display small, landscape, square, and portrait PNGs. Confirm that small images do not expand to the full content width and large images remain within 72 columns, 24 rows, and the available terminal space while preserving aspect ratio.
  5. Try a non-PNG file, a path outside the workspace, and a PNG larger than 8 MiB. Confirm that each request is rejected without emitting file bytes or untrusted terminal data.
  6. Run a non-interactive/headless invocation and confirm that display_image is not registered.

Evidence (Before & After)

Before After
The interactive model had no tool for rendering repository images in the terminal. Native PNG previews were manually observed in Kitty, Ghostty, and cmux on macOS during development; automated tests cover final sizing, capability gating, fallback behavior, path validation, and failure reporting.
A failed renderer could still look like a successful tool call, allowing the model to claim an image was shown. Renderer availability is checked before tool success, and unsupported terminals without chafa receive an explicit execution failure.
Warp direct Kitty placement looked correct initially but separated from its transcript rows during scrolling or terminal resize/reflow. Warp no longer receives raw image placement commands; it uses normal-row chafa ANSI output when available, otherwise it fails explicitly.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS arm64 with Node.js 24.18.0. Manual development testing used direct Kitty, Ghostty, cmux, and Warp sessions. The focused renderer suite passes 50 tests; a clean dependency install applies the Ink patch successfully, and the full repository build and typecheck pass.

Risk & Scope

  • Main risk or tradeoff: Warp uses a colored character approximation rather than the original raster and requires chafa.
  • Not validated / out of scope: A reflow-safe native Warp image path, Windows and Linux real-terminal behavior, tmux/SSH native passthrough, direct iTerm2 placement, and formats other than PNG.
  • Breaking changes / migration notes: None. The new tool is limited to the main interactive TUI.

Linked Issues

Closes #8216

中文说明

本 PR 做了什么

仅在主交互式 TUI 中新增模型可调用的 display_image 工具。该工具会校验工作区内的绝对 PNG 路径、普通文件状态、PNG 文件签名以及 8 MiB 大小限制;持久化内容仅包含结构化路径和 MIME 类型,不包含图片字节或终端转义序列。

直接运行的 Kitty 和 Ghostty 使用带 Unicode placeholder 的原生虚拟图片放置。Warp、iTerm2、tmux/SSH 会话以及没有兼容原生路径的终端,在安装了 chafa 时使用 ANSI 字符输出;没有任何渲染器时,工具会向 TUI 和模型明确报告失败,而不是声称图片已经展示。Warp 被明确排除在 Kitty 直接放置之外,因为它的图片层会在 scrollback 和终端 reflow 时与 Ink 内容发生偏移。

预览会保持宽高比、受终端空间约束,默认最大为 72 列 × 24 行,并使用保守的自然尺寸估算,避免主动放大小图。该工具不会注册到 headless、SDK、屏幕阅读器、bare 或 subagent 场景。

为什么需要

代码仓库经常包含开发者需要在编码过程中检查的图片资源。没有终端预览工具时,用户必须离开 TUI;而当终端实际无法渲染时,模型还可能误以为展示成功。本改动让图片检查留在交互流程内,并明确区分渲染成功与失败。

Reviewer 测试计划

如何验证

  1. 在直接运行的 Kitty 或 Ghostty 中展示工作区 PNG,确认工具成功并出现原生图片。
  2. 在安装了 chafa 的 Warp 中展示彩色 PNG,确认 ANSI 字符输出保留颜色、占用普通终端行,并且滚动或调整窗口大小后仍与对话内容对齐。
  3. PATH 中没有 chafa 的 Warp 中执行展示,确认工具明确报告“图片未展示”,且模型不会声称成功。
  4. 展示小图、横图、方图和竖图,确认小图不主动放大,大图不超过 72 列、24 行及终端可用空间,并保持宽高比。
  5. 尝试非 PNG、工作区外路径和超过 8 MiB 的 PNG,确认均被安全拒绝,不会输出文件字节或不可信终端数据。
  6. 执行非交互式/headless 调用,确认没有注册 display_image

证据(改动前后)

改动前 改动后
交互式模型没有在终端中渲染仓库图片的工具。 开发期间已在 macOS 的 Kitty、Ghostty 和 cmux 中人工观察到原生 PNG 预览;自动化测试覆盖尺寸、能力判断、回退、路径校验和失败报告。
渲染失败仍可能表现为成功,导致模型声称图片已展示。 工具成功前会检查渲染器可用性;不支持且没有 chafa 的终端会收到明确失败。
Warp 的 Kitty 直接放置初看正常,但滚动或调整终端大小触发 reflow 后会与对应对话行分离。 Warp 不再接收原始图片放置命令;有 chafa 时使用普通终端行的 ANSI 输出,否则明确失败。

测试环境

macOS arm64,Node.js 24.18.0。开发期间在 Kitty、Ghostty、cmux 和 Warp 会话中进行了人工测试。聚焦渲染器测试共 50 项通过;干净依赖安装可成功应用 Ink 补丁,完整仓库 build 和 typecheck 均通过。

风险与范围

  • 主要风险或取舍:Warp 使用彩色字符近似而非原始栅格图片,并且依赖 chafa
  • 未验证 / 超出范围:可安全 reflow 的 Warp 原生图片路径、Windows 和 Linux 真实终端行为、tmux/SSH 原生透传、iTerm2 直接放置以及 PNG 以外格式。
  • 破坏性变更或迁移说明:无。新工具仅存在于主交互式 TUI。

关联 Issue

Closes #8216

@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

E2E test report

Manual observations during development

  • Kitty, Ghostty, and cmux displayed native PNG previews on macOS.
  • The original preview sizing filled too much of the terminal; the final branch now caps previews at 72 columns by 24 rows, preserves aspect ratio, and avoids rounding small images upward.
  • Warp reproduced visible Unicode-placeholder glyphs. The final branch explicitly excludes Warp from that path and routes it to chafa or an explicit rendering failure.
  • A terminal without native support or chafa previously allowed a misleading success result. The final branch checks renderer availability before tool success and returns an execution failure.

Automated verification on the final rebased branch

  • Core targeted tests: 489 passed.
  • CLI targeted tests: 330 passed.
  • Full repository build: passed.
  • Full repository typecheck: passed.
  • Full repository lint: passed.

The targeted coverage includes TUI-only registration, workspace/path and PNG validation, restored-result safety, native placeholder generation, Warp exclusion, chafa fallback and environment filtering, renderer failure reporting, preview sizing for small/landscape/square/portrait images, terminal width/height limits, and UI raw-output handling.

Still pending

  • Real-terminal visual retest of the final sizing behavior in Kitty, Ghostty, and cmux.
  • Real Warp retest with and without chafa after the explicit exclusion change.
  • Windows, Linux, tmux, and SSH behavior.

@DragonnZhang
DragonnZhang marked this pull request as ready for review July 31, 2026 09:53
@DragonnZhang
DragonnZhang enabled auto-merge July 31, 2026 09:53
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 31, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on 3e568af and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— 3e568af 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: this is a feature request (linked issue #8216, self-reported) rather than a bug fix. The motivation is genuine — repositories routinely contain image assets, and the current TUI has no way to preview them without leaving the session. The issue is well-specified with clear scope boundaries (PNG-only, capability-gated, explicit failure reporting).

Direction: aligned. An interactive coding assistant benefits from keeping visual inspection in-workflow, and the tool is deliberately display-only (no image bytes enter model context). The capability-gated rendering with explicit failure reporting is the right posture. CHANGELOG has no direct terminal-image-preview reference, but the area is active — multiple image-handling fixes in recent releases confirm images are a relevant surface.

Size: 963 production logic lines (359 core, 602 CLI, 2 web-shell) + 1268 test lines + 79 docs + 11 patch. Core production is under the 500-line escalation threshold. Total production is under the 1000-line advisory. The core touch points (config, tools, agents, fork-subagent) are each necessary for the feature: tool registration, config plumbing, and fork/subagent exclusion.

Approach: the scope feels right for the stated goal. One new tool, one renderer, one TUI component — plus the fork/subagent plumbing needed to keep the tool main-session-only while preserving prompt-cache parity. The renderer reuses existing Mermaid infrastructure (findExecutable, createRendererChildEnv, encodeKittyVirtualImage, buildKittyPlaceholder, readPngSize) rather than building parallel utilities, which is good. The design doc in docs/design/ is a helpful addition. I don't see a materially simpler path that covers the same requirements.

Risk: no elevated risk signals — none of the changed files match the high-risk path patterns from the revert-history analysis.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:这是一个功能请求(关联 issue #8216,作者自行提交),而非 bug 修复。动机是真实的——代码仓库中普遍包含图片资源,而当前 TUI 无法在会话内预览。Issue 规格清晰,范围边界明确(仅 PNG、能力判断、明确失败报告)。

方向:对齐。交互式编码助手受益于将视觉检查留在工作流内,且该工具刻意只做展示(图片字节不进入模型上下文)。能力判断加明确失败报告是正确的姿态。CHANGELOG 没有直接的终端图片预览引用,但该领域活跃——近期版本中有多项图片处理修复。

规模:963 行生产逻辑(359 核心、602 CLI、2 web-shell)+ 1268 行测试 + 79 行文档 + 11 行补丁。核心生产行低于 500 行升级阈值,总生产行低于 1000 行建议阈值。核心触点(config、tools、agents、fork-subagent)均为功能所需。

方案:范围与目标匹配。一个新工具、一个渲染器、一个 TUI 组件——加上保持工具仅限主会话同时维护 prompt-cache 一致性的 fork/subagent 管道。渲染器复用了现有 Mermaid 基础设施而非构建平行工具,这很好。docs/design/ 中的设计文档是有益的补充。

风险:无升级风险信号——变更文件均未命中 revert 历史分析中的高风险路径模式。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 3e568af854182f7fb13b7338c632f2853a7232e3 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: for adding terminal image previews, I would create a core tool that validates the path and returns a small structured display value (keeping image bytes out of model history), a CLI-side renderer that detects terminal capabilities and falls back gracefully, and a TUI component that renders synchronously for Ink's Static region. Registration would be gated to interactive mode only, and subagents would be excluded.

Comparison with the diff: the PR's approach matches this closely and goes further in one important way — the fork/subagent handling preserves the parent's display_image declaration in the fork's tool registry for prompt-cache parity while stripping execution capability. This is a non-obvious requirement that the PR handles cleanly through registerForkDisplayImageForCache + resolveForkExecutionAllowedTools + a runtime isInForkExecution() guard in the invocation itself. The three-layer defense (declaration stripping, allowlist filtering, runtime check) is thorough.

No critical blockers found. Specific observations:

  • Security posture is solid. Workspace path validation via isPathWithinWorkspace (symlink-resolving), PNG signature verification, 8 MiB size cap, cmd.exe metacharacter rejection for Windows shell shims, sanitized child environment for chafa (reusing the Mermaid renderer's allowlisted env), and bounded error messages. The renderer re-validates workspace membership on restored sessions, treating persisted resultDisplay as untrusted.
  • Reuse is good. Five functions are newly exported from mermaidImageRenderer.ts (findExecutable, shouldRunThroughShell, createRendererChildEnv, encodeKittyVirtualImage, buildKittyPlaceholder, readPngSize) rather than duplicated. The Kitty virtual-image and Unicode-placeholder approach is the same one already used for Mermaid diagrams.
  • Caches are bounded. The render cache (40 entries, 32 MiB cap) and transmitted-key set (256 entries) both evict oldest-first, preventing unbounded growth in long sessions.
  • Synchronous rendering is correct. renderTerminalImage is synchronous (using execFileSync for chafa), which is required because completed tool rows enter Ink's append-only Static region immediately — an async state update would be dropped there. The process.nextTick for Kitty sequence emission avoids writing during render.
  • The resolveForkExecutionAllowedTools fail-closed path (returning [] when display_image is advertised but no allowlist exists) looks aggressive at first glance, but the comment is accurate: every live caller passes a concrete list through buildForkExecutionAllowlist, so the ?? [] is a safety net, not the normal path. The tests confirm this.
Files changed (30 of 30 shown)
File What changed
docs/design/tui-display-image-tool.md New design doc covering goal, rendering strategy, compatibility, and security
packages/cli/src/config/config.ts Wires the terminal image render support provider into CLI config for interactive mode
packages/cli/src/i18n/locales/en.js Adds DisplayImage tool display name
packages/cli/src/i18n/locales/zh-TW.js Adds DisplayImage tool display name (Traditional Chinese)
packages/cli/src/i18n/locales/zh.js Adds DisplayImage tool display name (Simplified Chinese)
packages/cli/src/ui/components/TerminalImage.test.tsx Tests for the TUI component: Kitty write, chafa ANSI, fallback, workspace guard, remount dedup
packages/cli/src/ui/components/TerminalImage.tsx New TUI component: validates workspace path, renders Kitty placeholder or chafa ANSI or text fallback
packages/cli/src/ui/components/messages/ToolMessage.test.tsx Tests that structured terminal_image results route through TerminalImage
packages/cli/src/ui/components/messages/ToolMessage.tsx Routes terminal_image display type to the new TerminalImage component
packages/cli/src/ui/utils/mermaidImageRenderer.ts Exports five existing functions for reuse by the terminal image renderer
packages/cli/src/ui/utils/terminal-image-renderer.test.ts 50 tests: sizing, Kitty detection, Warp exclusion, chafa fallback, env sanitization, caching, cmd.exe metachar rejection
packages/cli/src/ui/utils/terminal-image-renderer.ts New renderer: Kitty virtual-image placement, chafa ANSI fallback, bounded caches, capability detection
packages/core/src/agents/background-agent-resume.test.ts Tests fork resume with display_image in declarations but denied execution
packages/core/src/agents/background-agent-resume.ts Registers display_image factory for cache parity on fork resume, filters it from execution allowlist
packages/core/src/config/config.test.ts Tests registration gating (interactive only) and render support provider forwarding
packages/core/src/config/config.ts Adds TerminalImageRenderSupport type, provider plumbing, and conditional tool registration
packages/core/src/core/coreToolScheduler.test.ts Tests file_path extraction for display_image
packages/core/src/core/coreToolScheduler.ts Adds display_image to FS_PATH_TOOL_NAMES and extractToolFilePaths
packages/core/src/tools/agent/agent.test.ts Tests fork declarations preserve display_image but deny execution
packages/core/src/tools/agent/agent.ts Integrates resolveForkExecutionAllowedTools and registerForkDisplayImageForCache into fork launch
packages/core/src/tools/agent/fork-subagent.test.ts Unit tests for resolveForkExecutionAllowedTools edge cases
packages/core/src/tools/agent/fork-subagent.ts New resolveForkExecutionAllowedTools and registerForkDisplayImageForCache functions
packages/core/src/tools/display-image.test.ts Tool tests: structured display, fork denial, path validation, renderer failure, missing/invalid/oversized files
packages/core/src/tools/display-image.ts New core tool: validates path, PNG signature, size; checks renderer support; returns structured display
packages/core/src/tools/tool-names.ts Adds DISPLAY_IMAGE to ToolNames and ToolDisplayNames
packages/core/src/tools/tools.test.ts Tests isTerminalImageDisplay type guard
packages/core/src/tools/tools.ts Adds MAX_TERMINAL_IMAGE_BYTES, TerminalImageDisplay interface, type guard, and union member
packages/web-shell/client/components/messages/toolFormatting.ts Adds display_image to web-shell tool display names
packages/web-shell/client/i18n.tsx Adds display_image Chinese translation for web-shell
patches/ink+7.0.3.patch Minor patch context adjustments (index hashes, context lines) — no new patch content

Testing

Final CI results for 3e568af (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

The ubuntu test suite and web-shell visual capture are still running. macOS and Windows tests are skipped (likely gated on the ubuntu suite passing first). No failures so far, but the primary test evidence is not yet available.

Sandboxed verification would settle the rendering claims: @qwen-code /tmux — that the Kitty virtual-image placement, chafa ANSI fallback, and explicit failure reporting actually work in a real terminal is not observable from the diff or unit tests alone. The author tested on macOS with Kitty, Ghostty, and Warp; Linux and Windows remain unvalidated. Since this is a fork PR, /tmux is unavailable (it executes the author's code). /verify is available as a sponsored run: a maintainer's @qwen-code /verify comment approves the head it was written against, and that run carries a pre-execution risk screen and a full workspace wipe. Read the resulting report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

独立方案: 对于添加终端图片预览,我会创建一个核心工具来验证路径并返回小型结构化显示值(不让图片字节进入模型历史),一个 CLI 端渲染器来检测终端能力并优雅降级,以及一个为 Ink Static 区域同步渲染的 TUI 组件。注册仅限交互模式,子代理被排除。

与 diff 的比较: PR 的方案与此高度一致,并在一个重要方面走得更远——fork/subagent 处理在 fork 的工具注册表中保留了父级的 display_image 声明以维护 prompt-cache 一致性,同时剥离执行能力。这通过 registerForkDisplayImageForCache + resolveForkExecutionAllowedTools + 调用时的 isInForkExecution() 运行时守卫实现。三层防御(声明剥离、允许列表过滤、运行时检查)很彻底。

未发现关键阻塞项。安全姿态扎实(工作区路径验证、PNG 签名校验、大小限制、cmd.exe 元字符拒绝、子进程环境净化)。复用良好(从 mermaidImageRenderer 导出五个函数而非重复实现)。缓存有界。同步渲染正确。

测试

ubuntu 测试套件和 web-shell 视觉捕获仍在运行。目前无失败,但主要测试证据尚不可用。

沙盒验证可以确认渲染声明:@qwen-code /tmux——Kitty 虚拟图片放置、chafa ANSI 回退和明确失败报告是否在真实终端中工作,无法仅从 diff 或单元测试中观察到。由于这是 fork PR,/tmux 不可用。/verify 可作为赞助运行使用:维护者的 @qwen-code /verify 评论会批准其对应的 head,该运行带有预执行风险筛查和完整工作区清除。

Qwen Code · qwen3.8-max-preview

Reviewed at 3e568af854182f7fb13b7338c632f2853a7232e3 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid, well-tested feature with clean architecture; CI still running and terminal rendering claims rest on the author's manual testing.

Stepping back: this PR does one thing well. It adds a display-only image preview tool to the interactive TUI, with proper capability detection, graceful degradation, and explicit failure reporting. The architecture is clean — core validates and returns structured data, CLI renders, and the two never mix image bytes into model context. The fork/subagent handling is the most complex part, but it solves a real problem (prompt-cache parity) with a well-documented three-layer approach.

Going back to my independent proposal: the PR matches it and exceeds it in the fork cache-parity handling, which I wouldn't have thought of initially. The reuse of Mermaid renderer infrastructure is exactly right — no parallel utilities, no duplicated logic. The test suite is comprehensive (50 renderer tests, tool tests, component tests, fork integration tests) and covers the security boundaries I care about (workspace traversal, cmd.exe injection, env leakage, oversized files).

The reservations are honest ones: the ubuntu test suite hasn't finished, so I don't have CI confirmation that the 1268 test lines actually pass. And the rendering behavior — Kitty placement, chafa output, Warp exclusion — is inherently terminal-specific and can't be verified from a diff. The author tested on macOS with Kitty, Ghostty, and Warp, which covers the primary paths, but Linux and Windows remain unvalidated (the author marks them ⚠️).

If I had to maintain this in six months, I'd thank the author — the design doc, the bounded caches, the explicit failure modes, and the test coverage make this straightforward to reason about. The code doesn't try too hard; it solves the stated problem without speculative generality.

Not blocking, but worth noting for the maintainer: the mermaidImageRenderer.ts exports are growing (six functions now shared between two consumers). If a third consumer appears, it might be worth extracting a shared terminal-rendering-utils module. Not now — two consumers is fine.

Approval deferred until CI lands green on 3e568af854182f7fb13b7338c632f2853a7232e3.

中文说明

置信度:4/5 —— 实现扎实、测试充分、架构清晰;CI 仍在运行,终端渲染声明依赖作者的手动测试。

退一步看:这个 PR 做好了一件事。它为交互式 TUI 添加了一个仅展示的图片预览工具,具备正确的能力检测、优雅降级和明确失败报告。架构清晰——核心验证并返回结构化数据,CLI 渲染,两者都不将图片字节混入模型上下文。fork/subagent 处理是最复杂的部分,但它用文档齐全的三层方案解决了一个真实问题(prompt-cache 一致性)。

与我的独立方案相比:PR 与之匹配并在 fork cache-parity 处理上超越了我最初的想法。Mermaid 渲染器基础设施的复用完全正确。测试套件全面,覆盖了我关心的安全边界。

保留意见是诚实的:ubuntu 测试套件尚未完成,渲染行为本质上是终端特定的,无法从 diff 中验证。作者在 macOS 上用 Kitty、Ghostty 和 Warp 进行了测试,覆盖了主要路径,但 Linux 和 Windows 仍未验证。

不阻塞,但值得维护者注意:mermaidImageRenderer.ts 的导出在增长(现在六个函数在两个消费者之间共享)。如果出现第三个消费者,可能值得提取一个共享模块。现在不需要——两个消费者没问题。

批准推迟至 CI 在 3e568af854182f7fb13b7338c632f2853a7232e3 上全部通过。

Qwen Code · qwen3.8-max-preview

Reviewed at 3e568af854182f7fb13b7338c632f2853a7232e3 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao — this one needs a human call, not an auto-merge.

The code review is clean (no blockers; the security posture and test coverage are strong), but two things put it outside what the gate should approve on its own:

  1. Core-module escalation. It's a fork feat that touches core infrastructure (packages/core/src/** plus the fork/background-resume execution allowlists) at ~740 production lines. Our core-module guidance escalates a core-touching feature of this size to a maintainer for awareness rather than auto-approving it.
  2. Behavior not yet proven. CI on this commit isn't settled (ubuntu still running; macOS/Windows/integration skipped behind fork authorization), the author tested macOS only, and the actual inline-render claim needs a real terminal. A sponsored @qwen-code /verify run could pin the gating/fallback/failure behavior; the visual rendering needs a live terminal that /tmux would cover but can't for a fork author without write access.

The specific design question worth your eye: the fork machinery keeps the display_image schema visible to forks for prompt-cache parity while denying execution — is that subtlety worth it versus not advertising the tool to forks at all? And, at the product level, is this P3 self-requested feature something we want to take on given the core surface it adds?

Not requesting changes — I found nothing to reject. Just handing the merge decision to you.

Comment thread packages/core/src/tools/tool-names.ts
Comment thread packages/cli/src/ui/components/TerminalImage.test.tsx
@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

2 similar comments
@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Comment on lines +209 to +212
const stdout = execFileSync(
'chafa',
[
'--animate=off',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The chafa fallback spawns a subprocess synchronously inside React render. renderTerminalImage is synchronous and its non-Kitty path calls renderWithChafaexecFileSync('chafa', …, { timeout: 8000 }); TerminalImage.tsx:42 invokes it inside a React.useMemo, i.e. during render. — Failure scenario: on any non-Kitty/Ghostty terminal (tmux / SSH / most TERM — the common case, where supportsKittyImageProtocol returns false), displaying a display_image result blocks the Ink event loop (input handling and repaint) for the entire chafa run, up to 8 s. The useMemo deps include availableTerminalHeight and contentWidth, so a terminal resize re-triggers the blocking spawn. The sibling mermaidImageRenderer.ts deliberately keeps external processes outside React render via an async renderer, and MermaidDiagram.tsx proves the async useEffect+setState pattern works in the same Static region — the design doc's 'an async update would be dropped in Static' rationale does not hold.

Fix (spans this file + TerminalImage.tsx): add an async variant that uses a promisified execFile for the chafa path, resolve it in useEffect+setState mirroring MermaidDiagram.tsx, and keep the sync renderTerminalImage only for tests/headless.

— qwen3.8-max-preview via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still escalated — this needs a maintainer design call, not an auto-fix.

The prescribed fix (an async execFile resolved via useEffect+setState, mirroring MermaidDiagram.tsx) was implemented and tested, and it breaks the feature: completed tool rows render in Ink's append-only <Static> region, which renders each item once on mount and drops later state updates. A <Static>-wrapped TerminalImage driven by the async renderer produced an empty frame for the kitty/ansi/unavailable cases. MermaidDiagram survives this only because it always renders a synchronous wireframe fallback and treats the native image as an enhancement; TerminalImage has no fallback — the image is the content — so the same pattern renders nothing once committed to history. This is the rationale documented in docs/design/tui-display-image-tool.md. The async change was reverted to avoid shipping a regression.

The viable fixes are architectural:

  • (a) keep the bounded synchronous render (status quo — now a single render rather than two, since the probe no longer spawns chafa); or
  • (b) pre-render asynchronously during execute() and carry the sized output in the display payload, so the component renders synchronously from pre-computed data (changes the TerminalImageDisplay data model and sizing flow).

Question for the maintainer: which direction do you want — accept the bounded sync render (a), or take the data-model change to pre-render off the render path (b)? Leaving this thread open for that decision.

中文说明

仍为升级状态——这需要维护者做设计决策,而非自动修复。

我们已实现并测试了 prescribed 方案(仿照 MermaidDiagram.tsx、经 useEffect+setState 解析的异步 execFile),结果破坏了功能:已完成的工具行渲染在 Ink 只追加的 <Static> 区域,该区域在挂载时对每个条目只渲染一次,之后的状态更新会被丢弃。用 <Static> 包裹、由异步渲染器驱动的 TerminalImage 在 kitty/ansi/unavailable 三种情况下都得到空帧。MermaidDiagram 之所以不受影响,是因为它始终同步渲染一个线框兜底、并把原生图片仅作为增强;而 TerminalImage 没有兜底——图片本身就是内容——因此同样的模式在提交进历史后会渲染为空。这正是 docs/design/tui-display-image-tool.md 中记录的设计依据。为避免引入回归,已回退该异步改动。

可行的修复属于架构层面:

  • (a) 保留有界的同步渲染(现状——由于探测不再触发 chafa,现在是一次渲染而非两次);或
  • (b)execute() 期间异步预渲染,并把已定尺寸的输出放进 display 载荷,使组件从预计算数据同步渲染(这会改动 TerminalImageDisplay 数据模型与定尺寸流程)。

给维护者的问题: 你倾向哪个方向——接受有界的同步渲染 (a),还是接受数据模型改动以在渲染路径之外预渲染 (b)?该线程保持打开以待决策。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escalated to maintainer. The async-vs-sync tradeoff is a design call, as the reviewer noted. Converting renderTerminalImage from execFileSync to async execFile inside the React component requires decisions on: (1) loading-state UX while chafa runs, (2) resize cancellation/debouncing to avoid stale spawns, (3) useMemouseEffect + useState lifecycle change.

Recommendation: move the chafa spawn into a useEffect with an AbortController, debounce on resize, show a one-line placeholder while pending. The Kitty path (pure computation) stays in useMemo. ~50-line change to TerminalImage.tsx + terminal-image-renderer.ts, but it touches the component's render contract.

Leaving this thread open for the maintainer's decision.

中文说明

已升级至维护者。 异步与同步的权衡是设计决策,正如审阅者所指出的。将 renderTerminalImageexecFileSync 转为异步 execFile 需要以下决策:(1) chafa 运行时的加载状态 UX,(2) resize 时的取消/防抖以避免过时的启动,(3) useMemouseEffect + useState 生命周期变更。

建议:将 chafa 启动移入带 AbortControlleruseEffect,resize 时防抖,等待期间显示单行占位符。Kitty 路径(纯计算)保留在 useMemo 中。约 50 行变更,但涉及组件渲染契约。

保持此线程开放,等待维护者决定。

Comment thread packages/cli/src/ui/components/TerminalImage.test.tsx
Comment thread packages/cli/src/ui/utils/terminal-image-renderer.ts Outdated
Comment thread packages/cli/src/ui/utils/terminal-image-renderer.ts
Comment thread packages/core/src/tools/agent/agent.ts Outdated
Combine the display_image execution restriction from the TUI image display tool with main's ask_user_question restriction (buildForkExecutionAllowlist + EXCLUDED_TOOLS_FOR_SUBAGENTS). A fork now keeps the parent's display_image declaration for prompt-cache parity but can execute neither display_image nor ask_user_question.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Root cause

Main commit c50137cf8 "fix(core): prevent subagents from asking users (#8219)" (same author as this PR) refactored the fork execution-allowlist code PR #8217 also modified. #8217 strips display_image from a fork's executable tools (keeping the declaration for cache parity via registerForkDisplayImageForCache); #8219 strips ask_user_question via a new buildForkExecutionAllowlist helper. They collided in agent.ts, background-agent-resume.ts, and the latter's test. fork-subagent.ts auto-merged but git duplicated the ToolNames import (added on both sides).

Textual or semantic

Semantic — both sides rewrote the same allowlist computation. The resolution composes both filters:

executionAllowedTools: resolveForkExecutionAllowedTools(
  parentToolNames,
  buildForkExecutionAllowlist(this.params.fork_tools, declaredExecutionToolNames),
  agentConfig.getToolRegistry().getAllToolNames(),
),

buildForkExecutionAllowlist (main) drops ask_user_question; resolveForkExecutionAllowedTools (PR) then drops display_image when advertised. registerForkDisplayImageForCache keeps the declaration visible for cache parity.

Load-bearing

  • Order: ask_user_question first, then display_image. resolveForkExecutionAllowedTools must receive a defined array (the buildForkExecutionAllowlist result) so it never returns undefined — main made this field always-defined. Feeding raw fork_tools (old PR path) would drop the ask_user_question filter.
  • parentToolNames is computed once at the top of createForkSubagent (needed early for registerForkDisplayImageForCache); main's duplicate inner extractParentToolNames was removed. Both branches pass parentToolNames as the "advertised" arg, so display_image is stripped only when advertised.
  • The merged test asserts executionAllowedTools is always present and denies one tool per case: Edit, ask_user_question, display_image.

Could not verify

No build/typecheck/tests run (per instructions). I hand-traced the resolution against the auto-merged agent.test.ts fork-allowlist tests and the merged background-agent-resume.test.ts cases; all match. I also edited the NON-conflicted fork-subagent.ts to remove the duplicate ToolNames import git's auto-merge introduced (a TS2300 error) — a merge artifact required to compile, not an unrelated change.

中文说明

根因:main 的 c50137cf8#8219,与本 PR 同一作者)重构了本 PR 也改过的 fork 执行白名单代码。#8217 剔除 display_image(保留声明以维持缓存一致);#8219buildForkExecutionAllowlist 剔除 ask_user_question。两者在 agent.tsbackground-agent-resume.ts 及后者测试中冲突;fork-subagent.ts 自动合并但重复保留了 ToolNames import。

语义冲突:双方重写了同一段白名单计算,解决方式为组合两种过滤(代码见上)——先 buildForkExecutionAllowlist 剔除 ask_user_question,再 resolveForkExecutionAllowedTools 在声明了 display_image 时剔除它。

关键约束:顺序为先 ask_user_questiondisplay_imageresolveForkExecutionAllowedTools 须接收已定义数组(buildForkExecutionAllowlist 的结果)才不返回 undefined(main 已令该字段恒有定义),若传 fork_tools 会丢掉 ask_user_question 过滤。parentToolNames 改为顶部统一计算一次,删除了 main 分支内重复的 extractParentToolNames。合并后测试断言 executionAllowedTools 恒存在,各用例分别拒绝 Editask_user_questiondisplay_image

无法验证:按指令未运行 build/typecheck/测试,已手工对照合并后的 agent.test.tsbackground-agent-resume.test.ts 用例推演,均吻合。另删除了未冲突文件 fork-subagent.ts 中自动合并引入的重复 ToolNames import(否则必然 TS2300),属合并产物、编译所必需。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code did not run conflict resolution for this request.

PR #8217 does not currently have merge conflicts with main.

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 31, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Local re-verification at 8f8b984 — real Ghostty, real model, byte-level checks

Follow-up to my earlier verification. That round's only finding — the monochrome chafa fallback — is fixed and confirmed working. This round re-runs the reviewer test plan against the current head and adds a byte-level inspection of what the TUI actually writes to the terminal.

Result: every item in the test plan reproduces. I found one non-blocking efficiency issue, described at the end.

Environment

PR head 8f8b984f0 (13 commits behind main, merges cleanly, mergeable: MERGEABLE)
OS / arch macOS 26.6 (Darwin 25.6.0), arm64
Node v24.18.1
Terminal Ghostty — a dedicated fullscreen instance per case, captured by CGWindow id
Fallback renderer chafa 1.18.2 (Homebrew)
Model qwen3.8-max-preview, real API, --approval-mode yolo
Method — how the runs were driven, and one launcher trap worth knowing

Two harnesses, both against the worktree build:

  1. PTY harness@lydell/node-pty spawns the TUI at a fixed 120×40, injects the prompt, and records every raw byte the TUI writes. This is what makes the Kitty-protocol claims below checkable rather than visual.
  2. Screenshot harness — a dedicated fullscreen Ghostty per case, located by CGWindowListCopyWindowInfo title match and captured with screencapture -l <window-id>.

⚠️ Trap for anyone reproducing this: scripts/cli-entry.js prefers a managed npm installation under ~/.qwen/updates/npm over the local dist/. On a machine with a released build installed, node scripts/cli-entry.js silently runs that build, not your worktree — display_image is simply absent from /tools and the model reports the tool does not exist. Invoke node packages/cli (what npm start does) instead. My first round of runs was invalid for exactly this reason.

Fixtures generated with a pure-Node PNG encoder: 32×32, 64×48, 1200×400, 800×800, 400×1200, a 5.50 MiB valid PNG, a 9.6 MiB oversized PNG, a non-PNG file named .png, and a 16-byte truncated PNG.

1. Build, typecheck, lint, tests

Check Result
npm install && npm run build pass
npm run typecheck pass
npm run lint pass (exit 0)
cli — terminal-image-renderer, TerminalImage, ToolMessage 92 passed / 3 files
core — display-image, tools, agent, fork-subagent, background-agent-resume 332 passed / 5 files
core — config, coreToolScheduler pass

2. Native Kitty path in Ghostty — verified at the byte level

The model called display_image and Ghostty placed the PNG inline.

native ghostty

Decoding the recorded APC stream, for each fixture the transmitted payload is byte-identical to the source file and the placement geometry is exact:

fixture control keys cells (c×r) payload identical to source
small-32x32.png a=T f=100 U=1 4×2 267 B, PNG 32×32
square-800x800.png a=T f=100 U=1 48×24 8199 B, PNG 800×800
landscape-1200x400.png a=T f=100 U=1 72×12 4749 B, PNG 1200×400
portrait-400x1200.png a=T f=100 U=1 16×24 7910 B, PNG 400×1200

Every row preserves the source ratio (1:1 → 48×24, 3:1 → 72×12, 1:3 → 16×24), stays within the 72×24 cap, and the 32×32 image stays 4×2 in a 120-column terminal — no upscaling. Placeholder rows drawn match the declared r, and every row has exactly c placeholder cells.

three ratios

3. Warp gate — placeholders bypassed, and colour is now correct

With TERM_PROGRAM=WarpTerminal, the recorded stream contains 0 Kitty APC sequences and 0 U+10EEEE placeholder cells — the glyph-spew this PR set out to fix. Instead: 28,890 block glyphs carried by 24,454 256-colour SGR runs.

That last number is the point: the previous round reported the fallback preview had no colour at all. The --colors=256 fix in 7ede190f works.

warp chafa colour

4. No renderer — explicit failure, no false success

TERM_PROGRAM=WarpTerminal with chafa off PATH. The tool row is red, and the model states plainly that nothing was shown:

x DisplayImage {"file_path":".../square-800x800.png"}
  The image was not displayed: No compatible native image protocol was detected, and chafa is not installed.

no renderer

5. Validation — all rejected, zero bytes emitted

Each case emitted 0 Kitty APC sequences and 0 placeholder cells, so no file bytes and no untrusted terminal data escaped:

input error
non-PNG named .png Only PNG images are supported by display_image: …
16-byte truncated PNG (valid signature) Only PNG images are supported by display_image: …
path outside the workspace Image path is outside the current workspace: …
9.6 MiB PNG Image exceeds the 8388608 byte display limit: …

rejections

6. Registration gating

context display_image registered
interactive TUI ✅ present in /tools as DisplayImage
headless (-p) ❌ absent — model replied TOOL_NOT_REGISTERED
subagent ❌ absent — subagent reported "display_image is a main-agent-only tool… neither in their tool list nor fetchable as a deferred tool", and 0 graphics bytes were emitted

Finding (non-blocking): the full PNG is re-transmitted on every re-render

Not a correctness bug, and I don't think it should hold the merge — but it is worth a follow-up.

TerminalImage guards repeat writes with a writtenSequence ref, which only lives as long as one mount — so the guard holds within a mount but not across remounts, and the observed counts line up with the component being remounted (live row → Static row, then once per resize). Measured on the recorded stream:

scenario transmissions of the same image stream written
5.50 MiB PNG, no resize 2 16.9 MB
same, plus 3 terminal resizes 5 (2 + one per resize) 40.7 MB

Every transmission carries the same image id (8746658), the same 64×24 shape, and byte-identical data — so the render cache is doing its job (no re-encode), but the bytes still go out to the TTY each time. Kitty already holds that id; the resends are pure waste.

At the 8 MiB cap this is roughly 11 MB of base64 per redraw. On a slow PTY, or a session with several images scrolled back, resizing the window would push a lot of data.

A shape-and-id-keyed "already transmitted this terminal session" set — outliving the component mount, next to the existing renderCache — would collapse all of these to one transmission and let the placeholder cells alone drive redraws.

中文版本

8f8b984 上的本地复验 —— 真实 Ghostty、真实模型、字节级校验

这是对我上一轮验证的跟进。上一轮唯一的问题(chafa 回退无颜色)已修复并确认生效。本轮针对当前 head 重跑了 reviewer 测试计划,并新增了对 TUI 实际写入终端字节流的解码校验。

结论:测试计划中的每一项都可复现。 发现一个不阻塞合并的效率问题,列在最后。

环境

PR head 8f8b984f0(落后 main 13 个提交,可干净合并,mergeable: MERGEABLE
系统 / 架构 macOS 26.6(Darwin 25.6.0),arm64
Node v24.18.1
终端 Ghostty —— 每个用例一个独立全屏实例,按 CGWindow id 精确截图
回退渲染器 chafa 1.18.2(Homebrew)
模型 qwen3.8-max-preview,真实 API,--approval-mode yolo
验证方法 —— 以及一个值得注意的 launcher 陷阱

两套 harness,均针对 worktree 构建:

  1. PTY harness —— 用 @lydell/node-pty 以固定 120×40 拉起 TUI,注入 prompt,并记录 TUI 写出的每一个原始字节。下文关于 Kitty 协议的结论因此是可核查的,而非仅凭肉眼。
  2. 截图 harness —— 每个用例一个独立全屏 Ghostty,通过 CGWindowListCopyWindowInfo 按标题定位窗口,再用 screencapture -l <window-id> 精确捕获。

⚠️ 复现者请注意: scripts/cli-entry.js 会优先加载 ~/.qwen/updates/npm 下的 managed npm 安装,而不是本地 dist/。在装有已发布版本的机器上,node scripts/cli-entry.js 实际运行的是那个构建而非你的 worktree —— 此时 /tools 里根本没有 display_image,模型也会说该工具不存在。应改用 node packages/cli(即 npm start 的做法)。我第一轮的运行结果正是因此作废。

测试素材用纯 Node 的 PNG 编码器生成:32×32、64×48、1200×400、800×800、400×1200、一个 5.50 MiB 的合法 PNG、一个 9.6 MiB 超限 PNG、一个后缀为 .png 的非 PNG 文件,以及一个 16 字节的截断 PNG。

1. 构建、类型检查、Lint、测试

检查项 结果
npm install && npm run build 通过
npm run typecheck 通过
npm run lint 通过(exit 0)
cli —— terminal-image-rendererTerminalImageToolMessage 92 通过 / 3 个文件
core —— display-imagetoolsagentfork-subagentbackground-agent-resume 332 通过 / 5 个文件
core —— configcoreToolScheduler 通过

2. Ghostty 原生 Kitty 路径 —— 字节级验证

模型调用 display_image 后,Ghostty 内联放置了该 PNG。

native ghostty

解码记录下来的 APC 流后,每个素材的传输载荷都与源文件逐字节一致,放置几何也完全准确:

素材 控制键 单元格 (c×r) 载荷 与源文件一致
small-32x32.png a=T f=100 U=1 4×2 267 B,PNG 32×32
square-800x800.png a=T f=100 U=1 48×24 8199 B,PNG 800×800
landscape-1200x400.png a=T f=100 U=1 72×12 4749 B,PNG 1200×400
portrait-400x1200.png a=T f=100 U=1 16×24 7910 B,PNG 400×1200

每一行都保持了源图比例(1:1 → 48×24,3:1 → 72×12,1:3 → 16×24),均未超过 72×24 上限;32×32 的小图在 120 列终端里仍是 4×2 —— 没有被放大。绘制的 placeholder 行数与声明的 r 一致,且每行恰好有 c 个 placeholder 单元格。

three ratios

3. Warp gate —— placeholder 已绕开,且颜色现已正确

设置 TERM_PROGRAM=WarpTerminal 后,记录到的字节流中包含 0 个 Kitty APC 序列、0 个 U+10EEEE placeholder 单元格 —— 正是本 PR 要解决的乱码问题。取而代之的是 28,890 个块状字形,由 24,454 段 256 色 SGR 承载。

最后这个数字是重点:上一轮测得的是 0 段颜色。7ede190f 中的 --colors=256 修复确实生效了。

warp chafa colour

4. 无可用渲染器 —— 明确失败,不会假装成功

TERM_PROGRAM=WarpTerminalchafa 不在 PATH 中。工具行标红,模型也明确说明图片并未展示:

x DisplayImage {"file_path":".../square-800x800.png"}
  The image was not displayed: No compatible native image protocol was detected, and chafa is not installed.

no renderer

5. 校验 —— 全部拒绝,且未输出任何字节

以下每个用例都产生了 0 个 Kitty APC 序列、0 个 placeholder 单元格,因此既没有文件字节、也没有不可信终端数据泄漏:

输入 错误
后缀为 .png 的非 PNG Only PNG images are supported by display_image: …
16 字节截断 PNG(签名合法) Only PNG images are supported by display_image: …
工作区外的路径 Image path is outside the current workspace: …
9.6 MiB PNG Image exceeds the 8388608 byte display limit: …

rejections

6. 注册门控

场景 是否注册 display_image
交互式 TUI /tools 中存在,显示为 DisplayImage
headless(-p ❌ 不存在 —— 模型回复 TOOL_NOT_REGISTERED
subagent ❌ 不存在 —— subagent 明确回报 "display_image is a main-agent-only tool… neither in their tool list nor fetchable as a deferred tool",且未产生任何图形字节

发现(不阻塞合并):每次重绘都会重传整张 PNG

这不是正确性缺陷,我也不认为它应该拦住合并,但值得后续跟进。

TerminalImagewrittenSequence ref 防止重复写入,但该 ref 的生命周期仅限于一次 mount —— 因此该保护在单次 mount 内有效,跨 remount 则失效;实测次数也与组件被 remount 的次数吻合(实时行 → Static 行,之后每次 resize 各一次)。基于记录字节流的实测:

场景 同一张图的传输次数 写出的字节流
5.50 MiB PNG,不 resize 2 16.9 MB
同上,外加 3 次终端 resize 5(2 次 + 每次 resize 各 1 次) 40.7 MB

每一次传输携带的都是相同的 image id(8746658)、相同的 64×24 形状、逐字节相同的数据 —— 说明 render cache 是生效的(没有重新编码),但这些字节仍然被再次送往 TTY。Kitty 已经持有该 id,重发纯属浪费。

按 8 MiB 上限计算,每次重绘大约是 11 MB 的 base64。在较慢的 PTY 上,或者会话中有多张图片滚动在上方时,调整窗口大小会推送相当可观的数据量。

一个以 id 与形状为键、生命周期超出组件 mount 的"本终端会话已传输过"集合(放在现有 renderCache 旁边)就能把这些收敛为一次传输,让 placeholder 单元格独自驱动重绘。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Unresolved, please confirm: [Critical] Synchronous chafa render inside React render (packages/cli/src/ui/utils/terminal-image-renderer.ts + packages/cli/src/ui/components/TerminalImage.tsx, comment 3691124431): a residual first-render and cold-cache session-restore synchronous execFileSync (up to 8s Ink event-loop block) remains in the code. The maintainer-endorsed bounded LRU render cache addresses the resize amplifier (a within-clamp resize reuses the cache key and does not re-spawn), and the maintainer's own re-verification at 8f8b984 — whose source is unchanged at this head — is positive with only a non-blocking efficiency finding; but this is an escalated maintainer design call (async-vs-sync timing rework deemed out of scope), so it cannot be independently certified as resolved here. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 建议见行内评论。 未决,请确认:[Critical] Synchronous chafa render inside React render (packages/cli/src/ui/utils/terminal-image-renderer.ts + packages/cli/src/ui/components/TerminalImage.tsx, comment 3691124431): a residual first-render and cold-cache session-restore synchronous execFileSync (up to 8s Ink event-loop block) remains in the code. The maintainer-endorsed bounded LRU render cache addresses the resize amplifier (a within-clamp resize reuses the cache key and does not re-spawn), and the maintainer's own re-verification at 8f8b984 — whose source is unchanged at this head — is positive with only a non-blocking efficiency finding; but this is an escalated maintainer design call (async-vs-sync timing rework deemed out of scope), so it cannot be independently certified as resolved here. 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/cli/src/ui/utils/terminal-image-renderer.ts
@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Re-verified at e4921a0 — behaviour-neutral update, earlier results carry over

Rechecked after the two new commits (00cf11d render images directly in Warp → e4921a0 disable native images in Warp). They cancel out: the net diff against the head I verified (8f8b984) touches no product code.

docs/design/tui-display-image-tool.md |  5 +++--
patches/ink+7.0.3.patch               | 11 +++++------
  • git diff 8f8b984..e4921a0 -- packages/ is empty.
  • The patches/ink+7.0.3.patch change is a regenerated file: hunk headers, index hashes, and one trailing-newline marker. Comparing only the +/- payload lines, both revisions are byte-identical (461 lines each) — the patched Ink output is unchanged. Worth stating explicitly, since that patch covers output.js and render-node-to-output.js, the exact Static/render path my earlier finding sits on.

I still rebuilt and re-ran rather than reasoning from the diff:

Check at e4921a0 Result
npm install && npm run build && npm run typecheck pass
core — display-image, tools, fork-subagent, config 523 passed
cli — terminal-image-renderer, TerminalImage, ToolMessage 92 passed
Ghostty native, square-800x800.png 48×24 cells, payload byte-identical to source ✅
Warp gate (TERM_PROGRAM=WarpTerminal) 0 Kitty APC, 0 U+10EEEE placeholders, 27,508 256-colour SGR runs ✅

Everything from the previous report still holds — including the screenshots, which show unchanged behaviour.

The re-transmission finding is unchanged and still open: the 5.50 MiB PNG is transmitted at rest and after three terminal resizes, same image id 8746658, same 64×24 shape, byte-identical payload each time. Still non-blocking from my side.

One housekeeping note: the branch is now 22 commits behind main (was 13). Still MERGEABLE.

中文版本

e4921a0 上的复验 —— 本次更新不改变行为,此前结论继续成立

针对新增的两个 commit(00cf11d 在 Warp 中直接渲染图片 → e4921a0 禁用 Warp 原生图片)重新检查。两者互相抵消:相对我此前验证的 head(8f8b984),净差异不涉及任何产品代码。

docs/design/tui-display-image-tool.md |  5 +++--
patches/ink+7.0.3.patch               | 11 +++++------
  • git diff 8f8b984..e4921a0 -- packages/ 为空
  • patches/ink+7.0.3.patch 的改动是重新生成该文件所致:hunk 头、index 哈希,以及一个行尾换行标记。只比较 +/- 的实质内容行,两个版本逐字节一致(各 461 行) —— 打完补丁后的 Ink 产物没有变化。这一点值得明确说明,因为该补丁覆盖 output.jsrender-node-to-output.js,正是我此前那个发现所处的 Static/渲染路径。

即便如此,我仍然重新构建并实跑,而不是仅凭 diff 推断:

e4921a0 上的检查 结果
npm install && npm run build && npm run typecheck 通过
core —— display-imagetoolsfork-subagentconfig 523 通过
cli —— terminal-image-rendererTerminalImageToolMessage 92 通过
Ghostty 原生路径,square-800x800.png 48×24 单元格,载荷与源文件逐字节一致 ✅
Warp gate(TERM_PROGRAM=WarpTerminal 0 个 Kitty APC、0 个 U+10EEEE placeholder、27,508 段 256 色 SGR ✅

上一份报告中的全部结论继续成立,截图同样有效(行为未变)。

重传问题原样存在,仍未处理:5.50 MiB 的 PNG 静态下传输 2 次,经过 3 次终端 resize 后为 5 次,每次都是相同的 image id 8746658、相同的 64×24 形状、逐字节相同的载荷。在我这边仍属不阻塞合并的问题。

另有一项事务性提醒:该分支目前落后 main 22 个提交(此前为 13)。仍为 MERGEABLE

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Re-verified at 913923d — test-only change, and the new test is not vacuous

One new commit, 913923d test(cli): cover chafa shell path guard: +43 lines in terminal-image-renderer.test.ts, no product code touched (git diff e4921a0..913923d -- packages/core packages/cli/src/ui/utils/terminal-image-renderer.ts is empty).

Good addition — it closes the one gap I'd have flagged if asked. containsCmdShellMetacharacters guards a real command-injection vector (a .cmd/.bat chafa shim spawned with shell: true, where Node forwards arguments unquoted), and until now only the predicate was covered, not the path through renderTerminalImage.

I checked the test earns its keep rather than just passing:

Check at 913923d Result
terminal-image-renderer.test.ts 21 passed (was 20)
Mutation: containsCmdShellMetacharactersreturn false 2 tests fail, including the new one — guard removal is caught
Source restored, re-run 21 passed, tree clean

The new test drives the full renderTerminalImage path under a mocked win32 platform with a real .CMD shim on PATH and a & in the filename, and asserts the render is refused before the spawn. That's the right level.

Nothing else changed, so the verification at 8f8b984 and the e4921a0 re-check both still stand — including the still-open re-transmission finding (5.50 MiB PNG sent 2× at rest, 5× after three resizes, identical id and payload each time). Non-blocking from my side.

Branch is now 23 commits behind main; still MERGEABLE.

中文版本

913923d 上的复验 —— 纯测试改动,且新测试并非空测试

新增一个 commit 913923d test(cli): cover chafa shell path guardterminal-image-renderer.test.ts 中 +43 行,未触碰任何产品代码git diff e4921a0..913923d -- packages/core packages/cli/src/ui/utils/terminal-image-renderer.ts 为空)。

这个补充很到位——它正好补上了我本会提出的那个缺口。containsCmdShellMetacharacters 防的是一个真实的命令注入面(以 shell: true 拉起 .cmd/.bat 形式的 chafa shim 时,Node 会不加引号地转发参数),而此前只覆盖了这个判定函数本身,没有覆盖经由 renderTerminalImage 的完整路径。

我确认了该测试确有价值,而不只是"能通过":

913923d 上的检查 结果
terminal-image-renderer.test.ts 21 通过(此前 20)
变异测试:把 containsCmdShellMetacharacters 改为 return false 2 个测试失败,含新增的这个——移除防护会被捕获
还原源码后重跑 21 通过,工作区干净

新测试在 mock 成 win32 的环境下走完整的 renderTerminalImage 路径:PATH 上放一个真实的 .CMD shim,文件名中带 &,断言在 spawn 之前就拒绝渲染。这个覆盖层次是对的。

其余没有任何变化,因此8f8b984 上的验证e4921a0 上的复检均继续成立——包括那个仍未处理的重传问题(5.50 MiB 的 PNG 静态下传 2 次,3 次 resize 后 5 次,每次 id 与载荷完全相同)。在我这边仍属不阻塞合并。

分支目前落后 main 23 个提交;仍为 MERGEABLE

wenshao
wenshao previously approved these changes Aug 1, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 6/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 6/100 轮)。改动内容与我反驳保留之处如下:

Review feedback addressed

Finding (non-blocking): the full PNG is re-transmitted on every re-render — Implemented

@wenshao's byte-level re-verification at 8f8b984 confirmed that every item in the test plan reproduces, and raised one explicitly non-blocking efficiency issue: TerminalImage guarded repeat writes with a writtenSequence ref that only lives for one mount. The live-row → Static-row move and every terminal resize remount the component, so the identical base64 payload was re-transmitted even though the Kitty terminal already held that image id — measured at 2 transmissions at rest and 5 across three resizes (up to ~40 MB written for a single 5.5 MiB image).

Although flagged as non-blocking and "worth a follow-up", this is squarely in scope for this image-display PR and mirrors the existing bounded renderCache, so it is addressed here rather than deferred to a separate change.

Changes

  • terminal-image-renderer.ts: the Kitty render result now carries its render-cache key, and a small session-scoped, bounded set (wasKittyImageWritten / markKittyImageWritten, capped at TRANSMITTED_KEY_LIMIT = 256) records which render keys have already been written to the terminal. Evicting an old key costs at most a re-transmit, never a blank image, because the payload is rebuilt from the still-cached render.
  • TerminalImage.tsx: the per-mount writtenSequence ref and its file-path reset effect are replaced by a single effect that writes the Kitty sequence only when its render key has not been transmitted this session. A remount now re-emits only the cheap placeholder cells; the terminal redraws the image it already holds.

Tests

  • terminal-image-renderer.test.ts: asserts the Kitty result exposes a stable key across cache hits (and a distinct key for a different placement shape), and that the transmitted-key set tracks writes and stays bounded under eviction.
  • TerminalImage.test.tsx: adds a regression test that unmounts and remounts the component and asserts the payload is transmitted once, not twice; the existing dedup tests are updated to the session-level tracker.

No conflict resolution was required (--conflict false).

Verification

  • npx vitest run src/ui/components/TerminalImage.test.tsx src/ui/utils/terminal-image-renderer.test.ts src/ui/components/messages/ToolMessage.test.tsx (in packages/cli, the touched package) — 95 passed / 3 files
  • npm run typecheck — passed
  • npm run lint — passed (exit 0)
  • npm run build — passed

The touched behavior (Kitty transmission dedup across remounts) is exercised directly by the unit tests above rather than only through the bundled CLI, so a focused integration run was not required. No settings source changed, so npm run generate:settings-schema was not needed.

中文说明

已处理的评审反馈

发现(不阻塞合并):每次重绘都会重传整张 PNG —— 已实现

@wenshao8f8b984 上的字节级复验确认了测试计划中的每一项都可复现,并提出了一个明确标注为不阻塞合并的效率问题:TerminalImage 用一个仅存活于单次 mount 的 writtenSequence ref 来防止重复写入。而「实时行 → Static 行」的迁移以及每一次终端 resize 都会 remount 该组件,因此即便 Kitty 终端已经持有该 image id,相同的 base64 载荷仍会被重传 —— 实测静止时传输 2 次,3 次 resize 下传输 5 次(单张 5.5 MiB 图片最多写出约 40 MB)。

尽管该问题被标注为不阻塞合并、「值得后续跟进」,但它完全属于本图片展示 PR 的范围,且与现有的有界 renderCache 思路一致,因此在此一并处理,而非拆到单独的改动中。

改动

  • terminal-image-renderer.ts:Kitty 渲染结果现在会携带其渲染缓存 key;并新增一个会话级、有界的集合(wasKittyImageWritten / markKittyImageWritten,上限为 TRANSMITTED_KEY_LIMIT = 256),用于记录本终端会话中已经写出过哪些渲染 key。淘汰一个旧 key 至多带来一次重传,而不会出现空白图片,因为载荷可以从仍在缓存中的渲染结果重建。
  • TerminalImage.tsx:将单次 mount 范围的 writtenSequence ref 及其按文件路径重置的 effect,替换为单个 effect —— 仅当某个渲染 key 在本会话中尚未传输过时才写出 Kitty 序列。remount 时只会重新输出开销极小的 placeholder 单元格,由终端自行重绘它已经持有的图片。

测试

  • terminal-image-renderer.test.ts:断言 Kitty 结果在缓存命中时暴露稳定的 key(不同放置形状则对应不同的 key),并断言该传输 key 集合能记录写入、并在淘汰机制下保持有界。
  • TerminalImage.test.tsx:新增一个回归测试 —— 卸载并重新挂载组件,断言载荷只传输一次而非两次;已有的去重测试也更新为使用会话级追踪器。

无需解决冲突(--conflict false)。

验证

  • npx vitest run src/ui/components/TerminalImage.test.tsx src/ui/utils/terminal-image-renderer.test.ts src/ui/components/messages/ToolMessage.test.tsx(在 packages/cli,即被改动的包内执行)—— 95 通过 / 3 个文件
  • npm run typecheck —— 通过
  • npm run lint —— 通过(exit 0)
  • npm run build —— 通过

被改动的行为(跨 remount 的 Kitty 传输去重)由上述单元测试直接覆盖,而非只能通过打包后的 CLI 验证,因此不需要单独的集成测试。未改动任何 settings 源文件,故无需运行 npm run generate:settings-schema

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

⚠️ The branch received new commits while this round ran; they were merged into this push, but this round's verification predates that merge — re-check anything that landed mid-run. · 本轮运行期间分支收到了新的提交;本次推送已将其合并,但本轮验证在合并之前完成——请复查运行期间落地的改动。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /trigage

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 69 passed · 0 failed · 69 total

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:69 通过 · 0 失败 · 69 总计

Verification report

PR #8217feat(cli): add TUI image display tool — deep verification

Verdict: findings — scripted assertions 69 pass / 0 fail / 69 total; verified head 3e568af8 (git rev-parse HEAD^2). The PR's central claims are proven load-bearing by A/B (validation + explicit-failure gates, and the chafa executable-lookup hardening that closes an arbitrary-code-execution surface): every guard flips when reverted, and a malicious project-local chafa is spawned by the un-hardened control but never by the head build. One concrete, reproducible, PR-introduced defect survives: a failing chafa's full stderr streams unbounded into the terminal scrollback, contradicting the commit that claimed to bound it — measured at 5000 leaked bytes vs 0 with a one-line fix, reason string unchanged. Not blocking (the chafa that emits it is a trusted/system binary, so it is scrollback noise / robustness, not injection), but worth the reviewer's attention.

中文摘要
  • 结论findings。脚本化断言 69 通过 / 0 失败 / 69 总计;已验证 head 3e568af8
  • A/B 结论:核心声明经 A/B 证明为承载性(load-bearing)。验证与“显式失败”各道闸门在单点突变下全部翻转(见 Table 1);chafa 可执行文件查找的加固在 A/B 中关闭了一个任意代码执行面——未加固的对照构建会 spawn 仓库内恶意 node_modules/.bin/chafa 并写出 PWNED 标记,而 head 构建绝不 spawn 它(见 Table 2,证据 02-renderer-security-ab.png)。尺寸 wire-oracle 全部符合(见 Table 3)。
  • Findings:1 项,中等严重度、非阻塞——失败的 chafa 的完整 stderr 经 execFileSync 的 stderr 继承无界地流入终端 scrollback,与声称已“bound”该 stderr 的提交意图相矛盾;实测泄漏 5000 字节,加一行 stdio:['pipe','pipe','pipe'] 后为 0 字节且返回的 reason 不变(见 Table 403-stderr-leak-ab-head-vs-fixed.png)。因为能产出该 stderr 的 chafa 是受信任/系统二进制,故非注入,属 scrollback 噪声/健壮性问题。
  • 未覆盖范围:真实终端像素渲染(Kitty/Ghostty/Warp/iTerm2 原生放置、tmux/SSH 透传)——无图形终端、chafa 未安装,且这些是 PR 自述的 out-of-scope;仅以 env 逻辑 + chafa wire-oracle 覆盖其分支选择与回退。per-commit 归因不可达(shallow depth-2,本地仅 1 个 commit vs 快照 22 个),故只验证聚合 diff。

Scope

  • Central claim (proven): the new display_image tool validates its input (absolute in-workspace path, regular file, ≤8 MiB, full 24-byte PNG header), persists only a structured {type,filePath,mimeType} display (no bytes/escapes), and reports renderer failure explicitly to model + TUI instead of claiming success — and is registered only for the main interactive TUI and denied to forks/subagents.
  • Central claim 2 (proven, security): chafa is resolved through the Mermaid renderer's hardened findExecutable, so a hostile repository's node_modules/.bin/chafa is never executed (a real arbitrary-code-execution surface the PR closes).
  • Secondary claim (proven): preview sizing preserves aspect ratio, stays ≤72×24 and within terminal space, and does not upscale small images.
  • Out of scope (listed in Not covered): real-terminal pixel rendering; per-commit attribution.

Central claim — validation & explicit-failure gates (A/B, mutation matrix)

Harness harness-validation.mjs drives the real compiled DisplayImageTool (tool.build(p).execute(signal) with a minimal Config), then re-drives each gate against a same-directory copy with exactly one guard disabled (faithful mutants derived from the current build by make-mutants.mjs). Head must reject; the matching mutant must accept — that flip is the load-bearing proof. Raw per-assertion log: logs/validation.result.json. Capture: 01-validation-mutation-matrix.png.

Table 1 — head gates (all reject/accept as specified):

input / context head result (oracle)
valid 1×1 PNG structured terminal_image display; no base64 bytes in result; llmContent = "does not provide the image contents"
relative path throws must be absolute
path outside workspace throws outside the current workspace
missing file FILE_NOT_FOUND
directory TARGET_IS_DIRECTORY
FIFO (not regular file) TARGET_NOT_REGULAR_FILE
GIF bytes in .png INVALID_TOOL_PARAMS
size = 8 MiB + 1 FILE_TOO_LARGE
8-byte signature-only (truncated) INVALID_TOOL_PARAMS
executed inside runInForkContext EXECUTION_DENIED ("main agent")
valid PNG, renderSupport={available:false} EXECUTION_FAILED; llmContent + returnDisplay = "The image was not displayed"; returnDisplay is not a display object

Table 1b — single-guard mutants (behavior flips ⇒ guard load-bearing):

mutant (guard disabled) head mutant load-bearing?
truncation (bytesRead !== 24) rejects 8-byte file accepts it ✅ (valid PNG still accepted → mutation-specific)
8 MiB size cap FILE_TOO_LARGE no longer FILE_TOO_LARGE
fork-execution ban EXECUTION_DENIED no longer denied
explicit renderer-failure EXECUTION_FAILED claims success

Every gate is pinned: 15 head assertions + 5 mutant-flip assertions = 20/20 pass.

Central claim 2 — chafa executable-lookup hardening (arbitrary-code-execution A/B)

The highest-value probe. A hostile repo can drop node_modules/.bin/chafa; if the renderer resolves chafa off an unfiltered PATH, that binary runs with the user's privileges. Harness harness-renderer.mjs builds a workspace whose node_modules/.bin/chafa is a malicious script that writes a PWNED marker, plus a benign chafa on a normal PATH dir, then calls the real renderTerminalImage end-to-end (real execFileSync child processes; the fake binaries are #!/usr/bin/env node scripts and node's bin dir is appended to the passed PATH only so env resolves the interpreter — node's dir holds no chafa, so it cannot affect lookup). The oracle is whether the PWNED marker file appears plus the resolved path from findExecutable. Raw log: logs/renderer.result.json. Capture: 02-renderer-security-ab.png.

Table 2 — chafa hardening A/B:

cell findExecutable('chafa') resolves to end-to-end render spawns PWNED marker
HEAD (hardened) benign PATH chafa benign chafa (record file confirms argv + cwd) absent
MUTANT (skip-block disabled) malicious ws/node_modules/.bin/chafa malicious chafa created
HEAD + QWEN_CODE_MERMAID_ALLOW_LOCAL_RENDERERS=1 malicious local chafa (opt-in escape hatch)

The mutant's PWNED marker is the positive control that makes the head's "no marker" meaningful: the harness can make a render spawn the malicious binary, and the head build is exactly what stops it. The opt-in env var still restores local renderers, so the hardening is a deliberate default-deny, not a blanket break. 9/9 pass in this section.

Secondary claim — sizing wire-oracle

The renderer passes --size=WxH to chafa; the benign fake records its argv, so the harness reads back the actual dimensions the renderer chose (not a hand-computed expectation). Cells (cell = 8×16 px):

Table 3 — sizing (read back from chafa --size):

image (px) contentWidth / availHeight got WxH property
1600×1600 100 / 40 48×24 row-limited, aspect kept
64×64 (small) 100 / 40 8×4 not upscaled to content width
1920×1080 100 / 40 72×20 width-limited
800×1600 (portrait) 100 / 40 24×24 1:2 aspect via 8×16 cells
1600×1600 30 / 40 30×15 respects narrow terminal
10000×10000 200 / 100 47×23 within 72×24 cap, aspect ~2:1

6/6 pass. (The last row's exact value is not 72×24 because a square image is 2:1 in 8×16 cells and is row-limited; the asserted property is "within cap and aspect-preserving", which holds — 47/23 ≈ 2.04.)

Renderer selection (env logic) + metachar predicate

supportsKittyImageProtocol / getTerminalImageRenderSupport over synthetic env tuples: Kitty (KITTY_WINDOW_ID/TERM=kitty)/Ghostty → native; Warp, tmux, SSH_TTY, SSH_CLIENT, non-TTY → excluded; plain xterm → not kitty; no-kitty + chafa-on-PATH → available; no-kitty + no-chafa → unavailable with the explicit "chafa is not installed" reason. containsCmdShellMetacharacters flags all 11 cmd.exe metacharacters (& | < > ^ % " ! ( ) \n \r) and does not flag clean unix/windows/space paths; shouldRunThroughShell(.cmd) is false on Linux (the Windows shell-guard is dormant here, so the metachar gate is correctly inert on this platform). 28/28 pass across these two sections.

Findings

F1 (medium, non-blocking) — failing chafa's stderr leaks unbounded into the terminal scrollback

What the PR claims vs what it does. Commit 10337397 ("Bound a failing chafa's stderr … before rendering it into permanent scrollback") bounds only the returned reason string via firstLineBounded(err.stderr) (capped to 200 chars + , the value Ink renders). It does not stop the raw stderr from reaching the terminal: renderWithChafa calls execFileSync(chafaPath, args, { encoding, env, shell, maxBuffer, timeout }) with no stdio option, and on Node 22 that default inherits the child's stderr to the parent's fd2 in addition to capturing it into err.stderr. (maxBuffer caps stdout only; it does not touch the inherited stderr.) So when chafa fails while rendering a user image, its full stderr — unbounded and un-sanitized — streams into the user's scrollback.

Why this is PR-introduced, not pre-existing. The sibling it was modelled on, renderPngWithChafa in mermaidImageRenderer.ts, uses spawnSync with the same default options — and spawnSync's default does not leak (captured only). I probed both directly on this image's Node (v22.23.2):

call child stderr on parent fd2 captured into result/err
spawnSync default (mermaid path) none yes
execFileSync default (display_image path) leaked yes
execFileSync + stdio:['pipe','pipe','pipe'] none yes

The display_image chafa path is entirely new in this PR, so the leak is introduced by its choice of execFileSync without stdio.

Measured A/B (harness harness-leak.mjs + harness-leak-table.mjs; a failing chafa writes 5000 E bytes to stderr; we measure what reaches the renderer's parent fd2). Capture: 03-stderr-leak-ab-head-vs-fixed.png.

Table 4 — stderr leak A/B:

build returned reason len leaked bytes to terminal fd2
HEAD (execFileSync, no stdio) 201 5000 ← unbounded, hits scrollback
+ stdio: ['pipe','pipe','pipe'] 201 0 ← bounded; reason unchanged

The trailing block of E… lines visible at the bottom of 02-renderer-security-ab.png is this same leak from the renderer's own failing-chafa cell (the capture tool feeds the harness no TTY, so the inherited stderr lands after the captured grid).

Severity / blast radius. Not a security issue: the hardened findExecutable (proven in Table 2) means the chafa that emits the stderr is a trusted/system binary or one the user opted into — an attacker cannot place it. So the content is chafa's own diagnostics, not attacker text: no injection. The harm is robustness/UX: a misbehaving or version-mismatched chafa, or a PNG chafa rejects, dumps an unbounded, un-sanitized stderr line into permanent scrollback (and on a TTY, mid-render), exactly the "permanent scrollback" surface the commit message said it bounded. The interactive TUI does not redirect process.stderr away from the terminal, so the leak reaches the user's screen.

Reproduce:

D=tmp/pr8217-verify-20260801-112351
CLI=packages/cli/dist/src/ui/utils
node "$D/make-mutants.mjs"           # regenerates the fixed build from the current dist
node "$D/harness-leak.mjs" "/__w/qwen-code/qwen-code/$CLI/terminal-image-renderer.js"        2>/tmp/leak-head.txt
node "$D/harness-leak.mjs" "/__w/qwen-code/qwen-code/$CLI/terminal-image-renderer-fixed.mjs" 2>/tmp/leak-fixed.txt
echo "head=$(wc -c </tmp/leak-head.txt) fixed=$(wc -c </tmp/leak-fixed.txt)"   # head=5000 fixed=0
Minimal suggested fix (measured, preserves intent)

In packages/cli/src/ui/utils/terminal-image-renderer.ts, renderWithChafa's execFileSync options, add a stdio that captures stderr instead of inheriting it:

{
  encoding: 'utf8',
  env: createRendererChildEnv(env),
  shell: useShell,
  stdio: ['pipe', 'pipe', 'pipe'],   // <- capture stderr; stops the fd2 leak
  maxBuffer: CHAFA_MAX_OUTPUT_BYTES,
  timeout: CHAFA_TIMEOUT_MS,
}

Measured in a scratch copy of the built module (terminal-image-renderer-fixed.mjs, derived from the current build by make-mutants.mjs, differing by this one option): leak 5000 → 0 bytes on fd2; returned reason unchanged at 201 chars (still bounded by firstLineBounded); render result kind unchanged (unavailable on the failing chafa). The 46-assertion renderer suite's behaviour on the success path is unaffected because a succeeding chafa writes nothing to stderr. (Not folded into the working tree — reported only.) The same stdio should be considered for the sibling spawnSync calls in mermaidImageRenderer.ts for consistency, though they do not currently leak.

Corrections

  • Commit 10337397 overstates its own effect. Its message says it bounds a failing chafa's stderr "before rendering it into permanent scrollback." Measured (Table 4), it bounds only the returned reason string; the raw stderr still streams unbounded to the terminal via execFileSync's default stderr inheritance. This is a correction to the commit's description, surfaced as finding F1 with a measured fix — not a request to change anything else in the PR.

Not covered

  • Real-terminal pixel rendering — Kitty/Ghostty native virtual-image placement, Warp/iTerm2 direct placement, tmux/SSH passthrough, and the actual on-screen appearance of the chafa ANSI output were not exercised: this container has no graphical terminal and no chafa installed (confirmed: command -v chafa → not found; process.stdout.isTTY true but no emulator). These are exactly the surfaces the PR lists as out-of-scope / manually-observed-on-macOS. I covered their branch selection (env logic, Table for selection) and the chafa fallback via a faithful fake-chafa wire-oracle (Tables 2–4), which proves the code paths and the data handed to chafa, not the rendered pixels. Distinguishing shape from cause: the harness reproduces the wire shape the renderer emits (the --size, the spawn argv, the env allowlist), not a real terminal's interpretation of it.
  • Per-commit attribution — the checkout is shallow (depth 2): git rev-list HEAD^1..HEAD^2 returns 1 locally while the metadata snapshot lists 22 commits, the shallow-boundary trap. Per-commit claims (e.g. which commit added the metachar guard vs the cache) were not individually exercised; only the aggregate HEAD^1..HEAD diff was verified.
  • Windows shell-injection path end-to-endshouldRunThroughShell is true only on win32, so the containsCmdShellMetacharacters gate is dormant on this Linux image; I verified the predicate (all 11 metachars flagged, clean paths not) but could not exercise the actual shell:true + .cmd-shim spawn that the guard protects. The predicate coverage is the part that transfers cross-platform.
  • The PR's own test suite as vacuity evidence — reported separately in logs/vacuity.txt (a source-level mutation, not rerunnable from the artifact dir without touching the working tree), so it is method evidence, not counted in assertions.json.

Targeted gates (affected workspaces only)

All green on the head build:

suite result
packages/core display-image + fork-subagent 26 passed
packages/core agent + tools + config + coreToolScheduler + background-agent-resume 1117 passed
packages/cli terminal-image-renderer + TerminalImage + ToolMessage 96 passed

The config suite includes registers display_image only for the main interactive TUI (covers interactive / sdkMode / screenReader / forSubAgent gating) and forwards terminal image renderer support to the display tool — both passed, corroborating the registration gate independently of my harness. The Ink patch (patches/ink+7.0.3.patch) change is a cosmetic patch-package 8.x regeneration (hunk-header context suffixes + blob hashes; functional hunks unchanged): npx patch-package reports ink@7.0.3 ✔ and the patched selectionBreakAfter prop is present in node_modules/ink/build/components/Text.js.

Methodology

Environment: node:22-bookworm verify container, node v22.23.2, process.platform=linux, no chafa, no graphical terminal; working tree = refs/pull/8217/merge at depth 2 (HEAD merge, HEAD^1 base tip bd855991, HEAD^2 PR head 3e568af8); npm ci + npm run build pre-run at head, reused as-is. Each harness imports the compiled dist/ output (real code, no stubs of the unit under test) and drives it with real child processes (fake chafa binaries) over real execFileSync/spawnSync. A/B controls are same-directory copies of a dist module with exactly one guard disabled (make-mutants.mjs), so relative imports resolve identically and the control differs from head by nothing but the guard; the chafa-hardening A/B additionally asserts the realpath/oracle (the PWNED marker) so the head's clean result is not a false negative. Internal-workspace-link confound: harnesses import dist/ directly by absolute path (not via node_modules/@qwen-code/* symlinks), so the base/head symlink trap does not apply. Evidence captures via scripts/verify-capture.mjs (ANSI→PNG, no browser). Raw per-assertion logs in logs/; scratch mutant/fix dist copies were removed after capture (tree clean). Assertion counts in assertions.json (69/0/69) map one-to-one to scripted checks that executed; the source-level vacuity mutation and the targeted-gate runs are method evidence reported above, not in assertions.json. No network calls, no GitHub writes.

Evidence images

01-validation-mutation-matrix

02-renderer-security-ab

03-stderr-leak-ab-head-vs-fixed

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 3e568af854182f7fb13b7338c632f2853a7232e3 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 3e568af854182f7fb13b7338c632f2853a7232e3既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship — CI landed green after the review. ✅

@DragonnZhang
DragonnZhang added this pull request to the merge queue Aug 1, 2026
Merged via the queue into QwenLM:main with commit e9db70a Aug 1, 2026
40 of 41 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add terminal image previews to the interactive TUI

4 participants