claude-inbox 0.4.0 → 0.6.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -9
  3. data/exe/claude-inbox +40 -5
  4. data/lib/claude_inbox/actions.rb +11 -8
  5. data/lib/claude_inbox/agents_client.rb +21 -15
  6. data/lib/claude_inbox/app.rb +77 -13
  7. data/lib/claude_inbox/dialog.rb +40 -0
  8. data/lib/claude_inbox/headless.rb +20 -4
  9. data/lib/claude_inbox/images.rb +1 -1
  10. data/lib/claude_inbox/job_state.rb +16 -2
  11. data/lib/claude_inbox/keymap.rb +1 -1
  12. data/lib/claude_inbox/logs.rb +0 -1
  13. data/lib/claude_inbox/mod.rb +36 -1
  14. data/lib/claude_inbox/mouse.rb +1 -1
  15. data/lib/claude_inbox/new_session_form.rb +59 -19
  16. data/lib/claude_inbox/painter.rb +1 -3
  17. data/lib/claude_inbox/palette.rb +1 -1
  18. data/lib/claude_inbox/paste.rb +1 -1
  19. data/lib/claude_inbox/peek.rb +5 -9
  20. data/lib/claude_inbox/poller.rb +5 -9
  21. data/lib/claude_inbox/pull_requests.rb +6 -6
  22. data/lib/claude_inbox/rate_limits.rb +1 -1
  23. data/lib/claude_inbox/reaper.rb +2 -4
  24. data/lib/claude_inbox/remote/http.rb +2 -2
  25. data/lib/claude_inbox/remote/listener.rb +2 -6
  26. data/lib/claude_inbox/remote/page.html +7 -4
  27. data/lib/claude_inbox/remote/pairing.rb +2 -1
  28. data/lib/claude_inbox/remote/start.rb +3 -8
  29. data/lib/claude_inbox/renderer.rb +5 -13
  30. data/lib/claude_inbox/session_request.rb +16 -4
  31. data/lib/claude_inbox/slash_commands.rb +13 -13
  32. data/lib/claude_inbox/snapshot.rb +8 -4
  33. data/lib/claude_inbox/store/entry.rb +4 -5
  34. data/lib/claude_inbox/store/row.rb +0 -2
  35. data/lib/claude_inbox/store/sections.rb +3 -3
  36. data/lib/claude_inbox/store.rb +1 -2
  37. data/lib/claude_inbox/switch.rb +1 -3
  38. data/lib/claude_inbox/terminal.rb +8 -0
  39. data/lib/claude_inbox/text_buffer.rb +4 -9
  40. data/lib/claude_inbox/theme.rb +1 -1
  41. data/lib/claude_inbox/updater.rb +163 -0
  42. data/lib/claude_inbox/writer_lock.rb +1 -8
  43. data/lib/claude_inbox.rb +1 -1
  44. data/mod/hooks/register.tsx +90 -45
  45. data/mod/tests/contract.test.tsx +2 -3
  46. data/mod/tests/pane.test.tsx +87 -8
  47. data/mod/tests/snapshot.fixture.ts +6 -8
  48. data/mod/types/index.d.ts +3 -3
  49. metadata +2 -2
  50. data/lib/claude_inbox/remote.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c29f6bef2ba7ecc7f4d63161cfc064517e21f95a3a187313a1c5b882ae34786
4
- data.tar.gz: 5195d163ef619f7f82a7224ca0a9b4dfe508fd23d8c1b3d87ab0a7b3b419cb28
3
+ metadata.gz: b325ed415502366088de4b6e9b7634835aea1fb0a6be708e211344dfeba8fa33
4
+ data.tar.gz: 53fbfbeac341e7f5643724911eb5feecb4165a6aaa367dad73c2a4002be9bee9
5
5
  SHA512:
