Skip to content

feat(images): Add image attachment support for create-prd#195

Merged
subsy merged 11 commits intosubsy:mainfrom
jesse-merhi:feat/image-attachment-clean
Feb 13, 2026
Merged

feat(images): Add image attachment support for create-prd#195
subsy merged 11 commits intosubsy:mainfrom
jesse-merhi:feat/image-attachment-clean

Conversation

@jesse-merhi
Copy link
Copy Markdown
Contributor

@jesse-merhi jesse-merhi commented Jan 22, 2026

Summary

This PR adds clipboard image paste support (Ctrl+V) for the create-prd chat interface, enabling users to attach images from their clipboard directly into the conversation with the AI agent.
image

Features

  • Clipboard paste support (Ctrl+V): Paste images directly from clipboard into the chat
  • [Image N] inline markers: Visual indicators in the input showing attached images
  • Cursor skip-over: Markers are treated as atomic units - cursor jumps over them rather than moving through
  • Atomic marker deletion: Backspace/delete removes entire markers at once
  • Auto cleanup on exit: Attached images are automatically cleaned up when exiting
  • /clear-images slash command: Clear all attached images
  • Cross-platform clipboard support: macOS (pngpaste), Linux (xclip/wl-paste), Windows (PowerShell)

Files Changed

New files

  • src/tui/hooks/ - useImageAttachment.ts, useImageAttachmentWithFeedback.ts, useInlineImageIndicators.ts, usePaste.ts, usePasteHint.ts, useToast.ts, index.ts
  • src/tui/utils/ - clipboard-image.ts, exit-cleanup.ts, image-detection.ts, image-storage.ts, marker-edit.ts, slash-commands.ts, image-attachment.test.ts
  • src/tui/components/ImageAttachmentCount.tsx

Modified files

  • src/tui/components/ChatView.tsx - Marker detection, cursor skip-over, paste handling
  • src/tui/components/PrdChatApp.tsx - Image attachment integration
  • src/tui/components/Toast.tsx - Added ToastContainer for feedback notifications
  • src/config/types.ts - Added ImageConfig, ImageCleanupPolicy, DEFAULT_IMAGE_CONFIG
  • src/config/index.ts - Re-export new types
  • src/commands/create-prd.tsx - Exit cleanup integration

Testing

  • Unit tests in src/tui/utils/image-attachment.test.ts (32 tests passing)
  • Manual testing on macOS with pngpaste

Notes

This is a clean branch created from upstream/main with only functional changes - no formatting/prettier changes that could cause merge conflicts.

Summary by CodeRabbit

  • New Features
    • Image attachment (clipboard & paste), inline image markers, attachment count UI, prompt-suffix generation, paste classification, and slash command to clear stored images
    • Marker-aware editing, enhanced keyboard navigation, paste hint and toast notification system, and external insert-text/ref support in chat view
    • Cross-platform clipboard image reader, deduplicating image storage, and configurable image cleanup with graceful async exit cleanup
  • Tests
    • Expanded tests for image detection, storage, markers and clipboard reading
  • Chores
    • Configuration type exports and code-style normalisation; CI test batching tweaks

Add clipboard image paste support to the create-prd chat interface:
- Ctrl+V to paste images from clipboard (macOS, Linux, Windows)
- Inline [Image N] markers in text with cursor skip-over
- Atomic marker deletion on backspace/delete
- Auto cleanup of stored images on exit
- /clear-images slash command
- Toast notifications for feedback

