Class: Rubino::CLI::Chat::ImageInbox

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/cli/chat/image_inbox.rb

Overview

The REPL's image-attachment inbox (attach an image from the terminal), extracted from ChatCommand (#17).

Attachments live in #pending_image_paths between the prompt read and the turn; run_turn consumes + clears them via #take! so each image is sent once into the native vision slot (image_paths → Lifecycle#execute → adapter with:).

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.resolve_oneshot(query, flag_values) ⇒ Object

Builds the [text, image_paths] pair for a one-shot turn. Pulls @image / dropped-path tokens out of the prompt (so they hit the vision slot, not the literal text) and prepends any paths given via --image. Flag paths are expanded the same way as in-line tokens; a flag path that isn't a readable image is reported and skipped rather than silently dropped.

Every candidate then passes the SAME secure-by-default attachment gate as the server/run path (Attachments::Classify + Policy, via ImageInput#attachment_error) — a policy rejection is a clean one-line error BEFORE any network call, not five provider retries (#98).



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/rubino/cli/chat/image_inbox.rb', line 24

def self.resolve_oneshot(query, flag_values)
  flag_paths = Array(flag_values).map { |p| Interaction::ImageInput.expand(p) }
  flag_paths.each do |p|
    next if LLM::ContentBuilder.image_file?(p) && File.file?(p)

    warn "rubino: ignoring --image #{p} (not a readable image file)"
  end
  valid_flags = flag_paths.select { |p| LLM::ContentBuilder.image_file?(p) && File.file?(p) }
  valid_flags.each do |p|
    reason = Interaction::ImageInput.attachment_error(p)
    raise Rubino::Error, "--image #{p}: #{reason}" if reason
  end

  result = Interaction::ImageInput.parse(query, existing: valid_flags)
  if (rejection = result.rejected.first)
    raise Rubino::Error, "#{rejection[:path]}: #{rejection[:reason]}"
  end

  [result.text, result.image_paths]
end

Instance Method Details

#extract_images!(input, ui) ⇒ Object

Parses the line for image references (@image, dropped/quoted/escaped path), moves any into @pending_image_paths and returns the cleaned text. Non-image references are left in the text (current behaviour). Shows an in-prompt indicator for whatever is now attached. A candidate the attachment policy rejects (oversize / spoofed extension / unsafe) is dropped with a one-line warning instead of being shipped (#98).



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/rubino/cli/chat/image_inbox.rb', line 85

def extract_images!(input, ui)
  result = Interaction::ImageInput.parse(input, existing: pending_image_paths)
  result.rejected.each do |rejection|
    ui.warning("not attached — #{File.basename(rejection[:path])}: #{rejection[:reason]}")
  end
  newly = result.image_paths - pending_image_paths
  @pending_image_paths = result.image_paths
  # A line with text AND an @image sends BOTH on THIS turn (the cleaned
  # text is non-empty, so the main loop submits now); an image-only line
  # stages for the next message. The indicator must match that
  # disposition — saying "sent with your next message" on a text+image
  # line is wrong (#225).
  unless newly.empty?
    attached_now = !result.text.strip.empty?
    show_image_indicator(ui, newly, attached_now: attached_now)
  end
  result.text
end

#handle_image_command(input, ui) ⇒ Object

Handles the REPL-local image commands. Returns true when it consumed the input (so the main loop should next), false otherwise.

/paste         — grab an image from the clipboard into image_paths
/clear-images  — drop all pending attachments

rubocop:disable Naming/PredicateMethod -- "did I consume the line", not a pure predicate



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/rubino/cli/chat/image_inbox.rb', line 110

def handle_image_command(input, ui)
  case input.strip.downcase
  when "/clear-images", "/clear-image"
    if pending_image_paths.empty?
      ui.info("No attached images to clear.")
    else
      ui.info("Cleared #{pending_image_paths.size} attached image(s).")
      @pending_image_paths = []
    end
    true
  when "/paste"
    paste_clipboard_image(ui)
    true
  else
    false
  end
end

#pending_image_pathsObject



45
46
47
# File 'lib/rubino/cli/chat/image_inbox.rb', line 45

def pending_image_paths
  @pending_image_paths ||= []
end

#stage_flag_images(flag_values, ui) ⇒ Object

Seeds the interactive pending-images inbox from --image/-i flag paths (#160), through the SAME attachment gate every other staging surface uses (Attachments::Classify + Policy via ImageInput#attachment_error). A bad flag path warns and is skipped — interactive startup must not die on it the way one-shot raises. Staged images show the usual indicator and are covered by /clear-images, as documented.



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/rubino/cli/chat/image_inbox.rb', line 63

def stage_flag_images(flag_values, ui)
  Array(flag_values).each do |raw|
    path = Interaction::ImageInput.expand(raw)
    unless LLM::ContentBuilder.image_file?(path) && File.file?(path)
      ui.warning("not attached — #{raw}: not a readable image file")
      next
    end
    if (reason = Interaction::ImageInput.attachment_error(path))
      ui.warning("not attached — #{File.basename(path)}: #{reason}")
      next
    end
    pending_image_paths << path unless pending_image_paths.include?(path)
  end
  show_image_indicator(ui, pending_image_paths) unless pending_image_paths.empty?
end

#take!Object

Consumes the turn's queued image attachments (the native vision slot) and resets so they're attached exactly once, not re-sent next turn.



51
52
53
54
55
# File 'lib/rubino/cli/chat/image_inbox.rb', line 51

def take!
  paths = pending_image_paths
  @pending_image_paths = []
  paths
end