6
- metadata.gz: 9b051d9eeba2b19da5c9ced63f9dcd00030619cc4c7ac667f873da8db411ecf3c777352d2b373dfb7def00c9737c6d1ed0bc8edb0663b0a73d850c01e429d20d
7
- data.tar.gz: cfe120a05f387965ba538ea48dbf726ad1bf379684b1515d213bead19d02803be8030b14abd938457671c6d80fe0d65ce1de1715632db8c304b768a5e6d46ae8
6
+ metadata.gz: 072a5ce3e44c1e5317d105fd49aea27fc5e1365c146c165e8ad87b61b50c24f0046bd659278a41b3848ec15523c1eaa255748f5f080a4e26b25476ba6f7e1218
7
+ data.tar.gz: 31715b40f83b6749d3064c323018c74f62e1176f248bcc00b3cf16769b81c202b49cb0e7228e3ca8bd3b5519c8d7ca99001c026868d29605b488430a6662f5eb
data/README.md CHANGED
@@ -3,6 +3,10 @@
3
3
  An inbox for Claude Code's background sessions. It lists what `claude agents`
4
4
  lists and adds snooze, settle, pinning and each session's pull request.
5
5
 
6
+ Inspired by [T3 Code](https://github.com/pingdotgg/t3code)'s functionality and
7
+ inbox-style UX, I wanted a better way to manage my Claude agents, so I built
8
+ this.
9
+
6
10
  ![claude-inbox with one working session, one snoozed and the settled section folded](docs/screenshot.png)
7
11
 
8
12
  ## Install
@@ -14,6 +18,8 @@ gem install claude-inbox
14
18
  claude-inbox
15
19
  ```
16
20
 
21
+ `claude-inbox update` installs the latest release.
22
+
17
23
  Put arguments you want on every launch in `~/.config/claude-inbox/config`.
18
24
 
19
25
  To add the pane to every Claude Code session:
@@ -22,6 +28,7 @@ To add the pane to every Claude Code session:
22
28
  claude-inbox install
23
29
  ```
24
30
 
31
+ The inbox offers to do this once, the first time it starts without the pane.
25
32
  `claude-inbox install --force` also replaces any other copy of the pane.
26
33
 
27
34
  ## Claude Code pane
@@ -33,8 +40,8 @@ a fullscreen terminal.
33
40
 
34
41
  `/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
35
42
  ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
36
- `P`, `X`, `Ctrl-x`, `/` or `R`. `Enter` switches to the session when you
37
- attached through `claude-inbox`.
43
+ `P`, `X`, `Ctrl-x`, `/`, `R` or `U`. `Enter`, or a click on a row, switches to the
44
+ session when you attached through `claude-inbox`.
38
45
 
39
46
  It opens when a session starts. To stop that, set
40
47
  `pluginConfigs.inbox-pane.options.openOnStart` to `false` in
@@ -84,6 +91,7 @@ Vim bindings. Arrows and the mouse work too.
84
91
  | `Ctrl-x` | delete the session, transcript and worktree for good |
85
92
  | `/` | filter; `Enter` keeps it, `Esc` clears |
86
93
  | `R` | poll now |
94
+ | `U` | update claude-inbox when the header says a newer one is out |
87
95
  | `q` | quit |
88
96
 
89
97
  `X` and `Ctrl-x` both ask for `y` first.
@@ -91,12 +99,16 @@ Vim bindings. Arrows and the mouse work too.
91
99
  ### New session
92
100
 
93
101
  `n` opens a form for the prompt, name, directory, model, effort, permissions,
94
- worktree and Remote Control. `Tab` moves between fields, `Ctrl-S` starts the
95
- session and `Ctrl-O` starts it and attaches.
102
+ worktree, Remote Control and color, which tints the row as `/color` would.
103
+ `Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
104
+ it and attaches.
96
105
 
97
106
  Paste an image on macOS, or drop a file into the prompt, and refer to it as
98
107
  `[Image #1]`. Type `/` for a menu of your skills.
99
108
 
109
+ Type in Directory to pick from recent and trusted directories; `Ctrl-U`
110
+ lists them all.
111
+
100
112
  ## How sessions move
101
113
 
102
114
  A snoozed session wakes when its timer runs out, when you press `u`, or when
@@ -108,11 +120,10 @@ again.
108
120
  A session settles when you press `x` or when all its pull requests are merged
109
121
  or closed. A session without a pull request only settles by hand.
110
122
 
111
- The inbox deletes background sessions that have been idle for 14 days, with
112
- `claude rm`. Working, pinned and snoozed sessions are kept, and `claude rm`
113
- refuses a worktree with unpushed commits. Each deletion is logged to
114
- `~/.config/claude-inbox/reaped.log`. Set `CLAUDE_INBOX_NO_REAP=1` to turn it
115
- off.
123
+ With `--reap`, the inbox deletes background sessions that have been idle for
124
+ 14 days, with `claude rm`. Working, pinned and snoozed sessions are kept, and
125
+ `claude rm` refuses a worktree with unpushed commits. Each deletion is logged
126
+ to `~/.config/claude-inbox/reaped.log`.
116
127
 
117
128
  ## Remote Control
118
129
 
data/exe/claude-inbox CHANGED
@@ -29,8 +29,28 @@ if ARGV.first == "install"
29
29
  exit
30
30
  end
31
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
+
32
51
  require "claude_inbox/app"
33
52
  require "claude_inbox/config"
53
+ require "claude_inbox/updater"
34
54
  require "claude_inbox/writer_lock"
35
55
  require "tmpdir"
36
56
 
@@ -60,23 +80,37 @@ if fixture
60
80
  opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
61
81
  lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
62
82
  else
63
- # The only place the reaper is armed. Everything else — tests, --fixture,
64
- # anything embedding App — gets one that deletes nothing. The listener,
65
- # likewise, comes only from this file, and only when asked for.
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.
66
87
  opts[:client] = ClaudeInbox::AgentsClient.new
67
88
  opts[:store] = ClaudeInbox::Store.new
68
- opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
89
+ opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
69
90
  opts[:snapshot] = ClaudeInbox::Snapshot.new
70
91
  opts[:switch] = ClaudeInbox::Switch.new
71
92
  opts[:actions] = ClaudeInbox::Actions.new
93
+ opts[:pane_offer] = ClaudeInbox::Mod::Offer.new
72
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
73
98
  end
74
99
  opts[:color] = false if argv.include?("--no-color")
75
100
 
76
101
  if argv.include?("--headless")
77
102
  abort "claude-inbox: --headless takes no --fixture" if fixture
78
103
  require "claude_inbox/headless"
79
- exit(ClaudeInbox::Headless.new(client: opts[:client], store: opts[:store]).run ? 0 : 1)
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}")
80
114
  end
