claude-inbox 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b325ed415502366088de4b6e9b7634835aea1fb0a6be708e211344dfeba8fa33
4
- data.tar.gz: 53fbfbeac341e7f5643724911eb5feecb4165a6aaa367dad73c2a4002be9bee9
3
+ metadata.gz: b432b1272fd3e3b923eaefe34fa4e3c30cbd9dfcf57d4beacd761f1cef70785b
4
+ data.tar.gz: '090ff2d41f9701cc7476196af19e4604241b3b3079376a896b441ab9dbf0ce9d'
5
5
  SHA512:
6
- metadata.gz: 072a5ce3e44c1e5317d105fd49aea27fc5e1365c146c165e8ad87b61b50c24f0046bd659278a41b3848ec15523c1eaa255748f5f080a4e26b25476ba6f7e1218
7
- data.tar.gz: 31715b40f83b6749d3064c323018c74f62e1176f248bcc00b3cf16769b81c202b49cb0e7228e3ca8bd3b5519c8d7ca99001c026868d29605b488430a6662f5eb
6
+ metadata.gz: a075eee59befc0de0e667d5b4325db8086a457040d544534f75b62d8a05b91a5e703b370bd1f3ce52fe89638cefa93403b1d6acd7170f143a86111a9d4c75277
7
+ data.tar.gz: 1d3dbb6ead15764e91834af2de5b8cfa9873493830e0cf2cee6080a4884fe3d74476b2a260b47565f6e70c99f5611d9c57bb422950435b80cc90b12eb55bc859
data/README.md CHANGED
@@ -73,10 +73,8 @@ Vim bindings. Arrows and the mouse work too.
73
73
  | `Ctrl-d` `Ctrl-u` | half page down / up (`Ctrl-f` `Ctrl-b` full page) |
74
74
  | `Enter` `l` | attach (`←` or `Ctrl+Z` returns here), adopt a remote session, or expand Snoozed / Settled |
75
75
  | `Tab` `Shift+Tab` | next / previous section |
76
- | `h` | close the peek pane, else collapse the current fold |
76
+ | `h` | collapse the current fold |
77
77
  | `za` `zo` `zc` | toggle / open / close the fold under the cursor |
78
- | `p` | toggle the read-only peek pane |
79
- | `J` `K` (`Ctrl-e` `Ctrl-y`) | scroll the peek pane |
80
78
  | `n` | new session |
81
79
  | `N` | pair a phone |
82
80
  | `t` | pin / unpin |
data/exe/claude-inbox CHANGED
@@ -2,129 +2,6 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "claude_inbox"
5
+ require "claude_inbox/cli"
5
6
 
