Skip to content

feat(cli): /images opens image URLs from the last response in the browser (#103) - #222

Merged
justrach merged 1 commit into
mainfrom
fix/103-images-command
Jul 31, 2026
Merged

feat(cli): /images opens image URLs from the last response in the browser (#103)#222
justrach merged 1 commit into
mainfrom
fix/103-images-command

Conversation

@justrach

Copy link
Copy Markdown
Owner

Problem

GitHub issues attach screenshots, which gh issue view prints as markdown/URLs — but a terminal can't render images, so you see an inert link and no picture:

Issue #42 has two attachments: ![login screen](https://github.com/user-attachments/assets/screenshot-login.png)
and ![crash log](https://github.com/user-attachments/assets/crash-log.png).

Fix — a /images command

Scans the most recent turn's output (assistant text + tool results) for image URLs and opens them in the default browser (macOS open / else xdg-open):

› /images
found 2 images:
  1. https://github.com/user-attachments/assets/screenshot-login.png
  2. https://github.com/user-attachments/assets/crash-log.png

(shown with GRAFF_NO_BROWSER=1, which lists instead of opening — for headless/SSH, and how the test asserts without spawning tabs. Normally it prints opening … and launches them.)

How it works

  • src/images.zig — pure extractImageUrls: markdown ![](url) (incl. CommonMark "title" and <url> forms), bare URLs ending in an image extension, and GitHub attachment hosts (user-attachments / *.githubusercontent.com) that serve images without one. Order-preserving, de-duplicated, http(s)-only so a model can't smuggle a javascript:/file: scheme. 8 unit tests.
  • commands_model.zig — the handler (placed before /image's startsWith so /images isn't swallowed) + recentTurnText, which serializes the last turn to JSON and scans it (provider-agnostic — URLs survive serialization). The turn boundary uses cleanUserTurn so an image-attachment prompt (array content) still bounds the scan. Transient work runs in a scratch arena freed on return (not the long-lived session arena, Long-running sessions grow RSS unboundedly — root Agent.arena is never reset (benchmarks/memory.py can't see it) #124). The browser spawn is capped at 8 tabs; the full list always prints.
  • oauth.zigopenBrowser made pub. main.zigGRAFF_NO_BROWSER=1. command_catalog.zig — catalog entry (menu/help/completion). The chat repl points /images at the main session (it runs no tools), like /image / /bash.

Review

Ran an adversarial review of the diff (correctness / integration / resource-safety lenses, each finding then independently verified). 7 candidates → 4 confirmed and fixed:

  1. recentTurnText boundary missed image-attachment prompts (array content) → widened the scan → now uses cleanUserTurn.
  2. markdown ![](url "title") / ![](<url>) mis-parsed → now cut at whitespace + unwrap angle brackets (+ tests).
  3. uncapped browser spawn (model-controlled URL count) → capped at 8.
  4. transient serialization used the session arena → moved to a per-call scratch arena.

Tests

  • src/images.zig: 8 unit tests (markdown/bare/host forms, dedupe, trailing punct, JSON context, title/angle, empty/no-match).
  • New scripts/test-pty-images.py: drives a mock backend returning two attachment URLs, asserts /images extracts + lists them (GRAFF_NO_BROWSER=1, no real tabs) plus the zero-image path. (This E2E test caught a real std.json.Stringify-reuse crash during development that the unit tests couldn't.)
  • zig build test green, zig fmt clean, streaming PTY suite green.

Closes #103

…wser (#103)

GitHub issues attach screenshots that `gh issue view` prints as markdown/URLs the
terminal can't render. /images scans the most recent turn's output (assistant text
+ tool results), extracts image URLs, and opens them in the default browser
(macOS `open` / else `xdg-open`).

- src/images.zig: pure extractImageUrls — markdown images (incl. CommonMark titles
  and <url> forms), bare URLs ending in an image extension, and GitHub attachment
  hosts (user-attachments / *.githubusercontent.com) that serve images without one.
  Order-preserving, de-duplicated, http(s)-only so a model can't smuggle a
  javascript:/file: scheme. 8 unit tests.
- commands_model.zig: the /images handler (before /image's startsWith so it isn't
  swallowed) + recentTurnText, which serializes the last turn to JSON and scans it
  (provider-agnostic — URLs survive serialization). The turn boundary uses
  cleanUserTurn so an image-attachment prompt (array content) still bounds the
  scan. Transient work runs in a scratch arena freed on return (not the session
  arena, #124). The browser spawn is capped at 8 tabs; the full list always prints.
- oauth.zig: openBrowser made pub. main.zig: GRAFF_NO_BROWSER=1 (headless/SSH)
  lists URLs instead of spawning. command_catalog.zig: catalog entry. The chat
  repl points /images at the main session (it runs no tools), like /image /bash.

Reviewed adversarially (correctness/integration/resource lenses + per-finding
verification); 4 confirmed findings fixed: attachment-prompt boundary, CommonMark
title/angle URLs, uncapped tabs, session-arena churn.

Tests: src/images.zig 8 unit tests; new scripts/test-pty-images.py drives a mock
backend returning two attachment URLs and asserts /images extracts + lists them
(GRAFF_NO_BROWSER=1, no real tabs) plus the zero-image path; zig build test green,
zig fmt clean, streaming PTY suite green.

Closes #103

Co-Authored-By: blackfloofie <265516171+blackfloofie@users.noreply.github.com>
@justrach
justrach merged commit 21ae0f9 into main Jul 31, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable opening attached issue images from the TUI

1 participant