81
115
 
82
116
  # The first inbox you can see is the snapshot's writer: a headless one gives
@@ -88,6 +122,7 @@ unless writer_lock.take(role: "inbox")
88
122
  opts[:reaper] = ClaudeInbox::Reaper.disabled
89
123
  opts[:snapshot] = ClaudeInbox::Snapshot.disabled
90
124
  opts[:actions] = ClaudeInbox::Actions.disabled
125
+ opts[:pane_offer] = ClaudeInbox::Mod::Offer.disabled
91
126
  end
92
127
  # So the pane settings.json names is the installed version's, after a gem update too.
93
128
  ClaudeInbox::Mod.link unless fixture
@@ -11,7 +11,6 @@ module ClaudeInbox
11
11
  # the store cannot honour after that, or a verb it lacks, is dropped.
12
12
  class Actions
13
13
  DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "actions")
14
- VERBS = %w[settle wake pin snooze acknowledge].freeze
15
14
  SNOOZES = %w[m15 h1 tomorrow_9am until_woken].freeze
16
15
  # A file still being written parses as nothing for milliseconds; a
17
16
  # session not yet polled appears within one poll.
@@ -25,16 +24,16 @@ module ClaudeInbox
25
24
  @sent = 0
26
25
  end
27
26
 
28
- # Leaves one request for the writer, as the pane does; `from` names the
29
- # asker, and a count keeps two requests in one millisecond apart.
30
- def request(action, id, choice: nil, from: "inbox-#{Process.pid}")
27
+ # Leaves one request for the writer, as the pane does, named for this
28
+ # inbox; a count keeps two requests in one millisecond apart.
29
+ def request(action, id, choice: nil)
31
30
  return unless @dir
