feat(images): Add image attachment support for create-prd#195
feat(images): Add image attachment support for create-prd#195subsy merged 11 commits intosubsy:mainfrom
Conversation
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)
|
@jesse-merhi is attempting to deploy a commit to the plgeek Team on Vercel. A member of the Team first needs to authorize it. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds 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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
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. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
|
Hopefully this a bit more clean. |
There was a problem hiding this comment.
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) andhandleCancel(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
startdescending for deletion (to preserve indices), but at line 91,onImageMarkerDeletedis called in the originalmarkersarray 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
sortedfor 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
userMessageis simply assigned the same value asrawMessagewithout 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
rawMessagethroughout the rest of the function instead ofuserMessage.src/tui/hooks/useImageAttachmentWithFeedback.ts (1)
149-161:getInstallHintandgetInstallHintsshare platform-checking logic, but serve different purposes and cannot be directly unified.
getInstallHints()inclipboard-image.tsreturns aRecord<string, string>with detailed, tool-specific installation instructions (including distribution-specific details like Debian/Ubuntu vs Arch), used internally by clipboard utilities.getInstallHint()inuseImageAttachmentWithFeedback.tsreturns a simple string for toast notifications, with more generic messages. SincegetInstallHintsis 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 acommandExistsinsrc/sandbox/detect.tswith a timeout; reusing it (or extracting a shared util) would avoid divergence and reduce the chance of a hang in edge cases.
|
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. |
…-clean # Conflicts: # src/commands/create-prd.tsx # src/config/index.ts # src/config/types.ts # src/tui/components/PrdChatApp.tsx
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
There was a problem hiding this comment.
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.
handleCompleteandhandleCancelcontain 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 forreadFromClipboard.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 mentionclassifyPastePayloadtests.The file now also tests
classifyPastePayload, but the header only mentionsbuildBeadsLabelsInstruction. 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 —imageNumis always a number.The ast-grep warnings about regex-from-variable on lines 271–278 are not exploitable because
imageNumis 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
removeMarkerRemnantshelper is duplicated inline for testing rather than imported from the production code. If this logic exists (or is planned) inChatView.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:wordBoundaryBackwarddoesn'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
deleteRangeSafelycatching 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-changedhandler callseditBuffer.setCursorByOffset()when the cursor lands inside a marker, which could fire anothercursor-changedevent. 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:findMarkerAtCursoris an imported module function — unnecessary in the dependency array.
findMarkerAtCursoris a stable import, not a React state/prop/callback. Including it in theuseCallbackdependency 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 precedingifchecks. 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:commandExistslacks a timeout, unlike the version insrc/sandbox/detect.ts.The
commandExistsinsrc/sandbox/detect.ts(lines 11–44) includes aCOMMAND_TIMEOUT_MSguard and kills the process if it hangs. This version has no such safeguard. Ifwhich/wherehangs (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
tempFileverbatim into the AppleScript. Iftmpdir()ever returns a path containing a double-quote ("), the script will break or — theoretically — be subject to injection. WithrandomUUID()and standardtmpdir()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:deleteAllStoredImagesperforms 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, aPromise.allSettledapproach would be faster.src/tui/hooks/useInlineImageIndicators.ts (1)
256-262: Redundant nullish coalescing on line 259.
getIndicatorNumberalways returns anumber, neverundefinedornull, sonumber ?? 1is 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}`; },
There was a problem hiding this comment.
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: VariablenextNumbershadows the state variable from line 182.The local
const nextNumber = number + 1on line 213 shadows the state destructuringconst [nextNumber, setNextNumber]on line 182. It works because of scoping, but it reduces clarity. Consider a distinct name likeupdatedNextNumber.♻️ 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.
getIndicatorNumberalways returns anumber— it never returnsundefinedornull. The?? 1fallback 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:findImageIdByNumberreads fromindicatorMapstate instead ofindicatorMapRef.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 usingindicatorMapRef.currentfor consistency, and removing theindicatorMapdependency.♻️ 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:removeOrphanedIndicatorsalso reads fromindicatorMapstate — same staleness concern.Same inconsistency as
findImageIdByNumber: preferindicatorMapRef.currentfor freshness. The static analysis ReDoS warning on lines 428-430 is a false positive sincenumberis 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: DuplicatecommandExistsimplementation.There is an existing
commandExistsinsrc/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 withtempFile— low risk but worth hardening.The
tempFilepath is interpolated directly into the AppleScript string on line 292. WhilerandomUUID()produces safe characters, iftmpdir()ever returned a path containing double-quotes or backslashes, the AppleScript would break or behave unexpectedly. Consider escaping quotes intempFilebefore 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 permissionsrc/tui/components/PrdChatApp.tsx (4)
252-280:classifyPastePayloadcontrol-character heuristic is reasonable but the thresholds are magic numbers.The combined check
controlChars >= 4 && controlChars / Math.max(text.length, 1) > 0.05uses 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
setTimeouton line 847 waits 80 ms to check whether a nativePasteEventarrived 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_CONFIGfields. 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:sendMessagedependency array includesattachedImages— may cause extra re-creations.
attachedImagesis an array reference that likely changes on every attach/detach. SincesendMessageonly readsattachedImages.length(line 660), consider using a separateattachedImages.lengthref or a count state to avoid re-creatingsendMessageon every image change. This is minor since React will batch well in practice.
There was a problem hiding this comment.
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:handlePrdDetectedis captured in the effect closure but is not a dependency.
handlePrdDetected(line 429) is a plainasync functionthat is re-created every render, yet theuseEffectonly re-runs when its listed deps change. If aprd:detectedevent fires after a render whereoutputDirortrackerOptionschanged withoutcwdchanging, the callback uses stale values.In practice this is safe today because
outputDiris a static prop default andtrackerOptionsis derived fromcwd. However,eslint-plugin-react-hookswould flag this, and it becomes a real bug if these ever decouple.Consider wrapping
handlePrdDetectedinuseCallback(with its own deps) and adding it to the effect's dependency array, or moving its body inside the effect.
@jesse-merhi I went ahead and resolved a few issues to get it merged tonight. Thanks! |
Summary
This PR adds clipboard image paste support (Ctrl+V) for the

create-prdchat interface, enabling users to attach images from their clipboard directly into the conversation with the AI agent.Features
[Image N]inline markers: Visual indicators in the input showing attached images/clear-imagesslash command: Clear all attached imagespngpaste), 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.tssrc/tui/utils/-clipboard-image.ts,exit-cleanup.ts,image-detection.ts,image-storage.ts,marker-edit.ts,slash-commands.ts,image-attachment.test.tssrc/tui/components/ImageAttachmentCount.tsxModified files
src/tui/components/ChatView.tsx- Marker detection, cursor skip-over, paste handlingsrc/tui/components/PrdChatApp.tsx- Image attachment integrationsrc/tui/components/Toast.tsx- Added ToastContainer for feedback notificationssrc/config/types.ts- AddedImageConfig,ImageCleanupPolicy,DEFAULT_IMAGE_CONFIGsrc/config/index.ts- Re-export new typessrc/commands/create-prd.tsx- Exit cleanup integrationTesting
src/tui/utils/image-attachment.test.ts(32 tests passing)pngpasteNotes
This is a clean branch created from
upstream/mainwith only functional changes - no formatting/prettier changes that could cause merge conflicts.Summary by CodeRabbit