New files:
- src/tui/hooks/* - Image attachment hooks
- src/tui/utils/clipboard-image.ts - Cross-platform clipboard
- src/tui/utils/image-detection.ts - Image format detection
- src/tui/utils/image-storage.ts - Image storage with deduplication
- src/tui/utils/exit-cleanup.ts - Cleanup handler
- src/tui/utils/slash-commands.ts - Slash command framework
- src/tui/components/Toast.tsx - Toast notifications

Modified:
- ChatView.tsx - Marker detection and cursor skip-over
- PrdChatApp.tsx - Image attachment integration
- config/types.ts - ImageConfig type
- create-prd.tsx - Exit cleanup integration
Resolves merge conflicts:
- src/commands/create-prd.tsx: kept upstream timeout ?? 0 change
- src/config/index.ts: merged envExclude handling
- src/tui/components/Toast.tsx: merged both Toast APIs
  - RunApp connection toasts (visible, message, icon, variant, bottom, right)
  - ChatView image attachment toasts (ToastContainer/ToastList)
@vercel
Copy link
Copy Markdown

vercel bot commented Jan 22, 2026

@jesse-merhi is attempting to deploy a commit to the plgeek Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jan 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds end-to-end image-attachment support (detection, clipboard reading, storage with dedupe), inline image markers and editor utilities, toasts and paste hooks, slash-commands, async exit cleanup for stored images, TUI component/hook integrations, and related tests and CI adjustments.

Changes

Cohort / File(s) Summary
Configuration & Types
src/config/types.ts, src/config/index.ts
Add ImageConfig, ImageCleanupPolicy, DEFAULT_IMAGE_CONFIG; extend StoredConfig with images?; export surface and formatting tweaks.
Create PRD / CLI exit cleanup
src/commands/create-prd.tsx, src/tui/utils/exit-cleanup.ts
Add async performExitCleanup, registration helpers; invoke cleanup on PRD complete/cancel with non-blocking warning handling.
Image storage
src/tui/utils/image-storage.ts
New content-addressed image storage API with SHA256-based dedupe, listing, deletion, purge and safety checks.
Image detection & parsing
src/tui/utils/image-detection.ts
Multi-source image detection (file paths, OSC 52, Data URIs, raw base64), format detection and path validation helpers.
Clipboard image reader
src/tui/utils/clipboard-image.ts
Cross-platform clipboard image reader (macOS, Linux, Windows) with tool detection, install hints and unified result shape.
Clipboard text utilities & tests
src/utils/clipboard.ts, tests/utils/clipboard.test.ts
Add readFromClipboard orchestration; tests moved to per-test spies and extended to cover read scenarios and platform fallbacks.
Hooks & barrel
src/tui/hooks/*, src/tui/hooks/index.ts
New hooks: useImageAttachment, useImageAttachmentWithFeedback, useInlineImageIndicators, usePaste, usePasteHint, useToast; add barrel exports.
Inline indicators & editor helpers
src/tui/hooks/useInlineImageIndicators.ts, src/tui/utils/marker-edit.ts
Zero-width inline markers, renumbering, orphan removal, cursor-aware edits, marker-aware safe deletion and word-boundary navigation.
Attachment workflows & feedback
src/tui/hooks/useImageAttachment.ts, src/tui/hooks/useImageAttachmentWithFeedback.ts, src/tui/hooks/useToast.ts
APIs to attach images from file/clipboard/base64, dedupe, prompt-suffix generation; user-facing categorized toasts and install hints.
Paste handling & hints
src/tui/hooks/usePaste.ts, src/tui/hooks/usePasteHint.ts
Terminal paste subscription with debouncing; one-time paste hint toast with keyboard-dismiss behaviour.
TUI components & integration
src/tui/components/ChatView.tsx, src/tui/components/PrdChatApp.tsx, src/tui/components/ImageAttachmentCount.tsx, src/tui/components/Toast.tsx
Extend ChatView props (onPaste, onImageMarkerDeleted, insertTextRef, toasts); integrate image hooks in PrdChatApp; add ImageAttachmentCount and refactor Toast to multi-toast list.
Slash commands
src/tui/utils/slash-commands.ts
Add slash-command parsing/execution (e.g. /clear-images) wired to image-storage deletion.
Tests for image utilities & PRD paste
src/tui/utils/image-attachment.test.ts, src/tui/components/PrdChatApp.labels.test.ts
New tests for detection, storage, dedupe, deletion, marker handling and paste classification.
CI
.github/workflows/ci.yml
Split test batching: isolate tests/commands/info.test.ts into a separate run and coverage part.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ChatView
    participant PasteHook as usePaste
    participant ImageHook as useImageAttachmentWithFeedback
    participant Storage as ImageStorage
    participant PrdApp as PrdChatApp

    User->>ChatView: paste / paste event
    ChatView->>PasteHook: paste callback
    PasteHook-->>ChatView: debounced paste text
    ChatView->>ImageHook: attachImage(pastedInput)
    ImageHook->>ImageHook: classify (path / base64 / osc52 / clipboard)
    ImageHook->>Storage: storeImageFromBuffer / storeImageFromPath
    Storage-->>ImageHook: stored path/info
    ImageHook-->>ChatView: return inline marker ([Image N]) and state
    User->>ChatView: submit message
    ChatView->>PrdApp: getPromptSuffix() + clean text
    PrdApp->>Storage: reference stored image paths (if required)
    PrdApp-->>User: augmented prompt sent to agent
Loading
sequenceDiagram
    participant Process
    participant Cleanup as performExitCleanup
    participant Config as StoredConfig
    participant Storage as ImageStorage

    Process->>Cleanup: invoke on exit
    Cleanup->>Config: read stored config (cleanup_policy)
    alt policy = "on_exit"
        Cleanup->>Storage: listStoredImages()
        Storage-->>Cleanup: image list
        Cleanup->>Storage: deleteAllStoredImages()
        Storage-->>Cleanup: deletion count
        Cleanup-->>Process: return deletedCount
    else policy = "manual" or "never"
        Cleanup-->>Process: return skipped (reason)
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: adding image attachment support to the create-prd command, which aligns with the main objectives and changeset.
Docstring Coverage ✅ Passed Docstring coverage is 94.06% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Jan 22, 2026

Codecov Report

❌ Patch coverage is 71.15987% with 92 lines in your changes missing coverage. Please review.
✅ Project coverage is 44.17%. Comparing base (407cd38) to head (4f19224).
⚠️ Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
src/utils/clipboard.ts 62.26% 40 Missing ⚠️
src/config/index.ts 74.10% 29 Missing ⚠️
src/commands/create-prd-utils.ts 74.15% 23 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #195      +/-   ##
==========================================
+ Coverage   42.98%   44.17%   +1.19%     
==========================================
  Files          95       95              
  Lines       29974    29839     -135     
==========================================
+ Hits        12883    13181     +298     
+ Misses      17091    16658     -433     
Files with missing lines Coverage Δ
src/config/types.ts 100.00% <100.00%> (ø)
src/commands/create-prd-utils.ts 74.15% <74.15%> (ø)
src/config/index.ts 56.36% <74.10%> (+1.75%) ⬆️
src/utils/clipboard.ts 66.33% <62.26%> (-6.22%) ⬇️

... and 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jesse-merhi
Copy link
Copy Markdown
Contributor Author

jesse-merhi commented Jan 22, 2026

Hopefully this a bit more clean.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@src/config/types.ts`:
- Around line 79-101: The default for max_images_per_message in
DEFAULT_IMAGE_CONFIG conflicts with the ImageConfig comment; update
DEFAULT_IMAGE_CONFIG to set max_images_per_message: 10 (instead of 0) and adjust
or remove the inline comment "0 = unlimited" so the code and docstring both
reflect a default limit of 10; locate symbols ImageConfig and
DEFAULT_IMAGE_CONFIG and change the default value for max_images_per_message
accordingly.

In `@src/tui/components/ChatView.tsx`:
- Around line 562-579: The onPaste callback call inside handlePaste can return a
Promise and currently its rejection is unhandled; update the handlePaste
implementation (function handlePaste) so when onPaste is present you invoke it
via Promise.resolve(onPaste(text, event)).catch(err => { /* log or ignore safe
*/ }) to catch rejections safely, preserving existing behavior tied to
inputEnabled and isLoading and keeping the PasteEvent handling semantics.