32
31
  FileUtils.mkdir_p(@dir)
33
32
  at = (@clock.call.to_f * 1000).to_i
34
33
  request = {"action" => action, "id" => id, "at" => at}
35
34
  request["choice"] = choice if choice
36
35
  @sent += 1
37
- File.write(File.join(@dir, "#{from}-#{at}-#{@sent}.json"), JSON.generate(request))
36
+ File.write(File.join(@dir, "inbox-#{Process.pid}-#{at}-#{@sent}.json"), JSON.generate(request))
38
37
  end
39
38
 
40
39
  # Applies every request on disk to `store` and returns how many it took.
@@ -62,12 +61,14 @@ module ClaudeInbox
62
61
  def parse(path)
63
62
  request = JSON.parse(File.read(path))
64
63
  request = {} unless request.is_a?(Hash)
65
- return request if File.mtime(path) < @clock.call - GRACE
64
+ return request if stale?(path)
66
65
  (request.key?("id") && request.key?("action")) ? request : nil
67
66
  rescue JSON::ParserError
68
- (File.mtime(path) < @clock.call - GRACE) ? {} : nil
67
+ stale?(path) ? {} : nil
69
68
  end
70
69
 
70
+ def stale?(path) = File.mtime(path) < @clock.call - GRACE
71
+
71
72
  def young?(request)
72
73
  at = request["at"].to_i / 1000
73
74
  at.positive? && Time.at(at) >= @clock.call - GRACE
@@ -75,7 +76,7 @@ module ClaudeInbox
75
76
 
76
77
  def apply(store, request, keys)
77
78
  id = request["id"]
78
- return false unless keys.include?(id) && VERBS.include?(request["action"])
79
+ return false unless keys.include?(id)
79
80
  case request["action"]
80
81
  when "settle" then store.settle(id)
81
82
  when "wake" then store.wake(id)
@@ -84,6 +85,8 @@ module ClaudeInbox
84
85
  when "snooze"
85
86
  return false unless SNOOZES.include?(request["choice"])
86
87
  store.snooze(id, request["choice"].to_sym)
88
+ else
89
+ return false
87
90
  end
88
91
  true
89
92
  end
@@ -39,6 +39,8 @@ module ClaudeInbox
39
39
 
40
40
  # Hands the terminal to the child; caller must have restored cooked mode.
41
41
  # Returns when the user detaches. Detaching never stops the session.
42
+ # False when the attach failed on its own, as a session that cannot be
43
+ # woken does: the CLI prints why and exits 1.
42
44
  #
43
45
  # Pressing ← inside an attached session detaches it and then `claude
44
46
  # attach` execs itself in place as `claude agents` (same pid). There is
@@ -52,22 +54,28 @@ module ClaudeInbox
52
54
  pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
53
55
  watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
54
56
  _, status = Process.wait2(pid)
55
- status
57
+ status.success? || !status.exited?
56
58
  ensure
57
59
  watchdog&.kill
58
60
  end
59
61
 
60
- def stop(id) = run(@bin, "stop", id)
62
+ def stop(id) = run("stop", id)
61
63
 
62
- def rm(id) = run(@bin, "rm", id)
64
+ def rm(id) = run("rm", id)
63
65
 
64
66
  MODELS = %w[default fable opus sonnet haiku].freeze