6
- if ARGV.include?("--version")
7
- puts ClaudeInbox::VERSION
8
- exit
9
- end
10
-
11
- require "claude_inbox/mod"
12
-
13
- # The stable path to the Claude Code pane, for CLAUDE_CODE_PLUGIN_DIRS.
14
- if ARGV.include?("--mod-dir")
15
- puts ClaudeInbox::Mod.link(force: true)
16
- exit
17
- end
18
-
19
- if ARGV.first == "install"
20
- begin
21
- installed = ClaudeInbox::Mod.install(force: ARGV.include?("--force"))
22
- rescue JSON::ParserError
23
- abort "claude-inbox: ~/.claude/settings.json is not valid JSON, so it was left alone"
24
- end
25
- installed.removed.each { |dir| puts "Removed #{dir} from ~/.claude/settings.json." }
26
- puts "The pane now runs #{ClaudeInbox::Mod::COMMAND || "claude-inbox from your PATH"}." if installed.command_set
27
- puts installed.added ? "Added the pane to ~/.claude/settings.json. New Claude Code sessions load it; /inbox opens it." :
28
- "The pane is already in ~/.claude/settings.json."
29
- exit
30
- end
31
-
32
- if ARGV.first == "update"
33
- require "claude_inbox/updater"
34
- updater = ClaudeInbox::Updater.new
35
- begin
36
- update = updater.check(force: true)
37
- rescue ClaudeInbox::Updater::Error => e
38
- abort "claude-inbox: #{e.message}"
39
- end
40
- unless update
41
- puts((updater.kind == :gem) ? "claude-inbox #{ClaudeInbox::VERSION} is the latest release." : "main is up to date.")
42
- exit
43
- end
44
- puts update.installing
45
- applied = updater.apply
46
- abort "claude-inbox: #{applied.output.strip}" unless applied.success?
47
- puts((update.kind == :gem) ? "Installed claude-inbox #{update.latest}." : "main is at #{update.latest}.")
48
- exit
49
- end
50
-
51
- require "claude_inbox/app"
52
- require "claude_inbox/config"
53
- require "claude_inbox/updater"
54
- require "claude_inbox/writer_lock"
55
- require "tmpdir"
56
-
57
- fixture = ARGV.include?("--fixture")
58
- # A fixture run takes the listen flags, for trying the API with curl, but
59
- # not the environment or the config file, which are for the real inbox.
60
- begin
61
- argv = fixture ? ARGV : ClaudeInbox::Config.argv(ARGV)
62
- listen = ClaudeInbox::Remote::Listener.options(argv, fixture ? {} : ENV)
63
- rescue ArgumentError => e
64
- abort "claude-inbox: #{e.message}"
65
- end
66
-
67
- # Crash traces go here instead of the alt screen when set.
68
- $stderr.reopen(ENV["CLAUDE_INBOX_STDERR"], "a") if ENV["CLAUDE_INBOX_STDERR"]
69
-
70
- opts = {}
71
- if fixture
72
- agents = argv[argv.index("--fixture") + 1]
73
- fixtures = File.dirname(agents)
74
- opts[:client] = ClaudeInbox::FixtureClient.new(agents, logs: File.binread(File.join(fixtures, "logs_raw.txt")))
75
- opts[:store] = ClaudeInbox::Store.new(path: nil)
76
- opts[:pull_requests] = ClaudeInbox::PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil)
77
- # Its own token, lock and images, so a test inbox can neither rotate the
78
- # token phones are paired with nor hold the real inbox's lock.
79
- dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
80
- opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
81
- lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
82
- else
83
- # The only place the reaper is armed, and only with --reap: deleting
84
- # sessions is opt-in. Everything else — tests, --fixture, anything
85
- # embedding App — gets one that deletes nothing. The listener, likewise,
86
- # comes only from this file, and only when asked for.
87
- opts[:client] = ClaudeInbox::AgentsClient.new
88
- opts[:store] = ClaudeInbox::Store.new
89
- opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
90
- opts[:snapshot] = ClaudeInbox::Snapshot.new
91
- opts[:switch] = ClaudeInbox::Switch.new
92
- opts[:actions] = ClaudeInbox::Actions.new
93
- opts[:pane_offer] = ClaudeInbox::Mod::Offer.new
94
- opts[:listen] = listen
95
- opts[:updater] = ClaudeInbox::Updater.new unless argv.include?("--no-update-check")
96
- # The typed arguments, not the config's: a relaunch reads the config again.
97
- opts[:argv] = ARGV
98
- end
99
- opts[:color] = false if argv.include?("--no-color")
100
-
101
- if argv.include?("--headless")
102
- abort "claude-inbox: --headless takes no --fixture" if fixture
103
- require "claude_inbox/headless"
104
- exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store], updater: opts[:updater]).run ? 0 : 1)
105
- end
106
-
107
- # Only what a check within the day already found: a cold launch never waits
108
- # on the network. The relaunch is the new version's launch; a failure leaves
109
- # this one to start as usual.
110
- if argv.include?("--auto-update") && (update = opts[:updater]&.cached)
111
- puts update.installing
112
- applied = opts[:updater].apply
113
- applied.success? ? opts[:updater].relaunch(ARGV) : warn("claude-inbox: #{applied.output.strip}")
114
- end
115
-
116
- # The first inbox you can see is the snapshot's writer: a headless one gives
117
- # way to it, and a second inbox reads along, writing and deleting nothing.
118
- # Held for the life of this process by this reference.
119
- writer_lock = fixture ? ClaudeInbox::WriterLock.disabled : ClaudeInbox::WriterLock.new
120
- unless writer_lock.take(role: "inbox")
121
- opts[:store] = ClaudeInbox::Store.new(writable: false, relay: ClaudeInbox::Actions.new)
122
- opts[:reaper] = ClaudeInbox::Reaper.disabled
123
- opts[:snapshot] = ClaudeInbox::Snapshot.disabled
124
- opts[:actions] = ClaudeInbox::Actions.disabled
125
- opts[:pane_offer] = ClaudeInbox::Mod::Offer.disabled
126
- end
127
- # So the pane settings.json names is the installed version's, after a gem update too.
128
- ClaudeInbox::Mod.link unless fixture
129
-
130
- ClaudeInbox::App.new(**opts).run
7
+ exit ClaudeInbox::CLI.new(ARGV).run
@@ -27,13 +27,6 @@ module ClaudeInbox
27
27
  classify_origins(parse(r.out))