In `@src/tui/hooks/useImageAttachment.ts`:
- Around line 288-347: attachFromClipboard reads attachedImages.length directly
which can be stale; fix by tracking count in a ref (e.g., imageCountRef) and
using that for the maxImages check and when computing imageNumber, and update
imageCountRef.current inside the setAttachedImages functional updater (the
updater already receives prev so increment ref there). Keep the rest of
attachFromClipboard unchanged (use setAttachedImages(prev => [...prev, image]))
and remove attachedImages.length dependency (refs are stable) so the callback
won't rely on a potentially stale array snapshot; reference symbols:
attachFromClipboard, attachedImages, setAttachedImages, maxImages,
imageCountRef.
- Around line 493-513: The removeImageById function has a race where the local
found flag is set inside the setAttachedImages updater but returned
synchronously; fix by synchronously checking attachedImages for the image before
calling setAttachedImages: compute index/imageToRemove from the current
attachedImages state, set found based on that check, then call
setAttachedImages(prev => prev.filter((_, i) => i !== index)) and call
deleteStoredImage(imageToRemove.storedPath) if found; update the useCallback
dependency array to include attachedImages so removeImageById uses the latest
state.
- Around line 155-210: You have a duplicate, simplistic detectBase64Image
implementation in this file; remove the local function and import the more
complete utility from ../utils/image-detection.ts, then replace calls to the
local detectBase64Image (the usage in this file) with the imported one so
callers receive the full Base64ImageResult (including base64Data, imageData:
Buffer, extension, error); ensure you update any type annotations or handling at
the call site to use the utility's shape and handle possible error/imageData
fields accordingly.

In `@src/tui/hooks/useInlineImageIndicators.ts`:
- Around line 195-216: getIndicatorNumber is currently reading stale
nextNumberRef.current and then calling setIndicatorMap/setNextNumber, causing
duplicate indicator numbers on rapid calls; fix by synchronously updating the
refs before scheduling state updates: read indicatorMapRef.current and
nextNumberRef.current, if imageId not present assign number =
nextNumberRef.current, then immediately set indicatorMapRef.current[imageId] =
number and increment nextNumberRef.current, and only afterwards call
setIndicatorMap and setNextNumber to mirror the refs; update getIndicatorNumber
to return the newly assigned number and keep using refs to avoid stale closures.

In `@src/tui/utils/clipboard-image.ts`:
- Around line 154-169: The Linux detection block in checkClipboardTool currently
treats 'xsel' as available though readClipboardImage has no reader for 'xsel',
causing false positives; remove the xsel branch from the case 'linux' block (and
remove xsel from any installHint string interpolation) so only wl-paste and
xclip are reported as available, and verify readClipboardImage (the reader
switch/handlers) remains unchanged; update the installHint to reference only the
remaining tools (xclip and wl-paste).

In `@src/tui/utils/image-storage.ts`:
- Around line 434-457: The deleteStoredImage function currently misclassifies
paths and can allow directory traversal; change its path handling to treat only
absolute paths as absolute (use path.isAbsolute), otherwise resolve the
candidate against getStorageDir(baseDir) via path.resolve, then compute
path.relative(storageDir, resolvedPath) and reject deletion if the relative path
starts with '..' or is an absolute traversal (i.e., relativePath === '' or
startsWith('..') or includes(`..${path.sep}`)); ensure you use the resolved
storageDir (path.resolve(getStorageDir(baseDir))) and only call unlink on paths
that are confirmed inside that storageDir, preserving the existing existence
check and error handling in deleteStoredImage.