65
67
  EFFORTS = %w[default low medium high xhigh max].freeze
66
68
  PERMISSION_MODES = %w[default acceptEdits auto plan bypassPermissions].freeze
67
-
68
- # Start a background session. Returns its short id.
69
- def spawn(prompt:, cwd:, **opts)
70
- start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
69
+ # The colors `/color` offers, in its order.
70
+ COLORS = %w[default red blue green yellow purple orange pink cyan].freeze
71
+
72
+ # Start a background session. Returns its short id. `claude --bg` takes
73
+ # no color, and `/color` refuses a prompt after the color, so the color
74
+ # goes into the job file the way `/color` would put it there.
75
+ def spawn(prompt:, cwd:, color: nil, **opts)
76
+ id = start_bg(self.class.spawn_args(@bin, prompt: prompt, **opts), cwd, "claude --bg")
77
+ JobState.write_color(id, color, jobs_dir: @jobs_dir) if color
78
+ id
71
79
  end
72
80
 
73
81
  # How a prompt attaches a file: the same @ mention the CLI's own prompt
@@ -188,14 +196,13 @@ module ClaudeInbox
188
196
  # origin, terminal when unlisted) and the bridge a remote one is served
189
197
  # over, and drops the unattended ones, whose parent is the row worth
190
198
  # showing.
191
- def assign_origins(sessions, origins, bridges = {})
199
+ def assign_origins(sessions, origins, bridges)
192
200
  sessions
193
201
  .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
194
202
  .reject(&:unattended?)
195
203
  end
196
204
 
197
205
  def origins_from(rows)
198
- return {} if rows.empty?
199
206
  self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
200
207
  end
201
208
 
@@ -238,10 +245,9 @@ module ClaudeInbox
238
245
  nil
239
246
  end
240
247
 
241
- def run(*argv)
242
- r = Subprocess.capture(*argv)
243
- raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
244
- true
248
+ def run(verb, id)
249
+ r = Subprocess.capture(@bin, verb, id)
250
+ raise Error, "#{verb} failed: #{r.err.strip}" unless r.success?
245
251
  end
246
252
 
247
253
  def start_bg(argv, cwd, what)
@@ -274,7 +280,7 @@ module ClaudeInbox
274
280
  # terminal round-trips through cooked mode and back.
275
281
  def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
276
282
 
277
- def stop(_id) = true
283
+ def stop(_id) = nil
278
284
 
279
285
  def spawn(prompt:, cwd:, **)
280
286
  sleep 0.5
@@ -283,6 +289,6 @@ module ClaudeInbox
283
289
 
284
290
  def adopt(session_id:, cwd:, pid:) = "adop7ed0"
285
291
 
286
- def rm(_id) = true
292
+ def rm(_id) = nil
287
293
  end
288
294
  end
@@ -11,7 +11,7 @@ require_relative "terminal"
11
11
  require_relative "logs"
12
12
  require_relative "peek"
13
13
  require_relative "keymap"
14
- require_relative "remote"
14
+ require_relative "remote/listener"
15
15
  require_relative "mouse"
16
16
  require_relative "new_session_form"
17
17
  require_relative "paste"
@@ -23,6 +23,9 @@ require_relative "session_request"
23
23
  require_relative "actions"
24
24
  require_relative "snapshot"
25
25
  require_relative "switch"
26
+ require_relative "trust"
27
+ require_relative "mod"
28
+ require_relative "updater"
26
29
 
27
30
  module ClaudeInbox
28
31
  # Owns the terminal and the key loop. The only class allowed to spawn a
@@ -32,15 +35,19 @@ module ClaudeInbox
32
35
  # so both are off unless `bin/claude-inbox` switches them on.
33
36
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
34
37
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
35
- actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
36
- terminal: Terminal.new(out, input), queue: Queue.new)
38
+ actions: Actions.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
39
+ trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled)
37
40
  @client = client
38
41
  @store = store
39
42
  @snapshot = snapshot
