Class: Rubino::CLI::SessionCommand
- Inherits:
-
Thor
- Object
- Thor
- Rubino::CLI::SessionCommand
- Defined in:
- lib/rubino/cli/session_command.rb
Overview
Subcommands for managing chat sessions
Class Method Summary collapse
-
.collapse_home(path) ⇒ Object
Home-collapsed display path for a session’s launch dir.
-
.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>`.
- .exit_on_failure? ⇒ Boolean
-
.interactive_terminal? ⇒ Boolean
True when stdin AND stdout are a real terminal, so the arrow-key picker makes sense (it reads keys and redraws).
-
.message_summary(session_id) ⇒ Object
Truthful, CUMULATIVE message count for ‘sessions show` (#382).
-
.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).
-
.safe(text) ⇒ Object
Neutralize terminal-control bytes in untrusted stored session fields to visible caret notation before the non-sanitizing ‘info` funnel (CWE-150).
-
.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.
Instance Method Summary collapse
- #browse(*unknown) ⇒ Object
- #compact(id) ⇒ Object
- #delete(id) ⇒ Object
- #list ⇒ Object
-
#resume ⇒ Object
Bare ‘rubino sessions` on a TTY (item: CLI resume picker).
- #show(id) ⇒ Object
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
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`.
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.(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((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
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
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, (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
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: [:force]) end |
#list ⇒ Object
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 = [:all] ? nil : Rubino::Workspace.primary_root sessions = repo.list(limit: [:limit], status: [:status], search: [:search], cwd: cwd) if sessions.empty? msg = [: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 |
#resume ⇒ Object
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 = [:all] ? nil : Rubino::Workspace.primary_root sessions = repo.list(limit: [:limit], status: [:status], search: [:search], cwd: cwd) if sessions.empty? msg = [: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
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 |