In `@src/tui/utils/marker-edit.ts`:
- Around line 1-2: Add a file-level JSDoc header comment at the very top of
src/tui/utils/marker-edit.ts that begins with "ABOUTME: " and succinctly
describes the file's purpose (e.g., what marker-edit utilities provide for the
TUI). Ensure the comment precedes the import statement for TextareaRenderable
and follows the project's JSDoc style conventions so automatic linters recognize
the ABOUTME header.
🧹 Nitpick comments (6)
src/commands/create-prd.tsx (1)

305-343: Consider extracting duplicated cleanup logic.

The cleanup logic in handleComplete (lines 310-318) and handleCancel (lines 330-338) is identical. Consider extracting it to a helper function to reduce duplication.

♻️ Suggested refactor
+  const cleanupImages = async () => {
+    try {
+      await performExitCleanup({ cwd });
+    } catch (err) {
+      console.error(
+        "Warning: Image cleanup failed:",
+        err instanceof Error ? err.message : String(err),
+      );
+    }
+  };
+
   const handleComplete = async (result: PrdCreationResult) => {
     root.unmount();
     renderer.destroy();

-    // Clean up any attached images from this session
-    try {
-      await performExitCleanup({ cwd });
-    } catch (err) {
-      // Don't let cleanup errors prevent normal exit
-      console.error(
-        "Warning: Image cleanup failed:",
-        err instanceof Error ? err.message : String(err),
-      );
-    }
+    await cleanupImages();

     console.log("");
     console.log(`PRD workflow complete: ${result.prdPath}`);
     resolvePromise(result);
   };

   const handleCancel = async () => {
     root.unmount();
     renderer.destroy();

-    // Clean up any attached images from this session
-    try {
-      await performExitCleanup({ cwd });
-    } catch (err) {
-      // Don't let cleanup errors prevent normal exit
-      console.error(
-        "Warning: Image cleanup failed:",
-        err instanceof Error ? err.message : String(err),
-      );
-    }
+    await cleanupImages();

     console.log("");
     console.log("PRD creation cancelled.");
     resolvePromise(null);
   };
src/tui/utils/image-attachment.test.ts (1)

92-173: Consider extracting the PNG buffer constant for reuse.

The valid PNG buffer bytes are repeated across multiple tests with slight variations. Consider extracting a minimal valid PNG constant at the top of the describe block for better maintainability.