40
43
  @switch = switch
41
44
  @rate_limits = rate_limits
42
45
  @terminal = terminal
46
+ @updater = updater
47
+ @argv = argv
48
+ @out = out
43
49
  @color = color
50
+ @trust = trust
44
51
  @renderer = Renderer.new(color: color)
45
52
  @input = input
46
53
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
@@ -63,6 +70,8 @@ module ClaudeInbox
63
70
  @last_poll = nil
64
71
  @quit = false
65
72
  @resize = false
73
+ @pane_offer = pane_offer
74
+ @modal = Dialog::PaneOffer.new if pane_offer.take
66
75
  end
67
76
 
68
77
  def run
@@ -81,6 +90,7 @@ module ClaudeInbox
81
90
  end
82
91
 
83
92
  def step(input = nil)
93
+ check_for_update unless @update_status
84
94
  drain_queue
85
95
  @logs.tick
86
96
  if @resize
@@ -122,6 +132,7 @@ module ClaudeInbox
122
132
  @error = nil
123
133
  when :error then @error = rest[0]
124
134
  when :notice then notice(rest[0])
135
+ when :update then update_checked(*rest)
125
136
  when :select then @pending_select = rest[0]
126
137
  when :attach then attach(rest[0])
127
138
  when :form_started then @modal = nil if @modal.equal?(rest[0])
@@ -129,8 +140,6 @@ module ClaudeInbox
129
140
  when :remote_started then remote_started(*rest)
130
141
  end
131
142
  end
132
- rescue ThreadError
133
- nil
134
143
  end
135
144
 
136
145
  # ----- main loop --------------------------------------------------------
@@ -206,7 +215,49 @@ module ClaudeInbox
206
215
  def status_text(now)
207
216
  return @notice[0] if @notice && now < @notice[1]
208
217
  return "⚠ #{@error}" if @error
209
- "polling…" unless @last_poll
218
+ return "polling…" unless @last_poll
219
+ "#{@update.notice} · U updates" if @update
220
+ end
221
+
222
+ # ----- updates ----------------------------------------------------------
223
+
224
+ # Once, on the first step: a stale cache means a network round trip, so
225
+ # it runs off the main thread. A laptop that is offline stays quiet
226
+ # rather than show an error at every launch; U says what went wrong.
227
+ def check_for_update
228
+ @update_status = @updater ? "checking for updates…" : "update check is off — claude-inbox update installs the latest"
229
+ return unless @updater
230
+ in_background do
231
+ @queue << [:update, @updater.check]
232
+ rescue Updater::Error => e
233
+ @queue << [:update, nil, e.message]
234
+ end
235
+ end
236
+
237
+ def update_checked(update, failure = nil)
238
+ @update = update
239
+ @snapshot.update = update&.notice
240
+ @update_status = failure ? "couldn't check for updates: #{failure}" : "up to date"
241
+ end
242
+
243
+ # The install prints on the real screen, and the relaunch replaces this
244
+ # process. A failed install relaunches this version instead, once its
245
+ # error has been read, rather than leave the shell showing.
246
+ def update_inbox
247
+ return notice(@update_status) unless @update
248
+ @terminal.release do
249
+ @out.puts @update.installing
250
+ applied = @updater.apply
251
+ @out.puts applied.output unless applied.output.empty?
252
+ @terminal.pause unless applied.success?
253
+ relaunch
254
+ end
255
+ end
256
+
257
+ def relaunch
258
+ @updater.relaunch(@argv)
259
+ rescue SystemCallError => e
260
+ @error = "relaunch failed: #{e.message}"
210
261
  end
211
262
 
212
263
  def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
@@ -225,7 +276,7 @@ module ClaudeInbox
225
276
 
226
277
  def select(selection)
227
278
  @selected = selection
228
- @peek.select(selection, selected_session)
279
+ @peek.select(selected_session)
229
280
  end
230
281
 
