Skip to content

tui: image-input UX — drag-drop attach, inline numbered chips, vision fallback, cursor + copy fixes#247

Merged
gnanam1990 merged 3 commits into
mainfrom
feat/image-input-ux
Jun 18, 2026
Merged

tui: image-input UX — drag-drop attach, inline numbered chips, vision fallback, cursor + copy fixes#247
gnanam1990 merged 3 commits into
mainfrom
feat/image-input-ux

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Improves the image/PDF attachment flow and two long-standing composer papercuts. Independent of the sub-agent delegation PR; touches only internal/tui + internal/modelregistry.

What's new

  • Drag-and-drop attach — dropping an image/PDF onto the composer attaches it instead of erroring as an unknown /… command. The dropped path is detected on paste and on submit (absolute / backslash-escaped / quoted paths that resolve to an existing image or PDF).
  • Inline numbered chips — staged attachments render as compact [Image #1] / [Doc #1] chips inside the input box; the verbose Attached … (image/png) system line is gone. Backspace on an empty composer removes the last chip (/image clear still drops all).
  • Vision fallback — a real multimodal model that's absent from the curated catalog (custom / openai-compatible / local ids) now accepts images via a conservative name match. Text-only ids stay refused, and a catalog entry remains authoritative (the heuristic never overrides a known "no").
  • Blinking composer cursor — including when the box is empty (previously no caret was drawn for a focused, empty box).
  • Ctrl+E mouse release — toggles terminal mouse capture off so native drag-select + copy works, then back on for clickable UI. (Holding ⌥Option while dragging also works without toggling.)

Tests

  • dropped_attachment_test.go — drop-path detection (escaped/quoted/plain positives; text, non-existent, non-image, multi-line negatives).
  • vision_name_test.go — vision-by-name families + unknown-model fallback + text-only stays refused.
  • mouse_release_test.go — capture forced off when released.
  • attachment_remove_test.go — last-chip removal ordering (doc before image, labels in lockstep).
  • Updated image_attach_test.go chip-render assertions for the numbered format.

gofmt · go vet · go build ./... · go test ./internal/tui/... ./internal/modelregistry/... -race all green.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added stricter drag-and-drop/paste handling for image and PDF paths, with compact numbered attachment chips ([Image #N], [Doc #N])
    • Introduced a blinking composer cursor and a Ctrl+E shortcut to toggle mouse interaction
  • Bug Fixes
    • Paste of image/PDF paths now routes correctly (no raw path insertion) and attachment removal stays consistent
    • Vision capability detection is now more reliable, using catalog/registry truth when available
    • Image/PDF attachments no longer show an extra inline “Attached …” notice
  • Tests
    • Added/updated coverage for vision detection fallback, attachment removal/chip rendering, dropped-path recognition, and mouse-release capture behavior

… fallback, cursor + copy fixes

Improves the image/PDF attachment flow and two long-standing composer papercuts.

- Drag-and-drop: dropping an image/PDF onto the composer attaches it instead of
  erroring as an unknown "/…" command. The dropped path is detected on paste and
  on submit (absolute/escaped/quoted paths to an existing image or PDF).
- Inline numbered chips: staged attachments render as compact [Image #1] /
  [Doc #1] chips INSIDE the input box; the verbose "Attached … (image/png)"
  system line is dropped. Backspace on an empty composer removes the last chip
  (/image clear still drops all).
- Vision fallback: a real multimodal model absent from the curated catalog
  (custom / openai-compatible / local ids) now accepts images via a conservative
  name match; text-only ids stay refused, and the catalog stays authoritative.
- Blinking composer cursor, including when the box is empty (previously no caret
  was drawn for a focused, empty box).
- Ctrl+E releases/recaptures the mouse so native drag-select + copy works (mouse
  capture otherwise intercepts terminal text selection).

Tests: dropped-path detection, vision-by-name + unknown-model fallback,
mouse-release capture toggle, attachment removal, and updated chip-render tests.
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 0c69d784eaf2
Changed files (10): internal/modelregistry/vision.go, internal/modelregistry/vision_name_test.go, internal/tui/attachment_remove_test.go, internal/tui/clipboard.go, internal/tui/dropped_attachment_test.go, internal/tui/image_attach.go, internal/tui/image_attach_test.go, internal/tui/model.go, internal/tui/mouse.go, internal/tui/mouse_release_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: f34a078a-16bb-4c0c-a4b7-e77073175e50

📥 Commits

Reviewing files that changed from the base of the PR and between f79c5e6 and 0c69d78.

📒 Files selected for processing (2)
  • internal/tui/dropped_attachment_test.go
  • internal/tui/image_attach.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/tui/dropped_attachment_test.go
  • internal/tui/image_attach.go

Walkthrough

Adds a two-tier SupportsVision check to the model registry (authoritative registry → name-pattern fallback). In the TUI, introduces drag-drop/paste file path recognition routed to the image handler, changes attachment chip rendering to numbered [Image #N]/[Doc #N] formats with chips moved inside the composer box, adds Backspace-to-remove-chip, a blinking composer cursor, and a Ctrl+E mouse-release toggle.

Changes

Model Registry Vision Capability

Layer / File(s) Summary
SupportsVision two-tier check and visionCapableByName heuristic
internal/modelregistry/vision.go, internal/modelregistry/vision_name_test.go
SupportsVision now checks the authoritative registry first, forces false for registry-known non-vision models, and falls back to visionCapableByName for unknown IDs. Tests cover the name allowlist/disallowlist and registry fallback scenarios.

TUI Attachment and Composer UX

Layer / File(s) Summary
Drag-drop path recognizer, paste routing, and submit fast-path
internal/tui/image_attach.go, internal/tui/clipboard.go, internal/tui/model.go, internal/tui/dropped_attachment_test.go
droppedAttachmentPath normalizes terminal-dropped paths (quoted, backslash-escaped, relative) and validates them as image/PDF files. routePaste and handleSubmit both route recognized paths to handleImageCommand instead of text insertion or command parsing.
Attachment staging without system notices
internal/tui/image_attach.go
Removes inline "Attached …" system notice emissions from both image and document/PDF attachment completion; staging proceeds silently, relying on composer chips for user feedback.
Attachment removal, numbered chip rendering, and Backspace integration
internal/tui/image_attach.go, internal/tui/model.go, internal/tui/attachment_remove_test.go, internal/tui/image_attach_test.go
Adds removeLastAttachment with document-first reverse ordering. renderImageChips/renderAttachmentChips emit numbered [Image #N]/[Doc #N] chips now rendered inside the composer box. Backspace on an empty composer removes the last chip. Tests updated throughout.
Blinking composer cursor
internal/tui/model.go
Adds composerCursorVisible state driven by a composerBlinkCmd tick loop initialized in Init. The blink flag threads through renderComposerInputrenderComposerVisualLine to suppress caret styling on the "off" tick.
Ctrl+E mouse-release toggle and wantsMouseCapture guard
internal/tui/model.go, internal/tui/mouse.go, internal/tui/mouse_release_test.go
mouseReleased state is toggled by Ctrl+E with system notices. wantsMouseCapture returns false immediately when mouseReleased is set, enabling native terminal text selection/copy.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@Vasanthdev2004 Vasanthdev2004 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.

APPROVE

Walked the 10 files (416 +, 57 -) at 6f87cc3 against the diff base 2d62387 (#244 merge). The five features are clean, the new code is well-tested, and the failures I can see are limited to one Windows path-unescape edge case and a couple of conservative heuristic choices. Three follow-ups for a later PR; no blockers.

What works

1. Drag-and-drop attach is conservative and tested. droppedAttachmentPath (internal/tui/image_attach.go:23-52) only matches a single-line, single-existing, image/PDF-extension file. Multi-line content, non-existent paths, non-image files, real slash-commands, and empty input all return ok=false, so text pastes and /help-style inputs are untouched. TestDroppedAttachmentPath exercises every branch: backslash-escaped / single-quoted / double-quoted / plain path positives; slash-command / non-existent / plain text / non-image / multi-line / empty negatives. The paste-path integration in clipboard.go:routePaste is a clean 4-line addition with the right comment about why the check is needed (so a /Users/… dropped path doesn't get submitted as an unknown slash-command). Solid.

2. Numbered inline chips replace the verbose "Attached X" system line. The chip rendering moved from a separate row in footerView to inside composerBox (model.go:2142-2148), above the input line. The numbered format ([Image #1], [Doc #1]) hides the long filename while still letting a user count attachments. The two appendImageNotice calls in handleImageCommand and handleDocumentAttach are removed, so the system log is no longer polluted with per-attach confirmations — a real UX win. removeLastAttachment (image_attach.go:138-151) drops a doc before an image (matching the render order) and keeps pendingImages and pendingImageLabels in lockstep; TestRemoveLastAttachment pins both invariants and the empty-set case.

3. Vision fallback is fail-closed at the right places. SupportsVision now: catalog-known vision → true; catalog-known non-vision → false (authoritative, never overridden by the heuristic); catalog-unknown → visionCapableByName. The heuristic is conservative and well-commented: Gemini, Claude 3+/4+/opus/sonnet/haiku, GPT-4o/4.1/4-turbo/4-vision/5, o1/o3/o4, Grok 4 (and *-vision variants), and the common open multimodal families (llava, pixtral, internvl, minicpm-v, moondream, bakllava, *-vl, *vl-*, *vision*). The comment on the SupportsVision function and the function comment on visionCapableByName both call out the conservative intent. TestVisionCapableByName covers both directions, and TestSupportsVisionFallbackForUnknownModels covers the catalog + name-fallback composition. The fact that the test list explicitly includes gpt-oss:120b, kimi-for-coding, deepseek-coder, qwen2.5-coder, codestral, mistral-large as text-only is the right discipline.

4. Composer cursor now blinks, including for an empty box. composerBlinkMsg / composerBlinkCmd (model.go:108-117) drive a custom 530ms tick that toggles m.composerCursorVisible. The cursor is rendered via composerCursor(...) only when the flag is on, so an empty focused box now shows a blinking caret, the previous bug. textinput.Blink is still in Init() because the onboarding API-key input is a real textinput.Model (onboarding.go:56,107) that needs it — both blinks coexist cleanly. renderComposerVisualLine and renderComposerInput thread cursorVisible through, and the empty-box branch in renderComposerInput adds a single space when the caret is blinked off so the placeholder column stays stable. No new lint findings.

5. Ctrl+E releases mouse capture for native text selection. Toggling m.mouseReleased flips wantsMouseCapture() to false in mouse.go:wantsMouseCapture, which means the app stops requesting the terminal's mouse-capture mode, so the user can drag-select and copy text natively. The system notice is honest about what got toggled and how to toggle it back. The ⌥Option drag escape is documented in the PR body and a one-liner would be nice in the source too. TestMouseReleaseDisablesCapture is the right minimal test.

Non-blocking follow-ups

  1. Unconditional backslash unescape breaks Windows paths with spaces. unescapeDroppedPath (image_attach.go:64-78) drops a backslash before every following byte, which is correct for the macOS Terminal escape (Screenshot\ 2026.pngScreenshot 2026.png) but mangles a Windows path that already contains backslashes as path separators — C:\Users\test dir\file.png becomes C:Users estfile.png (the leading C:\ becomes C:, the \ becomes just ). The stat check downstream then fails and droppedAttachmentPath returns false, so the path is treated as a text paste — a UX bug, not a security hole, but real for Windows users dropping a file whose path contains a space. Two reasonable fixes: (a) only unescape when the unescaped form actually exists on disk; (b) keep an if runtime.GOOS == "windows" short-circuit that skips the unescape. A regression test in the existing TestDroppedAttachmentPath that creates a path containing a space on Windows (or skips via t.Skip on Unix) would lock the behavior in.

  2. The vision heuristic has implicit forward assumptions on grok-4 and claude-3 / claude-4. A hypothetical grok-4-text or claude-3-text would match the first heuristic clause (strings.Contains(id, "grok-4") / strings.Contains(id, "claude-3")) and be incorrectly marked as vision-capable. The catalog is authoritative, so the actual blast radius is "models the catalog doesn't know about yet" — which is the heuristic's whole purpose. Worth a one-line comment in visionCapableByName calling out that the family-substring matches assume "all members of family X are multimodal" and that a future text-only sibling would need either a tighter match (e.g. grok-4-vision) or a catalog entry to override. Not a blocker; the current test list is the strongest signal of intent.

  3. droppedAttachmentPath doesn't try the original (unescaped) form on stat failure. Closely related to #1 — when the unescape mangles a Windows path with spaces, the function gives up rather than trying the original input. Even without a runtime GOOS check, the function could: (a) stat the unescaped form, (b) if that fails AND the original was different, stat the original. Catches the Windows-path case without needing platform-specific code.

The shape is right. Happy to see this land once #1 has at least a regression test (the fix can be a follow-up).

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Verdict: APPROVE.

Walked the 10 files (416 +, 57 -) at 6f87cc3 against the diff base 2d62387 (#244 merge). The five features are clean, the new code is well-tested, and the failures I can see are limited to one Windows path-unescape edge case and a couple of conservative heuristic choices. Three follow-ups for a later PR; no blockers.

What works

  • Drag-and-drop attach is conservative and tested. droppedAttachmentPath only matches a single-line, single-existing, image/PDF-extension file. Multi-line content, non-existent paths, non-image files, real slash-commands, and empty input all return ok=false, so text pastes and /help-style inputs are untouched. TestDroppedAttachmentPath exercises every branch. The paste-path integration in clipboard.go:routePaste is a clean 4-line addition with the right comment.
  • Numbered inline chips replace the verbose "Attached X" system line. The chip rendering moved from a separate row in footerView to inside composerBox (model.go:2142-2148), above the input line. The numbered format ([Image #1], [Doc #1]) hides the long filename while still letting a user count attachments. The two appendImageNotice calls are removed, so the system log is no longer polluted with per-attach confirmations. removeLastAttachment drops a doc before an image and keeps pendingImages and pendingImageLabels in lockstep; TestRemoveLastAttachment pins both invariants.
  • Vision fallback is fail-closed at the right places. SupportsVision now: catalog-known vision → true; catalog-known non-vision → false (authoritative, never overridden by the heuristic); catalog-unknown → visionCapableByName. The heuristic is conservative and well-commented: Gemini, Claude 3+/4+/opus/sonnet/haiku, GPT-4o/4.1/4-turbo/4-vision/5, o1/o3/o4, Grok 4 (and *-vision variants), and the common open multimodal families. The test list explicitly includes gpt-oss:120b, kimi-for-coding, deepseek-coder, qwen2.5-coder, codestral, mistral-large as text-only — the right discipline.
  • Composer cursor now blinks, including for an empty box. composerBlinkMsg / composerBlinkCmd (model.go:108-117) drive a custom 530ms tick that toggles m.composerCursorVisible. textinput.Blink is still in Init() because the onboarding API-key input is a real textinput.Model (onboarding.go:56,107) that needs it — both blinks coexist cleanly. renderComposerVisualLine and renderComposerInput thread cursorVisible through, and the empty-box branch adds a single space when the caret is blinked off so the placeholder column stays stable.
  • Ctrl+E releases mouse capture for native text selection. Toggling m.mouseReleased flips wantsMouseCapture() to false in mouse.go:wantsMouseCapture, so the user can drag-select and copy text natively. The system notice is honest about what got toggled and how to toggle it back.

Non-blocking follow-ups

  1. Unconditional backslash unescape breaks Windows paths with spaces. unescapeDroppedPath (image_attach.go:64-78) drops a backslash before every following byte, which is correct for macOS Terminal escape but mangles a Windows path that already contains backslashes as path separators — C:\Users\test dir\file.png becomes C:Users estfile.png. The stat check downstream fails and the function returns false, so the path is treated as a text paste — a UX bug, not a security hole, but real for Windows users dropping a file whose path contains a space. Two reasonable fixes: (a) only unescape when the unescaped form actually exists on disk; (b) keep an if runtime.GOOS == "windows" short-circuit that skips the unescape. A regression test that creates a path containing a space on Windows (or t.Skips on Unix) would lock the behavior in.

  2. The vision heuristic has implicit forward assumptions on grok-4 and claude-3 / claude-4. A hypothetical grok-4-text or claude-3-text would match the first heuristic clause and be incorrectly marked as vision-capable. Worth a one-line comment in visionCapableByName calling out the assumption that "all members of family X are multimodal" and that a future text-only sibling would need either a tighter match or a catalog entry to override.

  3. droppedAttachmentPath doesn't try the original (unescaped) form on stat failure. Closely related to Add contributing guidelines #1 — when the unescape mangles a Windows path with spaces, the function gives up rather than trying the original input. Even without a runtime GOOS check, the function could: stat the unescaped form, and if that fails AND the original was different, stat the original. Catches the Windows-path case without platform-specific code.

The shape is right. Happy to see this land once #1 has at least a regression test (the fix can be a follow-up).

…n fallback

visionCapableByName treated any id containing "vision" as multimodal, so a model
named "...vision-less..." (i.e. WITHOUT vision) was wrongly classified vision-
capable — which silently skipped the non-vision image gate and broke
TestRunExecDropsImagesOnNonVisionModelWithWarning in internal/cli.

Add mentionsVision, which still matches the "vision" word but excludes the
negated forms (vision-less / visionless / no-vision / non-vision). Used for both
the grok-vision and the general open-multimodal fallback. Added negation cases to
the name table.
… unescape)

unescapeDroppedPath stripped backslashes to undo Unix terminal drag-drop escaping
("\ " -> " "), but on Windows the backslash is the path separator, so a plain
dropped path like C:\Users\x\file.png became C:Usersxfile.png and never resolved
-- TestDroppedAttachmentPath failed on windows-latest.

No-op the unescape on Windows (dropped paths there arrive quoted or plain), and
gate the Unix-only backslash-escaped positive case behind runtime.GOOS.

@anandh8x anandh8x 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.

No blocking findings from diff review.

Reviewed the drag-drop attachment flow, inline image chips, vision fallback, cursor/backspace behavior, and copy handling. git diff --check passed for this PR. I did not run the full branch test suite.

@gnanam1990
gnanam1990 merged commit 4582727 into main Jun 18, 2026
7 checks passed

@Vasanthdev2004 Vasanthdev2004 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.

APPROVE (re-review)

Two new commits land on top of the original 6f87cc3 review: f79c5e6 (vision-less name fix) and 0c69d78 (Windows path unescape fix). Both address the concrete bugs in my prior follow-ups; no new regressions spotted.

f79c5e6mentionsVision excludes negated "vision" forms

internal/modelregistry/vision.go:71-83 introduces a small helper that requires the word "vision" but rejects six negated variants (vision-less, visionless, no-vision, non-vision, novision, nonvision). The two call sites in visionCapableByName (the grok clause and the open-multimodal family clause) now route through it. The new test cases in TestVisionCapableByName (my-custom-vision-less-model, no-vision-model, grok-vision-less) confirm the negated forms are now text-only.

This was a real bug — a model explicitly named for LACKING vision was being treated as multimodal, which silently skipped the non-vision image gate and broke the TestRunExecDropsImagesOnNonVisionModelWithWarning test. Fixed cleanly.

0c69d78 — Windows unescape is a no-op

internal/tui/image_attach.go:57-63 short-circuits unescapeDroppedPath on runtime.GOOS == "windows". The test in internal/tui/dropped_attachment_test.go:24-32 now conditionally excludes the "backslash-escaped drop" positive case on Windows, with a comment explaining why. Backslash remains a path separator on Windows, so the unescape was correctly diagnosed as a Unix-only convention.

Verified TestVisionCapableByName and the modelregistry suite (44 tests) pass against origin/feat/image-input-ux. The TUI build couldn't be exercised in this session because the branch is stacked on the not-yet-merged #246, but the change is local to two files and the test surface is complete.

Out of scope, still

My prior follow-up #3 (droppedAttachmentPath doesn't try the original form on stat failure) is a P3 and the runtime.GOOS gate makes the Windows case reachable. Not gating; can land as a follow-up.

Happy to see this go in.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Re-review posted (id 4525713490, APPROVE on 0c69d78).

Both follow-ups from the original review are now addressed:

  • f79c5e6mentionsVision helper in internal/modelregistry/vision.go:71-83 excludes the six negated forms (vision-less, visionless, no-vision, non-vision, novision, nonvision) so a model explicitly named for LACKING vision is no longer treated as multimodal. Verified by the new TestVisionCapableByName cases.
  • 0c69d78unescapeDroppedPath in internal/tui/image_attach.go:57-63 is now a no-op on Windows, and the test gates the backslash-escaped positive case behind runtime.GOOS != "windows".

Verified TestVisionCapableByName + 43 sibling modelregistry tests pass against origin/feat/image-input-ux. The TUI build couldn't be exercised locally (the branch is stacked on the not-yet-merged #246 permission-prompt rework), but the change is local to two files and the test surface is complete.

The third follow-up (try the original on stat failure) is a P3 and the runtime.GOOS gate makes the Windows case reachable; happy to see this land without it.

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