♻️ Suggested refactor
 describe('Image Storage', () => {
   let testDir: string;
+  
+  // Minimal valid PNG (1x1 transparent pixel)
+  const VALID_PNG_HEADER = Buffer.from([
+    0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
+  ]);

   beforeEach(async () => {
src/tui/utils/marker-edit.ts (1)

78-94: Callback invocation order may not match visual order.

At line 81, markers are sorted by start descending for deletion (to preserve indices), but at line 91, onImageMarkerDeleted is called in the original markers array order (as found in the text, left-to-right). This inconsistency between deletion order and callback order could cause confusion if the callback has side effects that depend on order.

Consider iterating over sorted for both operations or documenting the expected behaviour.

♻️ Suggested consistency fix
       editBuffer.setText(newText);
       editBuffer.setCursorByOffset(Math.min(...markers.map((m) => m.start)));

-      for (const m of markers) onImageMarkerDeleted(m.imageNumber);
+      // Call in ascending order (visual order) for consistent UX
+      const sortedAsc = [...markers].sort((m1, m2) => m1.start - m2.start);
+      for (const m of sortedAsc) onImageMarkerDeleted(m.imageNumber);
       return;
src/tui/components/PrdChatApp.tsx (1)

519-525: Redundant variable assignment.

The comment mentions "no zero-width markers to clean" but userMessage is simply assigned the same value as rawMessage without any transformation. This appears to be leftover from a previous implementation.

♻️ Simplify by using rawMessage directly
     const rawMessage = value?.trim() ?? inputValue.trim();
     if (!rawMessage || isLoading) {
       return;
     }

-    // Use the message as-is (no zero-width markers to clean)
-    const userMessage = rawMessage;
-
     // Check for slash commands first
-    if (isSlashCommand(userMessage)) {
+    if (isSlashCommand(rawMessage)) {
       setInputValue('');
-      const result = await executeSlashCommand(userMessage, {
+      const result = await executeSlashCommand(rawMessage, {

And use rawMessage throughout the rest of the function instead of userMessage.

src/tui/hooks/useImageAttachmentWithFeedback.ts (1)

149-161: getInstallHint and getInstallHints share platform-checking logic, but serve different purposes and cannot be directly unified.

getInstallHints() in clipboard-image.ts returns a Record<string, string> with detailed, tool-specific installation instructions (including distribution-specific details like Debian/Ubuntu vs Arch), used internally by clipboard utilities. getInstallHint() in useImageAttachmentWithFeedback.ts returns a simple string for toast notifications, with more generic messages. Since getInstallHints is not exported and the return types differ, direct reuse is not straightforward. If refactoring, consider whether extracting shared platform logic into a common utility is worthwhile, though the current separation is maintainable given the different contexts and message styles.

src/tui/utils/clipboard-image.ts (1)

41-100: Consider reusing the shared command-existence helper. There is already a commandExists in src/sandbox/detect.ts with a timeout; reusing it (or extracting a shared util) would avoid divergence and reduce the chance of a hang in edge cases.

@jesse-merhi
Copy link
Copy Markdown
Contributor Author

Hey @subsy!

Just wanted to know if I can do anything to help merge this in - Imagine image support would be quite useful for UI based tasks.

AI Agent added 2 commits February 13, 2026 15:57
…-clean

# Conflicts:
#	src/commands/create-prd.tsx
#	src/config/index.ts
#	src/config/types.ts
#	src/tui/components/PrdChatApp.tsx
Copilot AI review requested due to automatic review settings February 13, 2026 16:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ralph-tui Ignored Ignored Preview Feb 13, 2026 8:17pm

Request Review

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/commands/create-prd.tsx`:
- Line 409: The async handlers handleComplete and handleCancel are passed into
PrdChatApp but invoked synchronously, risking unhandled rejections; update
PrdChatApp's prop types to accept Promise-returning callbacks (e.g. onComplete:
(result: PrdCreationResult) => Promise<void> and onCancel: () => Promise<void>),
change the call sites inside PrdChatApp (the places currently invoking those
props around lines 788, 842, 856) to await the calls and wrap them in try/catch
to handle errors, and keep handleComplete/handleCancel async as-is (or convert
them to sync if you intentionally want sync behavior). This ensures any
rejections from performExitCleanup() or future async work are properly awaited
and handled.

In `@src/tui/hooks/useInlineImageIndicators.ts`:
- Around line 443-472: The renumberIndicators function currently does sequential
per-entry regex replacements using indicatorMap which causes incorrect
double-replacements when numbers are swapped; change it to perform a single-pass
replacement over the whole text using a global indicator pattern (e.g.,
INDICATOR_PATTERN matching `${INDICATOR_START}[Image (\\d+)]${INDICATOR_END}`)
and a replacer function that looks up each matched image id in the newly built
newMap to return the correct `[Image N]` string, then update state via
setIndicatorMap(newMap) and setNextNumber(attachedImages.length + 1); ensure you
no longer iterate over indicatorMap for replacements and instead rely on the
single global replace with the replacer to avoid swapping collisions.

In `@src/tui/utils/clipboard-image.ts`:
- Around line 211-231: hasClipboardImage currently checks xclip before wl-paste
which causes Wayland systems with xclip present to prematurely return false;
change hasClipboardImage to prefer wl-paste first (matching readClipboardImage
and checkClipboardTool) by attempting wl-paste detection/execCommand before
falling back to xclip so Wayland clipboard reads succeed and attachFromClipboard
in useImageAttachment.ts can proceed when wl-paste would work.

In `@src/tui/utils/marker-edit.ts`:
- Around line 81-96: The current deleteRangeSafely logic removes only
overlapping markers (via findMarkersOverlappingRange and slicing by
marker.start/marker.end) and ignores the original [a,b] range; update
deleteRangeSafely to also remove the non-marker portion of the requested range:
compute a newText that removes the full range [a,b] and then remove any markers
within that range (or remove markers first and then remove the remaining span of
[a,b] while adjusting offsets), then call editBuffer.setText(newText), set
cursor with editBuffer.setCursorByOffset(Math.min(...markers.map(m=>m.start),
a)), and invoke onImageMarkerDeleted(m.imageNumber) for each removed marker so
both the marker and the rest of the requested deletion are cleared.
🧹 Nitpick comments (13)
src/commands/create-prd.tsx (1)

409-447: Extract duplicated cleanup into a shared helper.

handleComplete and handleCancel contain identical unmount → cleanup → log sequences (lines 410-422 and 430-442). Extracting a small helper keeps both paths in sync and reduces the risk of them diverging over time.

♻️ Proposed refactor
+    const teardown = async () => {
+      root.unmount();
+      renderer.destroy();
+      try {
+        await performExitCleanup({ cwd });
+      } catch (err) {
+        console.error(
+          "Warning: Image cleanup failed:",
+          err instanceof Error ? err.message : String(err),
+        );
+      }
+    };
+
     const handleComplete = async (result: PrdCreationResult) => {
-      root.unmount();
-      renderer.destroy();
-
-      // Clean up any attached images from this session
-      try {
-        await performExitCleanup({ cwd });
-      } catch (err) {
-        // Don't let cleanup errors prevent normal exit
-        console.error(
-          "Warning: Image cleanup failed:",
-          err instanceof Error ? err.message : String(err),
-        );
-      }
-
+      await teardown();
       console.log("");
       console.log(`PRD workflow complete: ${result.prdPath}`);
       resolvePromise(result);
     };

     const handleCancel = async () => {
-      root.unmount();
-      renderer.destroy();
-
-      // Clean up any attached images from this session
-      try {
-        await performExitCleanup({ cwd });
-      } catch (err) {
-        // Don't let cleanup errors prevent normal exit
-        console.error(
-          "Warning: Image cleanup failed:",
-          err instanceof Error ? err.message : String(err),
-        );
-      }
-
+      await teardown();
       console.log("");
       console.log("PRD creation cancelled.");
       resolvePromise(null);
     };
tests/utils/clipboard.test.ts (1)

282-343: Good coverage for the happy path — consider adding error-handling tests for readFromClipboard.

The new read tests cover macOS, Linux (with fallback), and Windows. The write suite already tests ENOENT, stderr, other-error, and unsupported platform, but the read suite currently lacks those analogous cases. Adding at least a "all Linux tools fail" test and an unsupported-platform test would improve confidence.

src/tui/components/PrdChatApp.labels.test.ts (1)

1-4: ABOUTME header doesn't mention classifyPastePayload tests.

The file now also tests classifyPastePayload, but the header only mentions buildBeadsLabelsInstruction. As per coding guidelines, all code files should start with a file-level JSDoc comment explaining the file's purpose.

📝 Suggested update
 /**
- * ABOUTME: Tests for buildBeadsLabelsInstruction helper.
- * Verifies case-insensitive dedup and label instruction formatting.
+ * ABOUTME: Tests for buildBeadsLabelsInstruction and classifyPastePayload helpers.
+ * Verifies case-insensitive dedup, label instruction formatting, and paste payload classification.
  */
src/tui/utils/image-attachment.test.ts (1)

262-300: Static analysis ReDoS warnings are false positives here — imageNum is always a number.

The ast-grep warnings about regex-from-variable on lines 271–278 are not exploitable because imageNum is always a parsed integer (digits only), so no malicious patterns can be injected. The warning is relevant in general but can be safely ignored in this context.

However, note that the removeMarkerRemnants helper is duplicated inline for testing rather than imported from the production code. If this logic exists (or is planned) in ChatView.tsx, consider extracting it to a shared utility and testing it directly, rather than maintaining a parallel implementation in tests.

src/tui/utils/marker-edit.ts (1)

53-58: Minor: wordBoundaryBackward doesn't account for image markers as atomic units.

The word-boundary functions treat markers character-by-character (bracket, space, letters, digits), so Ctrl+W near a marker would compute a boundary mid-marker. This is mitigated by deleteRangeSafely catching overlapping markers, but the cursor would land at a non-intuitive position during the boundary calculation itself.

src/tui/components/ChatView.tsx (2)

260-316: Cursor skip-over logic looks correct but has a potential tight loop risk.

The cursor-changed handler calls editBuffer.setCursorByOffset() when the cursor lands inside a marker, which could fire another cursor-changed event. Since the cursor is moved to the marker boundary (outside the marker), the regex won't match on the subsequent event, so it won't loop infinitely. This is safe in practice, but adding a guard (e.g., a boolean flag to skip re-entrant calls) would make it more robust.


551-557: findMarkerAtCursor is an imported module function — unnecessary in the dependency array.

findMarkerAtCursor is a stable import, not a React state/prop/callback. Including it in the useCallback dependency array is harmless but misleading.

📝 Suggested fix
   [
     inputEnabled,
     isLoading,
     handleSubmit,
     onImageMarkerDeleted,
-    findMarkerAtCursor,
   ],
src/tui/components/PrdChatApp.tsx (1)

805-825: Clever native-paste vs. keyboard-shortcut debounce — but the 80ms window is a magic number.

The mechanism to skip the clipboard fallback when a native paste event arrives within 80ms is well-designed. Consider extracting the 80ms constant and adding a brief comment explaining why this specific duration was chosen (balances responsiveness vs. giving the terminal time to emit the paste event).

📝 Suggested improvement
+      const PASTE_EVENT_GRACE_PERIOD_MS = 80;
       if (isPasteShortcut) {
         key.preventDefault?.();
         const requestTime = Date.now();
         setTimeout(() => {
           if (lastPasteEventAtRef.current >= requestTime) {
             return;
           }
           void performClipboardPasteFallback();
-        }, 80);
+        }, PASTE_EVENT_GRACE_PERIOD_MS);
         return;
       }
src/tui/hooks/useImageAttachment.ts (1)

252-261: Redundant non-null assertions after truthy guards.

At lines 258, 323, and 357–358 you use ! (e.g. storageResult.path!, pathResult.filePath!) on values that have already been verified as truthy by preceding if checks. These assertions are safe but unnecessary — removing them keeps the code cleaner and avoids masking future bugs if the guard logic changes.

♻️ Remove redundant non-null assertions
       displayName: generateDisplayName(
         'clipboard',
-        storageResult.path!,
+        storageResult.path,
         imageNumber - 1,
       ),
       displayName: generateDisplayName(
         'base64',
-        storageResult.path!,
+        storageResult.path,
         imageNumber - 1,
       ),
       displayName: generateDisplayName(
-            pathResult.filePath!,
-            storageResult.path!,
+            pathResult.filePath,
+            storageResult.path,
             imageNumber - 1,
       ),

Also applies to: 317-326, 352-361

src/tui/utils/clipboard-image.ts (2)

47-54: commandExists lacks a timeout, unlike the version in src/sandbox/detect.ts.

The commandExists in src/sandbox/detect.ts (lines 11–44) includes a COMMAND_TIMEOUT_MS guard and kills the process if it hangs. This version has no such safeguard. If which/where hangs (e.g. due to a broken NFS mount on $PATH), it will block indefinitely. In practice this is very unlikely, but it's worth noting since the pattern already exists in the codebase.


281-332: AppleScript temp-file path is interpolated directly into the script string.

Line 292 embeds tempFile verbatim into the AppleScript. If tmpdir() ever returns a path containing a double-quote ("), the script will break or — theoretically — be subject to injection. With randomUUID() and standard tmpdir() values this is extremely unlikely, but a defensive escape (replacing " with \\") would eliminate the risk entirely.

src/tui/utils/image-storage.ts (1)

508-520: deleteAllStoredImages performs sequential deletes — consider parallelism for large sets.

Each deletion is awaited sequentially. For the expected use-case (≤10 images per DEFAULT_IMAGE_CONFIG.max_images_per_message), this is fine. If the limit is ever raised significantly, a Promise.allSettled approach would be faster.

src/tui/hooks/useInlineImageIndicators.ts (1)

256-262: Redundant nullish coalescing on line 259.

getIndicatorNumber always returns a number, never undefined or null, so number ?? 1 is unnecessary.

♻️ Remove redundant fallback
     (imageId: string): string => {
       const number = getIndicatorNumber(imageId);
-      return `${INDICATOR_START}[Image ${number ?? 1}]${INDICATOR_END}`;
+      return `${INDICATOR_START}[Image ${number}]${INDICATOR_END}`;
     },

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/tui/components/PrdChatApp.tsx`:
- Around line 610-641: The slash-command branch in sendMessage currently returns
in both the handled and unhandled paths, causing any syntactically slash-like
input to be swallowed when executeSlashCommand returns handled: false; update
sendMessage so that only when result.handled is true you clear the input and
return after showing success/error toasts, and when handled is false either fall
through to send the message normally (i.e., do not return) or explicitly show an
"unknown command" toast via toast.showError; use the existing identifiers
(sendMessage, isSlashCommand, executeSlashCommand, setInputValue, clearImages,
attachedImages, toast) to locate and implement the change.
🧹 Nitpick comments (10)
src/tui/hooks/useInlineImageIndicators.ts (4)

210-221: Variable nextNumber shadows the state variable from line 182.

The local const nextNumber = number + 1 on line 213 shadows the state destructuring const [nextNumber, setNextNumber] on line 182. It works because of scoping, but it reduces clarity. Consider a distinct name like updatedNextNumber.

♻️ Suggested rename
       const number = currentNextNumber;
       const nextMap = { ...currentMap, [imageId]: number };
-      const nextNumber = number + 1;
+      const updatedNextNumber = number + 1;

       // Update refs immediately to avoid duplicate assignments on rapid calls.
       indicatorMapRef.current = nextMap;
-      nextNumberRef.current = nextNumber;
+      nextNumberRef.current = updatedNextNumber;

       // Mirror refs in state for rendering.
       setIndicatorMap(nextMap);
-      setNextNumber(nextNumber);
+      setNextNumber(updatedNextNumber);

256-262: Unnecessary nullish coalescing on line 259.

getIndicatorNumber always returns a number — it never returns undefined or null. The ?? 1 fallback is dead code and might mislead readers into thinking the function can return nullish values.

♻️ Suggested fix
-      return `${INDICATOR_START}[Image ${number ?? 1}]${INDICATOR_END}`;
+      return `${INDICATOR_START}[Image ${number}]${INDICATOR_END}`;

301-311: findImageIdByNumber reads from indicatorMap state instead of indicatorMapRef.

Other functions in this hook (e.g., getIndicatorNumber, renumberIndicators) read from refs for freshness. This function uses the state value, which could be stale within the same render cycle. Consider using indicatorMapRef.current for consistency, and removing the indicatorMap dependency.

♻️ Suggested fix
   const findImageIdByNumber = useCallback(
     (number: number): string | null => {
-      for (const [imageId, num] of Object.entries(indicatorMap)) {
+      for (const [imageId, num] of Object.entries(indicatorMapRef.current)) {
         if (num === number) {
           return imageId;
         }
       }
       return null;
     },
-    [indicatorMap],
+    [],
   );

413-438: removeOrphanedIndicators also reads from indicatorMap state — same staleness concern.

Same inconsistency as findImageIdByNumber: prefer indicatorMapRef.current for freshness. The static analysis ReDoS warning on lines 428-430 is a false positive since number is always an integer from the internal map.

♻️ Suggested fix
   const removeOrphanedIndicators = useCallback(
     (text: string, attachedImages: AttachedImage[]): string => {
       const attachedIds = new Set(attachedImages.map((img) => img.id));
       let result = text;

       const indicatorsToRemove: number[] = [];
-      for (const [imageId, number] of Object.entries(indicatorMap)) {
+      for (const [imageId, number] of Object.entries(indicatorMapRef.current)) {
         if (!attachedIds.has(imageId)) {
           indicatorsToRemove.push(number);
         }
       }

       for (const number of indicatorsToRemove) {
         const pattern = new RegExp(
           `${INDICATOR_START}\\[Image ${number}\\]${INDICATOR_END}`,
           'g',
         );
         result = result.replace(pattern, '');
       }

       return result;
     },
-    [indicatorMap],
+    [],
   );
src/tui/utils/clipboard-image.ts (2)

47-54: Duplicate commandExists implementation.

There is an existing commandExists in src/sandbox/detect.ts (lines 11–44) that includes timeout handling and a guard against double-resolution. This local version lacks both. Consider reusing the existing utility to reduce duplication and gain the timeout safety net.

#!/bin/bash
# Check if commandExists from detect.ts is exported and could be imported
rg -n 'export.*commandExists' --type=ts

281-332: AppleScript string interpolation with tempFile — low risk but worth hardening.

The tempFile path is interpolated directly into the AppleScript string on line 292. While randomUUID() produces safe characters, if tmpdir() ever returned a path containing double-quotes or backslashes, the AppleScript would break or behave unexpectedly. Consider escaping quotes in tempFile before interpolation.

🛡️ Minimal hardening
+  const safeTempFile = tempFile.replace(/"/g, '\\"');
   const script = `
     try
       set theImage to the clipboard as «class PNGf»
-      set theFile to open for access POSIX file "${tempFile}" with write permission
+      set theFile to open for access POSIX file "${safeTempFile}" with write permission
src/tui/components/PrdChatApp.tsx (4)

252-280: classifyPastePayload control-character heuristic is reasonable but the thresholds are magic numbers.

The combined check controlChars >= 4 && controlChars / Math.max(text.length, 1) > 0.05 uses two hard-coded thresholds. Consider extracting these as named constants for clarity and easier future tuning.

♻️ Extract constants
+const MIN_CONTROL_CHARS_FOR_BINARY = 4;
+const CONTROL_CHAR_RATIO_THRESHOLD = 0.05;
+
 export function classifyPastePayload(text: string): PasteClassification {
   // ...
-  if (controlChars >= 4 && controlChars / Math.max(text.length, 1) > 0.05) {
+  if (controlChars >= MIN_CONTROL_CHARS_FOR_BINARY && controlChars / Math.max(text.length, 1) > CONTROL_CHAR_RATIO_THRESHOLD) {
     return { intercept: true, suppressFallbackInsert: true };
   }

834-854: 80 ms race window between keyboard shortcut and native paste event.

The setTimeout on line 847 waits 80 ms to check whether a native PasteEvent arrived after the keyboard shortcut. If the native event is delayed beyond 80 ms (e.g., slow terminal, high system load), both the fallback and the native handler will fire, potentially inserting content twice. This is a known trade-off, but consider making the delay configurable or slightly larger (e.g., 150 ms) to reduce double-paste on slower environments.


386-408: Image config defaults are resolved inline — consider centralising.

Lines 388–393 individually fall back to DEFAULT_IMAGE_CONFIG fields. If more config fields are added later, each one needs the same pattern. A single merged config object would be DRYer:

♻️ Merge config once
-  const imagesEnabled = imageConfig?.enabled ?? DEFAULT_IMAGE_CONFIG.enabled;
-  const maxImagesPerMessage =
-    imageConfig?.max_images_per_message ??
-    DEFAULT_IMAGE_CONFIG.max_images_per_message;
-  const showPasteHints =
-    imageConfig?.show_paste_hints ?? DEFAULT_IMAGE_CONFIG.show_paste_hints;
+  const resolvedImageConfig = { ...DEFAULT_IMAGE_CONFIG, ...imageConfig };
+  const { enabled: imagesEnabled, max_images_per_message: maxImagesPerMessage, show_paste_hints: showPasteHints } = resolvedImageConfig;

711-719: sendMessage dependency array includes attachedImages — may cause extra re-creations.

attachedImages is an array reference that likely changes on every attach/detach. Since sendMessage only reads attachedImages.length (line 660), consider using a separate attachedImages.length ref or a count state to avoid re-creating sendMessage on every image change. This is minor since React will batch well in practice.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/tui/components/PrdChatApp.tsx`:
- Around line 957-968: The paste handler is calling attachImage(text) for every
non-empty paste even when classifyPastePayload indicated pasteType.intercept is
false; update the logic so attachImage(text) (and the subsequent
insertTextRef.current(textResult.inlineMarker + ' ')) only runs when
pasteType.intercept is true to avoid unnecessary work and double-inserts.
Concretely, wrap the existing hasText branch in a check for pasteType.intercept
(the value returned by classifyPastePayload) and short-circuit/return early when
intercept is false so native paste proceeds and no image-detection I/O is
triggered.
🧹 Nitpick comments (1)
src/tui/components/PrdChatApp.tsx (1)

410-448: handlePrdDetected is captured in the effect closure but is not a dependency.

handlePrdDetected (line 429) is a plain async function that is re-created every render, yet the useEffect only re-runs when its listed deps change. If a prd:detected event fires after a render where outputDir or trackerOptions changed without cwd changing, the callback uses stale values.

In practice this is safe today because outputDir is a static prop default and trackerOptions is derived from cwd. However, eslint-plugin-react-hooks would flag this, and it becomes a real bug if these ever decouple.

Consider wrapping handlePrdDetected in useCallback (with its own deps) and adding it to the effect's dependency array, or moving its body inside the effect.

@subsy subsy merged commit 87cbcf6 into subsy:main Feb 13, 2026
9 checks passed
@subsy
Copy link
Copy Markdown
Owner

subsy commented Feb 13, 2026

Hey @subsy!

Just wanted to know if I can do anything to help merge this in - Imagine image support would be quite useful for UI based tasks.

@jesse-merhi I went ahead and resolved a few issues to get it merged tonight. Thanks!

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.

3 participants