231
282
  def session_for(key) = @store.sessions.find { |s| s.key == key }
@@ -305,7 +356,7 @@ module ClaudeInbox
305
356
  return if @list_width && col > @list_width
306
357
  item = row_item_at(row)
307
358
  return unless item
308
- select(item.selection)
359
+ select(item)
309
360
  activate
310
361
  end
311
362
 
@@ -313,7 +364,7 @@ module ClaudeInbox
313
364
  # no item of its own; a click there resolves to the row above it.
314
365
  def row_item_at(row)
315
366
  idx = row - 1
316
- @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.kind == :row)
367
+ @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
317
368
  end
318
369
 
319
370
  def perform(action)
@@ -347,6 +398,7 @@ module ClaudeInbox
347
398
  when :stop then open_confirm(:stop)
348
399
  when :delete then open_confirm(:delete)
349
400
  when :refresh then @poller.soon
401
+ when :update then update_inbox
350
402
  when :toggle_peek then toggle_peek
351
403
  when :new_session then open_new_session
352
404
  when :remote_pairing then @modal = @listener.pairing_dialog
@@ -420,13 +472,16 @@ module ClaudeInbox
420
472
 
421
473
  # The pane in the attached session may ask for a different one: the
422
474
  # attach ends and the next starts without passing through the list. The
423
- # poller keeps running meanwhile, so the pane's snapshot stays fresh.
475
+ # poller keeps running meanwhile, so the pane's snapshot stays fresh. A
476
+ # failed attach leaves its reason on screen until a key is pressed.
424
477
  def attach(id)
425
478
  while id
426
479
  @store.acknowledge(id)
427
480
  @switch.clear
428
- from = @store.sessions.find { |s| s.key == id }&.session_id
429
- @terminal.release { @client.attach(id) { @switch.requested_for?(from) } }
481
+ from = session_for(id)&.session_id
482
+ @terminal.release do
483
+ @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
484
+ end
430
485
  id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
431
486
  end
432
487
  end
@@ -475,7 +530,8 @@ module ClaudeInbox
475
530
 
476
531
  def open_new_session
477
532
  cwd = selected_session&.cwd || Dir.pwd
478
- @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
533
+ dirs = SessionRequest.directories(@store.sessions, @trust.call)
534
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
479
535
  end
480
536
 
481
537
  # `attach:` hands the terminal over as soon as the session starts. Without
@@ -532,6 +588,7 @@ module ClaudeInbox
532
588
  when :stop then stop_session(id)
533
589
  when :delete then delete_session(id)
534
590
  when :adopt then adopt_session(id)
591
+ when :install then install_pane
535
592
  end
536
593
  when :save then save_prompt
537
594
  when :copy then @listener.copy_pairing_url
@@ -570,6 +627,13 @@ module ClaudeInbox
570
627
  end
571
628
  end
572
629
 
630
+ def install_pane
631
+ @pane_offer.install
632
+ notice("added the pane — new Claude Code sessions load it; /inbox opens it")
633
+ rescue JSON::ParserError
634
+ notice("~/.claude/settings.json is not valid JSON, so it was left alone")
635
+ end
636
+
573
637
  def delete_session(id)
574
638
  notice("deleting #{id}…")
575
639
  in_background do
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tty-box"
4
+ require_relative "text"
4
5
  require_relative "text_buffer"
5
6
 
6
7
  module ClaudeInbox
@@ -78,6 +79,45 @@ module ClaudeInbox
78
79
  end
79
80
  end
80
81
 
