Class: Rubino::CLI::SessionCommand

Inherits:
Thor
  • Object
show all
Defined in:
lib/rubino/cli/session_command.rb

Overview

Subcommands for managing chat sessions

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.collapse_home(path) ⇒ Object

Home-collapsed display path for a session’s launch dir. Returns a dash for sessions created before the cwd column existed (NULL cwd) so the column stays aligned.



248
249
250
251
252
253
254
255
256
# File 'lib/rubino/cli/session_command.rb', line 248

def self.collapse_home(path)
  return "" if path.nil? || path.to_s.empty?

  home = Dir.home
  str = path.to_s
  str.start_with?(home) ? str.sub(home, "~") : str
rescue ArgumentError
  path.to_s
end

.destroy_with_confirm(session, repo:, ui:, force: false) ⇒ Object

ONE confirm-and-destroy flow for both surfaces (#183): the CLI verb above and the in-chat ‘/sessions delete <id>`.



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/rubino/cli/session_command.rb', line 275

def self.destroy_with_confirm(session, repo:, ui:, force: false)
  unless force
    confirmed = ui.confirm_destructive(
      "Delete session #{session[:id][0..7]} '#{session[:title] || "(untitled)"}'? " \
      "This will also remove its messages, events, and tool calls."
    )
    unless confirmed
      ui.info("Aborted.")
      return
    end
  end

  repo.destroy!(session[:id])
  ui.success("Deleted session #{session[:id][0..7]}.")
end

.exit_on_failure?Boolean

Returns:

  • (Boolean)


12
13
14
# File 'lib/rubino/cli/session_command.rb', line 12

def self.exit_on_failure?
  true
end

.interactive_terminal?Boolean

True when stdin AND stdout are a real terminal, so the arrow-key picker makes sense (it reads keys and redraws). The same gate the in-REPL picker uses (Commands::Handlers::Sessions#interactive_terminal?). A pipe/redirect on either side falls back to the static ‘list`.

Returns:

  • (Boolean)


40
41
42
43
44
# File 'lib/rubino/cli/session_command.rb', line 40

def self.interactive_terminal?
  $stdin.respond_to?(:tty?) && $stdin.tty? && $stdout.respond_to?(:tty?) && $stdout.tty?
rescue StandardError
  false
end

.message_summary(session_id) ⇒ Object

Truthful, CUMULATIVE message count for ‘sessions show` (#382). The cached sessions.message_count column only counts top-level turns and hides every assistant(tool_use)/tool(result) row, so it under-reports a tool-heavy session. Count the actual messages table and label the tool rows so the breakdown is honest (e.g. “12 (4 tool)”). Falls back to the cached column if the live count can’t be read — a display detail must not raise here.



218
219
220
221
222
223
224
225
226
227
# File 'lib/rubino/cli/session_command.rb', line 218

def self.message_summary(session_id)
  return nil unless session_id

  by_role = Session::Store.new.count_by_role(session_id)
  total   = by_role.values.sum
  tool    = by_role["tool"].to_i
  tool.positive? ? "#{total} (#{tool} tool)" : total.to_s
rescue StandardError
  nil
end

.render(session, ui:) ⇒ Object

ONE session-details rendering for both surfaces (#183): the CLI verb above and the in-chat ‘/sessions show <id>` (Commands::Executor).



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/rubino/cli/session_command.rb', line 191

def self.render(session, ui:)
  # Title/Model are attacker-influenceable (the title is generated from
  # the conversation), and the rest are defensively sanitized too: `info`
  # does NOT neutralize escapes, so a raw `\e]0;…\a` / `\e[2J` here would
  # hijack the window title or clear the screen (CWE-150, R4-N2). Render
  # any control bytes as visible caret notation instead.
  ui.info("Session: #{safe(session[:id])}")
  ui.info("Title: #{safe(session[:title] || "(untitled)")}")
  ui.info("Status: #{safe(session[:status])}")
  ui.info("Dir: #{safe(collapse_home(session[:cwd]))}")
  ui.info("Model: #{safe(session[:model])}")
  ui.info("Messages: #{safe(message_summary(session[:id]))}")
  ui.info("Tokens: #{safe(token_total(session[:id]))}")
  ui.info("Created: #{safe(session[:created_at])}")
  ui.info("Updated: #{safe(session[:updated_at])}")

  return unless session[:parent_session_id]

  ui.info("Parent: #{safe(session[:parent_session_id])}")
end

.safe(text) ⇒ Object

Neutralize terminal-control bytes in untrusted stored session fields to visible caret notation before the non-sanitizing ‘info` funnel (CWE-150).



241
242
243
# File 'lib/rubino/cli/session_command.rb', line 241

def self.safe(text)
  Util::Output.sanitize_terminal(text)
end

.token_total(session_id) ⇒ Object

Truthful cumulative token total for ‘sessions show` (#382): the SUM over all persisted messages, not the non-cumulative cached column.



231
232
233
234
235
236
237
# File 'lib/rubino/cli/session_command.rb', line 231

def self.token_total(session_id)
  return nil unless session_id

  Session::Store.new.token_sum(session_id)
rescue StandardError
  nil
end

Instance Method Details

#browse(*unknown) ⇒ Object

Raises:

  • (Thor::UndefinedCommandError)


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

def browse(*unknown)
  # An UNKNOWN subcommand (`rubino sessions frobnicate`) reaches the
  # default command with its token unshifted back into the args (Thor
  # `dispatch`: `!command && invoked_via_subcommand` → call default with
  # the unmatched token). It must still EXIT NON-ZERO (#67), not silently
  # open the picker — so a stray positional re-raises Thor's own
  # "Could not find command" voice the top-level error handler renders.
  raise Thor::UndefinedCommandError.new(unknown.first, self.class.all_commands.keys, nil) unless unknown.empty?

  if self.class.interactive_terminal?
    resume
  else
    list
  end
end

#compact(id) ⇒ Object

Raises:

  • (Thor::Error)


292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/rubino/cli/session_command.rb', line 292

def compact(id)
  guard_corrupt_database!
  Rubino.ensure_database_ready!
  repo = Session::Repository.new
  session = repo.find(id)

  # Single-styled not-found error (#20), as in #show above.
  raise Thor::Error, "session not found: #{id}" if session.nil?

  Rubino.ui.info("Compacting session #{session[:id][0..7]}...")
  # Measure BEFORE so the savings line reports the TRUTHFUL before→after
  # delta (item 4) — consistent with the interactive `/compact`, which
  # reports `~X → ~Y tokens (saved ~N; A → B messages)`. The compressor's
  # own `saved_tokens` is only the removed-middle estimate (it ignores the
  # inserted summary), so we compute the delta over the persisted rows.
  store  = Session::Store.new
  before = estimate_session_tokens(store, session[:id], model_id: session[:model])

  # Pass the RESOLVED full id, not the user's short id (#352): the
  # Compressor now re-resolves internally too, but feeding it the full id
  # keeps the contract explicit and the not-found path honest.
  compressor = Context::Compressor.new(session_id: session[:id])
  result = compressor.compact!

  # A no-op compaction must NOT print the "┄ compacted · saved 0 tok ┄"
  # fake success (#352): a session with nothing to compact (0 messages, or
  # too few to split) should say so plainly. Only a real compaction —
  # something was actually moved into a summary — renders the saved-tokens
  # line.
  raise Thor::Error, compact_skip_message(session[:id], result) if result[:skipped]

  after = estimate_session_tokens(store, result[:target_session_id], model_id: session[:model])
  delta = before - after
  Rubino.ui.compression_finished(result.merge(saved_tokens: delta))
  change = delta >= 0 ? "saved ~#{delta} tok" : "grew ~#{-delta} tok"
  msgs = if result[:original_messages] && result[:compacted_messages]
           "; #{result[:original_messages]}#{result[:compacted_messages]} messages"
         else
           ""
         end
  Rubino.ui.info("Context: ~#{before} → ~#{after} tokens (#{change}#{msgs}).")
end

#delete(id) ⇒ Object

Raises:

  • (Thor::Error)


261
262
263
264
265
266
267
268
269
270
271
# File 'lib/rubino/cli/session_command.rb', line 261

def delete(id)
  guard_corrupt_database!
  Rubino.ensure_database_ready!
  repo = Session::Repository.new
  session = repo.find(id)

  # Single-styled not-found error (#20), as in #show above.
  raise Thor::Error, "session not found: #{id}" if session.nil?

  self.class.destroy_with_confirm(session, repo: repo, ui: Rubino.ui, force: options[:force])
end

#listObject



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/rubino/cli/session_command.rb', line 84

def list
  guard_corrupt_database!
  Rubino.ensure_database_ready!
  repo = Session::Repository.new
  # Reap sessions left "active" by a process that died without ending them
  # (hard terminal kill / SIGKILL, #11) so the list never shows a stale
  # "active" for a window that is actually gone.
  repo.reap_orphaned_active!
  # Default to THIS directory's sessions (#334) so a multi-folder user
  # isn't shown another project's history; --all opts back into the global
  # listing. nil cwd ⇒ unscoped (all dirs).
  cwd = options[:all] ? nil : Rubino::Workspace.primary_root
  sessions = repo.list(limit: options[:limit], status: options[:status],
                       search: options[:search], cwd: cwd)

  if sessions.empty?
    msg = options[:all] ? "No sessions found." : "No sessions found in this directory (try --all)."
    Rubino.ui.info(msg)
    return
  end

  rows = sessions.map do |s|
    # cwd (the launch dir) lets a multi-folder/multi-tab user tell which
    # project each session belongs to (r5 MF-4); home-collapsed and
    # terminal-escape-sanitized like the other stored fields.
    [self.class.safe(s[:id][0..7]),
     self.class.safe(s[:title] || "(untitled)"),
     self.class.safe(self.class.collapse_home(s[:cwd])),
     self.class.safe(s[:status]),
     self.class.safe(s[:message_count].to_s),
     self.class.safe(s[:updated_at] || s[:created_at])]
  end

  Rubino.ui.table(
    headers: %w[ID Title Dir Status Messages Updated],
    rows: rows
  )
end

#resumeObject

Bare ‘rubino sessions` on a TTY (item: CLI resume picker). Lists the (cwd-scoped, –all to unscope) sessions in the SAME arrow-key picker the in-REPL `/sessions` uses (Session::Picker — ONE selection UI), and on Enter boots the chat REPL resumed at the chosen id by handing it to the EXACT path `rubino chat –session <id>` runs (ChatCommand). Esc cancels (no boot). Off a TTY this verb is never reached — #default_subcommand routes bare `sessions` to `list` there — but if called explicitly the picker’s UI#select returns nil (non-interactive) and we fall through to the same “nothing to resume / cancelled” message, never a hang.



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/rubino/cli/session_command.rb', line 138

def resume
  guard_corrupt_database!
  Rubino.ensure_database_ready!
  repo = Session::Repository.new
  # Reap sessions left "active" by a process that died without ending
  # them, same as #list, so the picker never offers a stale "active" row.
  repo.reap_orphaned_active!
  # Default to THIS directory's sessions (#334); --all seeds an unscoped
  # picker over every dir. nil cwd ⇒ unscoped.
  cwd = options[:all] ? nil : Rubino::Workspace.primary_root
  sessions = repo.list(limit: options[:limit], status: options[:status],
                       search: options[:search], cwd: cwd)

  if sessions.empty?
    msg = options[:all] ? "No sessions found." : "No sessions found in this directory (try --all)."
    Rubino.ui.info(msg)
    return
  end

  chosen = Session::Picker.new(ui: Rubino.ui).pick(sessions)
  unless chosen
    # Esc / non-interactive UI: nothing was picked. Leave a one-line hint
    # so the user knows how to resume explicitly and isn't dropped at a
    # blank prompt wondering whether anything happened.
    Rubino.ui.info("Cancelled. Resume directly with: rubino chat --session <id>")
    return
  end

  # Hand the chosen id to the SAME resolver `rubino chat --session <id>`
  # uses (ChatCommand → SessionResolver#resolve_session_id reads :session
  # first), so the loaded REPL is byte-identical to the flag form — no
  # resume logic is reimplemented here. Pass through --yolo etc. is not
  # needed: a resume picker is the interactive entry point.
  ChatCommand.new(session: chosen).execute
end

#show(id) ⇒ Object

Raises:

  • (Thor::Error)


175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/rubino/cli/session_command.rb', line 175

def show(id)
  guard_corrupt_database!
  Rubino.ensure_database_ready!
  repo = Session::Repository.new
  session = repo.find(id)

  # One error, one style (#20): Thor already prints the Thor::Error message
  # to stderr and exits non-zero (exit_on_failure?), so the extra styled
  # ui.error line was the same failure repeated in a second format.
  raise Thor::Error, "session not found: #{id}" if session.nil?

  self.class.render(session, ui: Rubino.ui)
end