28
28
  end
29
29
 
30
- # Raw terminal replay for a session, or nil when the daemon can't serve it
31
- # (finished sessions whose process is gone report "job not found").
32
- def logs(id)
33
- r = Subprocess.capture(@bin, "logs", id)
34
- (r.success? && !r.out.empty?) ? r.out : nil
35
- end
36
-
37
30
  # Poll interval for the agents-view watchdog below.
38
31
  WATCH_INTERVAL = 0.05
39
32
 
@@ -259,10 +252,9 @@ module ClaudeInbox
259
252
 
260
253
  # Reads a committed JSON fixture instead of the daemon.
261
254
  class FixtureClient < AgentsClient
262
- def initialize(path, logs: nil, origins: {}, bridges: {})
255
+ def initialize(path, origins: {}, bridges: {})
263
256
  super(jobs_dir: File.join(File.dirname(path), "jobs"))
264
257
  @path = path
265
- @logs = logs
266
258
  @origins = origins
267
259
  @bridges = bridges
268
260
  end
@@ -274,8 +266,6 @@ module ClaudeInbox
274
266
  assign_origins(parse(File.read(@path)), @origins, @bridges)
275
267
  end
276
268
 
277
- def logs(_id) = @logs
278
-
279
269
  # Stand-in child: prints, waits for a line, exits — enough to prove the
280
270
  # terminal round-trips through cooked mode and back.
281
271
  def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # Handing the terminal to a session and getting it back, and asking the