82
+ # The first launch without the Claude Code pane asks once whether to add
83
+ # it, and has to say what the pane is to someone who has never seen it.
84
+ class PaneOffer < Confirm
85
+ WIDTH = 60
86
+ SKETCH = [
87
+ " ┌──────────────────────────┬─────────────────────┐",
88
+ " │ > fix the flaky spec │ 📥 Inbox │",
89
+ " │ │ ● needs you docs │",
90
+ " │ Reading app.rb… │ ⠋ working api │",
91
+ " │ │ ✓ done auth │",
92
+ " └──────────────────────────┴─────────────────────┘"
93
+ ].freeze
94
+
95
+ def initialize = super(:install, nil)
96
+
97
+ def title = " ✨ The inbox, inside Claude Code "
98
+
99
+ # Drawn by hand: TTY::Box measures an emoji as one column, which
100
+ # pushes the right border out on every line that has one.
101
+ def frame(width, _caret = nil)
102
+ inner = [width - 4, WIDTH].min - 2
103
+ ["┌#{title}#{"─" * (inner - Text.width(title))}┐",
104
+ *lines(inner, nil).map { |l| "│#{Text.pad(Text.truncate(l, inner), inner)}│" },
105
+ "└#{"─" * inner}┘"]
106
+ end
107
+
108
+ def lines(_width, _caret)
109
+ ["", *SKETCH, "",
110
+ " • Every session, docked beside the transcript",
111
+ " • Switch sessions without leaving the one you're in",
112
+ " • Snooze, pin and settle with the same keys",
113
+ " • /inbox opens it, ctrl+x tab focuses it",
114
+ "",
115
+ " You can install this later with claude-inbox install",
116
+ "",
117
+ " y add it to Claude Code esc not now"]
118
+ end
119
+ end
120
+
81
121
  # `a` alias and `P` pull request: one line of text, read from `value`
82
122
  # once :save comes back. Empty means clear.
83
123
  class Prompt < Dialog
@@ -5,6 +5,7 @@ require_relative "poller"
5
5
  require_relative "pull_requests"
6
6
  require_relative "reaper"
7
7
  require_relative "snapshot"
8
+ require_relative "updater"
8
9
  require_relative "writer_lock"
9
10
 
10
11
  module ClaudeInbox
@@ -13,19 +14,34 @@ module ClaudeInbox
13
14
  # reaps, since deleting sessions stays with the inbox you can see, and it
14
15
  # exits at once while an inbox holds the writer lock.
15
16
  class Headless
16
- def initialize(client:, store:, pull_requests: PullRequests.new, snapshot: Snapshot.new, actions: Actions.new,
17
- lock: WriterLock.new, queue: Queue.new)
17
+ def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
18
+ lock: WriterLock.new, updater: nil)
18
19
  @lock = lock
19
- @queue = queue
20
+ @snapshot = snapshot
21
+ @updater = updater
22
+ @queue = Queue.new
20
23
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
21
- queue: queue, snapshot: snapshot, actions: actions)
24
+ queue: @queue, snapshot: snapshot, actions: actions)
22
25
  end
23
26
 
24
27
  # False when the lock is held, by an inbox or another headless process.
25
28
  def run
26
29
  return false unless @lock.take(role: "headless")
27
30
  @poller.start
31
+ check_for_update if @updater
28
32
  loop { @queue.pop }
29
33
  end
34
+
35
+ private
36
+
37
+ # After the poller is off, since the check may wait on the network. The
38
+ # pane cannot swap the process under Claude Code, so the snapshot only
39
+ # says a newer one is out, and a check that cannot tell says nothing.
40
+ def check_for_update
41
+ @snapshot.update = @updater.check&.notice
42
+ @poller.soon
43
+ rescue Updater::Error
44
+ nil
45
+ end
30
46
  end
31
47
  end
@@ -41,7 +41,7 @@ module ClaudeInbox
41
41
  # `index` orders the images one request brings; the random part keeps
42
42
  # two requests saving in the same millisecond apart. Readable by the
43
43
  # owner only: a photo can be anything.
44
- def self.save(bytes, dir: DEFAULT_DIR, now: Time.now, index: 1)
44
+ def self.save(bytes, dir:, index:, now: Time.now)
45
45
  data = bytes.b
46
46
  ext = extension(data)
47
47
  raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext