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.



163
164
165
166
167
168
169
170
171
# File 'lib/rubino/cli/session_command.rb', line 163

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>`.



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/rubino/cli/session_command.rb', line 190

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

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



133
134
135
136
137
138
139
140
141
142
# File 'lib/rubino/cli/session_command.rb', line 133

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

.no_subcommand?(args) ⇒ Boolean

True when the args carry no leading SUBCOMMAND token — either empty, or starting with an option flag (‘–all`, `-h`). Such an invocation is the bare-`sessions` intent, so we route it to `list` (item 3). A first positional token (`show`, `compact`, or even a typo like `frobnicate`) is left for normal Thor dispatch so the unknown-subcommand error (#67) and the real subcommands are untouched.

Returns:

  • (Boolean)


35
36
37
38
# File 'lib/rubino/cli/session_command.rb', line 35

def self.no_subcommand?(args)
  first = args.find { |a| !a.to_s.empty? }
  first.nil? || first.to_s.start_with?("-")
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).



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/rubino/cli/session_command.rb', line 106

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).



156
157
158
# File 'lib/rubino/cli/session_command.rb', line 156

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

.start(given_args = ARGV, config = {}) ⇒ Object

Bare ‘rubino sessions` LISTS rather than printing the subcommand help (item 3): listing is the overwhelmingly common intent, and the help was a dead end that hid the very thing the user came for. We rewrite ONLY the empty-args invocation to `list` and otherwise defer to normal Thor dispatch — so `sessions show|delete|compact`, `sessions help`, and the unknown-subcommand error (#67: `sessions frobnicate` must still exit non-zero) all behave exactly as before. A leading `–help`/`-h`/`–all` is NOT empty, so it routes normally too.



24
25
26
27
# File 'lib/rubino/cli/session_command.rb', line 24

def self.start(given_args = ARGV, config = {})
  given_args = ["list", *given_args] if no_subcommand?(given_args)
  super
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.



146
147
148
149
150
151
152
# File 'lib/rubino/cli/session_command.rb', line 146

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

#compact(id) ⇒ Object

Raises:

  • (Thor::Error)


207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/rubino/cli/session_command.rb', line 207

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.
  if result[:skipped]
    threshold = result[:minimum_messages]
    bar = threshold ? " (needs >= #{threshold} messages)" : ""
    raise Thor::Error,
          "nothing to compact in session #{session[:id][0..7]}: " \
          "it has too few messages to summarize#{bar}."
  end

  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)


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

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



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/rubino/cli/session_command.rb', line 50

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

#show(id) ⇒ Object

Raises:

  • (Thor::Error)


90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/rubino/cli/session_command.rb', line 90

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