Class: Rubino::UI::Null

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/ui/null.rb

Overview

Null UI adapter that discards all output. Used in testing and background job execution where no terminal output is needed.

Direct Known Subclasses

HeadlessTrace

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#blocking_human_input?, #hint_row, #panel_line, #turn_interrupted

Constructor Details

#initializeNull

Returns a new instance of Null.



11
12
13
# File 'lib/rubino/ui/null.rb', line 11

def initialize
  @messages = []
end

Instance Attribute Details

#messagesObject (readonly)

Returns the value of attribute messages.



9
10
11
# File 'lib/rubino/ui/null.rb', line 9

def messages
  @messages
end

Instance Method Details

#approval_blocked?Boolean

Returns:

  • (Boolean)


147
148
149
# File 'lib/rubino/ui/null.rb', line 147

def approval_blocked?
  @approval_blocked == true
end

#ask(_prompt) ⇒ Object



95
96
97
# File 'lib/rubino/ui/null.rb', line 95

def ask(_prompt)
  nil
end

#assistant_text(text) ⇒ Object



47
48
49
# File 'lib/rubino/ui/null.rb', line 47

def assistant_text(text)
  @messages << { level: :assistant_text, message: text }
end

#blank_lineObject



204
205
206
# File 'lib/rubino/ui/null.rb', line 204

def blank_line
  @messages << { level: :blank_line, message: "" }
end

#blocked_messagesObject