6
+ # daemon to stop or delete one.
7
+ module Attaching
8
+ private
9
+
10
+ def activate
11
+ return set_expanded(true) if @selected&.fold?
12
+ return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.remote?
13
+ attach(@selected.key) if require_actionable
14
+ end
15
+
16
+ # Guard for attach/stop: refuse politely on a terminal or remote row.
17
+ def require_actionable
18
+ s = selected_session
19
+ return true if s&.actionable?
20
+ if s&.interactive?
21
+ notice(s.remote? ? "that's a remote session — Enter adopts it, w opens it at claude.ai/code" : "that's your own terminal — switch to that window")
22
+ end
23
+ false
24
+ end
25
+
26
+ # The pane in the attached session may ask for a different one: the
27
+ # attach ends and the next starts without passing through the list. The
28
+ # poller keeps running meanwhile, so the pane's snapshot stays fresh. A
29
+ # failed attach leaves its reason on screen until a key is pressed.
30
+ def attach(id)
31
+ while id
32
+ @store.acknowledge(id)
33
+ @switch.clear
34
+ from = session_for(id)&.session_id
35
+ @terminal.release do
36
+ @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
37
+ end
38
+ id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
39
+ end
40
+ end
41
+
42
+ def adopt_session(key)
43
+ s = session_for(key)
44
+ return unless s&.remote?
45
+ notice("adopting #{s.display_name}…")
46
+ in_background do
47
+ id = @client.adopt(session_id: s.session_id, cwd: s.cwd, pid: s.pid)
48
+ @queue << [:notice, "adopted as #{id}"]
49
+ @queue << [:select, id]
50
+ @queue << [:attach, id]
51
+ end
52
+ end
53
+
54
+ def open_confirm(kind)
55
+ return unless require_actionable
56
+ @modal = Dialog::Confirm.new(kind, @selected.key)
57
+ end
58
+
59
+ def stop_session(id)
60
+ in_background do
61
+ @client.stop(id)
62
+ @poller.soon
63
+ end
64
+ end
65
+
66
+ def delete_session(id)
67
+ notice("deleting #{id}…")
68
+ in_background do
69
+ @client.rm(id)
70
+ @store.forget(id)
71
+ @queue << [:notice, "deleted #{id}"]
72
+ @poller.soon
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # o opens the selected session's pull request and w its claude.ai/code
6
+ # page, through whatever opens URLs on this platform.
7
+ module Browsing
8
+ private
9
+
10
+ def open_pr
11
+ pr = selected_session&.pr
12
+ return notice("no pull request linked — P sets one") unless pr
13
+ open_in_browser(pr.url, pr.short)
14
+ end
15
+
16
+ def open_remote
17
+ url = selected_session&.remote_url
18
+ return notice("no claude.ai/code page for this session") unless url
19
+ open_in_browser(url, "claude.ai/code")
20
+ end
21
+
22
+ def open_in_browser(url, what)
23
+ opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
24
+ notice("opening #{what}")
25
+ in_background { Subprocess.capture(opener, url) }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # The `/` filter line and the sections that match it.
6
+ module Filtering
7
+ private
8
+
9
+ def filter_editing? = @filter_editing
10
+
11
+ def filter = @filter
12
+
13
+ def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
14
+
15
+ def start_filter
16
+ @filter ||= TextBuffer.new
17
+ @filter_editing = true
18
+ end
19
+
20
+ def clear_filter
21
+ @filter = nil
22
+ @filter_editing = false
23
+ end
24
+
25
+ def handle_line_key(name, key)
26
+ case name
27
+ when :escape then clear_filter
28
+ when :return, :enter then @filter_editing = false
29
+ when :backspace, :ctrl_h
30
+ @filter.empty? ? clear_filter : @filter.press(name, key)
31
+ else
32
+ @filter.press(name, key)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # Where the cursor is in the sectioned, foldable list and how it moves.
6
+ # The only writer of the selection; the rest of App reads it.
7
+ module Navigating
8
+ private
9
+
10
+ # Remembers where the frame put the rows, so a click can find one.
11
+ def placed(frame)
12
+ @row_items = frame.items
13
+ @top = frame.top
14
+ end
15
+
16
+ # The row to land on once a poll lists it, as after a spawn.
17
+ def select_later(id)
18
+ @pending_select = id
19
+ end
20
+
21
+ def ensure_selection(sections)
22
+ stops = sections.selections(@expanded)
23
+ pending = @pending_select && Store::Selection.row(@pending_select)
24
+ if pending && stops.include?(pending)
25
+ select(pending)
26
+ @pending_select = nil
27
+ return
28
+ end
29
+ return if stops.include?(@selected)
30
+ select(stops.first)
31
+ end
32
+
33
+ def select(selection)
34
+ @selected = selection
35
+ end
36
+
37
+ def session_for(key) = @store.sessions.find { |s| s.key == key }
38
+
39
+ def selected_session = session_for(@selected&.key)
40
+
41
+ def page = [Renderer.body_height(@terminal.size[1]), 1].max
42
+
43
+ def move(delta)
44
+ stops = filtered.selections(@expanded)
45
+ return if stops.empty?
46
+ idx = stops.index(@selected) || 0
47
+ select(stops[(idx + delta).clamp(0, stops.size - 1)])
48
+ end
49
+
50
+ # Tab / Shift-Tab: first selectable row of the next / previous section.
51
+ def jump_section(dir)
52
+ sections = filtered
53
+ heads = sections.heads(@expanded)
54
+ return if heads.empty?
55
+ current = sections.section_of(@selected)
56
+ idx = heads.index { |name, _| name == current } || -1
57
+ select(heads[(idx + dir) % heads.size].last)
58
+ end
59
+
60
+ # The foldable section the cursor is currently on or inside, if any.
61
+ def current_fold_section
62
+ name = filtered.section_of(@selected)
63
+ name if Store::FOLDABLE_SECTIONS.include?(name)
64
+ end
65
+
66
+ def set_expanded(value)
67
+ name = current_fold_section
68
+ @expanded[name] = value if name
69
+ end
70
+
71
+ def toggle_expanded = set_expanded(!@expanded[current_fold_section])
72
+
73
+ # Clicking a row selects it and attaches, same as landing on it with
74
+ # j/k and pressing Enter — activate already knows how to expand a fold
75
+ # or refuse a terminal/remote row, so this doesn't repeat that.
76
+ def click_row(row)
77
+ item = row_item_at(row)
78
+ return unless item
79
+ select(item)
80
+ activate
81
+ end
82
+
83
+ # The wrapped detail line under a two-line row ("↳ ~/code/x") carries
84
+ # no item of its own; a click there resolves to the row above it.
85
+ def row_item_at(row)
86
+ idx = row - 1
87
+ @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # Starting a session, from the `n` form here or from another device.
6
+ module Spawning
7
+ private
8
+
9
+ def open_new_session
10
+ cwd = selected_session&.cwd || Dir.pwd
11
+ dirs = SessionRequest.directories(@store.sessions, @trust.call)
12
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
13
+ end
14
+
15
+ # `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
16
+ # closes itself once the spawn is confirmed to have started (`:form_started`)
17
+ # or reopens with the failure and the prompt intact (`:form_failed`).
18
+ def handle_form_key(name, key)
19
+ form = @modal
20
+ case form.press(name, key)
21
+ when :cancel then @modal = nil
22
+ when :start then start_session(form, attach: false)
23
+ when :start_and_attach then start_session(form, attach: true)
24
+ end
25
+ end
26
+
27
+ # `attach:` hands the terminal over as soon as the session starts. Without
28
+ # it we stay in the inbox and poll, so the new row shows up right away
29
+ # rather than at the next tick.
30
+ def start_session(form, attach:)
31
+ v = form.values
32
+ notice("starting session…")
33
+ in_background do
34
+ id = @client.spawn(**v)
35
+ @queue << [:notice, "started #{id}"]
36
+ @queue << [:form_started, form]
37
+ @queue << [:select, id]
38
+ attach ? @queue << [:attach, id] : @poller.soon
39
+ rescue AgentsClient::Error => e
40
+ @queue << [:form_failed, form, e.message]
41
+ end
42
+ end
43
+
44
+ def form_started(form)
45
+ @modal = nil if @modal.equal?(form)
46
+ end
47
+
48
+ # A spawn failure hands the form back rather than just logging it, so
49
+ # the composed prompt survives (see NewSessionForm#submission_failed).
50
+ def form_failed(form, message)
51
+ @modal.equal?(form) ? form.submission_failed(message) : (@error = message)
52
+ end
53
+
54
+ # Someone at the desk may be mid-thought, so a start from another
55
+ # device never moves the cursor, attaches or closes what is open.
56
+ def remote_started(id, via)
57
+ notice("started #{id} from #{via}")
58
+ @poller.soon
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # The edits of the selected row that live in our own store, and the two
6
+ # guards they share.
7
+ module Triaging
8
+ private
9
+
10
+ # Guard for snooze/wake/alias: anything with a key, since those live in
11
+ # our own store. A terminal you are sitting in is the one exception.
12
+ def require_storable
13
+ s = selected_session
14
+ return true if @selected&.row? && !s&.terminal?
15
+ notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
16
+ false
17
+ end
18
+
19
+ # The edits with no request form, which a second inbox cannot keep.
20
+ def require_writable(verb)
21
+ return true if @store.writable?
22
+ notice("another claude-inbox owns state.json — #{verb} there")
23
+ false
24
+ end
25
+
26
+ def wake_selected
27
+ @store.wake(@selected.key) if require_storable
28
+ end
29
+
30
+ def toggle_pin_selected
31
+ @store.toggle_pin(@selected.key) if require_storable
32
+ end
33
+
34
+ def settle_selected
35
+ return unless require_storable
36
+ @store.settle(@selected.key)
37
+ notice("settled — u brings it back")
38
+ end
39
+
40
+ def open_snooze_menu
41
+ return unless require_storable
42
+ @modal = Dialog::Snooze.new(@selected.key)
43
+ end
44
+
45
+ def snooze_chosen
46
+ @store.snooze(@modal.id, @modal.choice)
47
+ @modal = nil
48
+ end
49
+
50
+ def open_alias_editor
51
+ return unless require_storable && require_writable("rename")
52
+ current = @store.alias_for(@selected.key) || ""
53
+ @modal = Dialog::Prompt.new(:alias, @selected.key, current)
54
+ end
55
+
56
+ def open_pr_editor
57
+ return unless require_storable && require_writable("link a pull request")
58
+ current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
59
+ @modal = Dialog::Prompt.new(:pr, @selected.key, current)
60
+ end
61
+
62
+ def save_prompt
63
+ value = @modal.value.strip
64
+ if @modal.kind == :alias
65
+ @store.set_alias(@modal.id, value)
66
+ elsif value.empty? || PullRequests.valid_url?(value)
67
+ @store.set_pr(@modal.id, value)
68
+ @poller.soon
69
+ else
70
+ return notice("that's not a github.com pull request url")
71
+ end
72
+ @modal = nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeInbox
4
+ class App
5
+ # The newer-release check, the header's word on it, and U to install
6
+ # and relaunch.
7
+ module Updating
8
+ private
9
+
10
+ # Once, on the first step: a stale cache means a network round trip, so
11
+ # it runs off the main thread. A laptop that is offline stays quiet
12
+ # rather than show an error at every launch; U says what went wrong.
13
+ def ensure_update_checked
14
+ return if @update_status
15
+ @update_status = @updater ? "checking for updates…" : "update check is off — claude-inbox update installs the latest"
16
+ return unless @updater
17
+ in_background do
18
+ @queue << [:update, @updater.check]
19
+ rescue Updater::Error => e
20
+ @queue << [:update, nil, e.message]
21
+ end
22
+ end
23
+
24
+ def update_checked(update, failure = nil)
25
+ @update = update
26
+ @snapshot.update = update&.notice
27
+ @update_status = failure ? "couldn't check for updates: #{failure}" : "up to date"
28
+ end
29
+
30
+ # The header's line once a newer release is known; nil until then.
31
+ def update_notice
32
+ "#{@update.notice} · U updates" if @update
33
+ end
34
+
35
+ # The install prints on the real screen, and the relaunch replaces this
36
+ # process. A failed install relaunches this version instead, once its
37
+ # error has been read, rather than leave the shell showing.
38
+ def update_inbox
39
+ return notice(@update_status) unless @update
40
+ @terminal.release do
41
+ @out.puts @update.installing
42
+ applied = @updater.apply
43
+ @out.puts applied.output unless applied.output.empty?
44
+ @terminal.pause unless applied.success?
45
+ relaunch
46
+ end
47
+ end
48
+
49
+ def relaunch
50
+ @updater.relaunch(@argv)
51
+ rescue SystemCallError => e
52
+ @error = "relaunch failed: #{e.message}"
53
+ end
54
+ end
55
+ end
56
+ end