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