The single-line block notices captured during a headless run, in order, so the one-shot CLI can echo them to stderr before exiting non-zero (#260) — UI::Null otherwise swallows every #warning into @messages.



154
155
156
# File 'lib/rubino/ui/null.rb', line 154

def blocked_messages
  @messages.select { |m| m[:level] == :tool_blocked }.map { |m| m[:message] }
end

#body(text) ⇒ Object



43
44
45
# File 'lib/rubino/ui/null.rb', line 43

def body(text)
  @messages << { level: :body, message: text }
end

#box_close(*pieces, color: nil) ⇒ Object



39
40
41
# File 'lib/rubino/ui/null.rb', line 39

def box_close(*pieces, color: nil)
  @messages << { level: :box_close, pieces: pieces, color: color }
end

#box_open(*pieces, at: nil, color: nil) ⇒ Object



35
36
37
# File 'lib/rubino/ui/null.rb', line 35

def box_open(*pieces, at: nil, color: nil)
  @messages << { level: :box_open, pieces: pieces, at: at, color: color }
end

#branch_confirmation(new_id:, parent_id:, title:, included_probe:) ⇒ Object



59
60
61
62
63
64
65
# File 'lib/rubino/ui/null.rb', line 59

def branch_confirmation(new_id:, parent_id:, title:, included_probe:)
  @messages << {
    level: :branch_confirmation,
    message: { new_id: new_id, parent_id: parent_id, title: title,
               included_probe: included_probe }
  }
end

#compression_finished(metadata, at: nil) ⇒ Object



184
185
186
# File 'lib/rubino/ui/null.rb', line 184

def compression_finished(, at: nil)
  @messages << { level: :compression_finished, message: , at: at }
end

#compression_started(at: nil) ⇒ Object



180
181
182
# File 'lib/rubino/ui/null.rb', line 180

def compression_started(at: nil)
  @messages << { level: :compression_started, message: "", at: at }
end

#confirm(_question, scope: nil, **_context) ⇒ Object

Headless: there is no human to ask, so FAIL CLOSED (#260). The Null adapter drives the one-shot / scripted ‘rubino prompt` / `-q` path; it used to return true here, silently auto-approving every write and every non-allowlisted shell command — a prompt-injection→RCE foot-gun (the Gemini-CLI / Dec-2025 auto-approve-writes pattern). ToolExecutor now checks #interactive? BEFORE ever reaching #confirm, so this is the belt-and-suspenders floor: declining is the only safe default off a TTY. `scope:` is part of the shared UI contract (ToolExecutor always passes it); the Null adapter ignores it.



121
122
123
# File 'lib/rubino/ui/null.rb', line 121

def confirm(_question, scope: nil, **_context)
  false
end

#confirm_destructive(_question) ⇒ Object

Destructive confirm (#218): no human to ask, so fail closed (decline) — never destroy on the non-interactive Null adapter.



160
161
162
# File 'lib/rubino/ui/null.rb', line 160

def confirm_destructive(_question)
  false
end

#error(message) ⇒ Object



27
28
29
# File 'lib/rubino/ui/null.rb', line 27

def error(message)
  @messages << { level: :error, message: message }
end

#info(message) ⇒ Object



15
16
17
# File 'lib/rubino/ui/null.rb', line 15

def info(message)
  @messages << { level: :info, message: message }
end

#input_injected(text) ⇒ Object



232
233
234
# File 'lib/rubino/ui/null.rb', line 232

def input_injected(text)
  @messages << { level: :input_injected, message: text }
end

#interactive?Boolean

No interactive session — no terminal, no approval gate. Tells ToolExecutor to fail closed on any tool that needs approval (#260).

Returns:

  • (Boolean)


127
128
129
# File 'lib/rubino/ui/null.rb', line 127

def interactive?
  false
end

#job_enqueued(type) ⇒ Object



188
189
190
# File 'lib/rubino/ui/null.rb', line 188

def job_enqueued(type)
  @messages << { level: :job_enqueued, message: type }
end

#job_finished(type) ⇒ Object



196
197
198
# File 'lib/rubino/ui/null.rb', line 196

def job_finished(type)
  @messages << { level: :job_finished, message: type }
end

#job_started(type) ⇒ Object



192
193
194
# File 'lib/rubino/ui/null.rb', line 192

def job_started(type)
  @messages << { level: :job_started, message: type }
end

#mode_changed(name, previous: nil) ⇒ Object



208
209
210
# File 'lib/rubino/ui/null.rb', line 208

def mode_changed(name, previous: nil)
  @messages << { level: :mode_changed, message: name, previous: previous }
end

#note(text) ⇒ Object



51
52
53
# File 'lib/rubino/ui/null.rb', line 51

def note(text)
  @messages << { level: :note, message: text }
end

#probe_aside(answer) ⇒ Object



55
56
57
# File 'lib/rubino/ui/null.rb', line 55

def probe_aside(answer)
  @messages << { level: :probe_aside, message: answer.to_s }
end

#queued(text) ⇒ Object



228
229
230
# File 'lib/rubino/ui/null.rb', line 228

def queued(text)
  @messages << { level: :queued, message: text }
end

#reasoning_changed(mode, previous: nil) ⇒ Object



216
217
218
# File 'lib/rubino/ui/null.rb', line 216

def reasoning_changed(mode, previous: nil)
  @messages << { level: :reasoning_changed, message: mode, previous: previous }
end

#reasoning_status(mode) ⇒ Object



212
213
214
# File 'lib/rubino/ui/null.rb', line 212

def reasoning_status(mode)
  @messages << { level: :reasoning_status, message: mode }
end

#replay_user_input(text, at: nil) ⇒ Object



79
80
81
# File 'lib/rubino/ui/null.rb', line 79

def replay_user_input(text, at: nil)
  @messages << { level: :replay_user_input, message: text, at: at }
end

#reset!Object

Resets captured messages (useful between test cases)



237
238
239
# File 'lib/rubino/ui/null.rb', line 237

def reset!
  @messages = []
end

#select(_prompt, _choices) ⇒ Object

No interactive selection off a real terminal; callers fall back to a non-interactive path (e.g. the static /sessions table + shortcut).



101
102
103
# File 'lib/rubino/ui/null.rb', line 101

def select(_prompt, _choices)
  nil
end

#separatorObject



200
201
202
# File 'lib/rubino/ui/null.rb', line 200

def separator
  @messages << { level: :separator, message: "" }
end

#status(message) ⇒ Object



31
32
33
# File 'lib/rubino/ui/null.rb', line 31

def status(message)
  @messages << { level: :status, message: message }
end

#stream(chunk) ⇒ Object



67
68
69
70
71
72
73
# File 'lib/rubino/ui/null.rb', line 67

def stream(chunk)
  # Every adapter yields the common chunk contract:
  #   { type: :content | :thinking, text: String, message_id: Integer }
  text = chunk[:text].to_s
  type = chunk[:type] || :content
  @messages << { level: :stream, message: text, stream_type: type }
end

#stream_endObject



75
76
77
# File 'lib/rubino/ui/null.rb', line 75

def stream_end
  @messages << { level: :stream_end, message: "" }
end

#subagent_approval_choiceObject

The unified arrow-key subagent approval (TUI-6) has no terminal to draw on headless: return nil (“no decision”), which the /agents handler reads as “re-prompt / leave parked” — never an auto-approve or auto-deny.



108
109
110
# File 'lib/rubino/ui/null.rb', line 108

def subagent_approval_choice
  nil
end

#success(message) ⇒ Object



19
20
21
# File 'lib/rubino/ui/null.rb', line 19

def success(message)
  @messages << { level: :success, message: message }
end

#table(headers:, rows:) ⇒ Object



91
92
93
# File 'lib/rubino/ui/null.rb', line 91

def table(headers:, rows:)
  @messages << { level: :table, message: { headers: headers, rows: rows } }
end

#think_changed(effort, previous: nil) ⇒ Object



224
225
226
# File 'lib/rubino/ui/null.rb', line 224

def think_changed(effort, previous: nil)
  @messages << { level: :think_changed, message: effort, previous: previous }
end

#think_status(effort) ⇒ Object



220
221
222
# File 'lib/rubino/ui/null.rb', line 220

def think_status(effort)
  @messages << { level: :think_status, message: effort }
end

#thinking_finishedObject



87
88
89
# File 'lib/rubino/ui/null.rb', line 87

def thinking_finished
  @messages << { level: :thinking_finished, message: "" }
end

#thinking_startedObject



83
84
85
# File 'lib/rubino/ui/null.rb', line 83

def thinking_started
  @messages << { level: :thinking_started, message: "" }
end

#tool_blocked(message) ⇒ Object

Latched by ToolExecutor when a tool is blocked for approval in this headless run (#260). The one-shot CLI reads #approval_blocked? after the run to exit NON-ZERO so CI/automation fails loudly.



134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/rubino/ui/null.rb', line 134

def tool_blocked(message)
  @approval_blocked = true
  @messages << { level: :tool_blocked, message: message }
  # F1-subagents: a `task` subagent runs on its OWN fresh Null adapter, so a
  # block latched here is invisible to the PARENT Null the one-shot CLI
  # inspects for the exit code. While a headless run is active, also record
  # the block in the process-global latch the one-shot exit check consults,
  # so a subagent-blocked headless run exits non-zero with the notice on
  # stderr instead of false-success. Off the headless path (interactive
  # subagents, API) this is a no-op — those surface the block their own way.
  Rubino::Output::HeadlessBlockLatch.record(message) if Rubino.headless?
end

#tool_body(text, kind: :plain) ⇒ Object



172
173
174
# File 'lib/rubino/ui/null.rb', line 172

def tool_body(text, kind: :plain)
  @messages << { level: :tool_body, message: text, kind: kind }
end

#tool_chunk(name, chunk, kind: :plain) ⇒ Object



176
177
178
# File 'lib/rubino/ui/null.rb', line 176

def tool_chunk(name, chunk, kind: :plain)
  @messages << { level: :tool_chunk, name: name, chunk: chunk, kind: kind }
end

#tool_finished(name, result: nil) ⇒ Object



168
169
170
# File 'lib/rubino/ui/null.rb', line 168

def tool_finished(name, result: nil)
  @messages << { level: :tool_finished, message: name }
end

#tool_started(name, arguments: nil, at: nil) ⇒ Object



164
165
166
# File 'lib/rubino/ui/null.rb', line 164

def tool_started(name, arguments: nil, at: nil)
  @messages << { level: :tool_started, message: name, arguments: arguments, at: at }
end

#warning(message) ⇒ Object



23
24
25
# File 'lib/rubino/ui/null.rb', line 23

def warning(message)
  @messages << { level: :warning, message: message }
end