claude-inbox 0.5.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -3
  3. data/exe/claude-inbox +35 -1
  4. data/lib/claude_inbox/actions.rb +7 -4
  5. data/lib/claude_inbox/agents_client.rb +17 -12
  6. data/lib/claude_inbox/app.rb +64 -6
  7. data/lib/claude_inbox/dialog.rb +40 -0
  8. data/lib/claude_inbox/headless.rb +20 -3
  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 +8 -3
  16. data/lib/claude_inbox/palette.rb +1 -1
  17. data/lib/claude_inbox/paste.rb +1 -1
  18. data/lib/claude_inbox/peek.rb +5 -9
  19. data/lib/claude_inbox/poller.rb +4 -6
  20. data/lib/claude_inbox/pull_requests.rb +5 -5
  21. data/lib/claude_inbox/reaper.rb +1 -1
  22. data/lib/claude_inbox/remote/http.rb +1 -1
  23. data/lib/claude_inbox/remote/page.html +7 -4
  24. data/lib/claude_inbox/remote/pairing.rb +2 -1
  25. data/lib/claude_inbox/remote/start.rb +1 -0
  26. data/lib/claude_inbox/renderer.rb +1 -1
  27. data/lib/claude_inbox/session_request.rb +6 -4
  28. data/lib/claude_inbox/slash_commands.rb +3 -3
  29. data/lib/claude_inbox/snapshot.rb +8 -3
  30. data/lib/claude_inbox/store/entry.rb +4 -5
  31. data/lib/claude_inbox/store/sections.rb +3 -3
  32. data/lib/claude_inbox/store.rb +1 -2
  33. data/lib/claude_inbox/text_buffer.rb +4 -9
  34. data/lib/claude_inbox/theme.rb +1 -1
  35. data/lib/claude_inbox/updater.rb +163 -0
  36. data/lib/claude_inbox.rb +1 -1
  37. data/mod/hooks/register.tsx +29 -5
  38. data/mod/tests/contract.test.tsx +2 -1
  39. data/mod/tests/pane.test.tsx +63 -2
  40. data/mod/tests/snapshot.fixture.ts +4 -3
  41. data/mod/types/index.d.ts +3 -1
  42. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 150bc4c5ae639139680ad48d476e0628faa82e89e77e783fb1ac9b2ac34ab25f
4
- data.tar.gz: c40bbe7c0a955ba01edc66014b3b2ff9d8b80eb70d3b6ea4a9345495a69af011
3
+ metadata.gz: b325ed415502366088de4b6e9b7634835aea1fb0a6be708e211344dfeba8fa33
4
+ data.tar.gz: 53fbfbeac341e7f5643724911eb5feecb4165a6aaa367dad73c2a4002be9bee9
5
5
  SHA512:
6
- metadata.gz: 1168c17b14d49071580d1c8565eff838a8c5229e339de5cbbd3f90ed78274973cbe1b1b0f3d21b89c4734142029630a1cb9ea8b03932655d22d151a3447faaf8
7
- data.tar.gz: 7a6fb8224d67ae2ff94136d9615bb1a5caf12b7430031c5d221fde12a46357aef6f7ffd223f2ab725fb15d3797458ddb7f6cd824d890a01b3b525c0dbc8e3f1b
6
+ metadata.gz: 072a5ce3e44c1e5317d105fd49aea27fc5e1365c146c165e8ad87b61b50c24f0046bd659278a41b3848ec15523c1eaa255748f5f080a4e26b25476ba6f7e1218
7
+ data.tar.gz: 31715b40f83b6749d3064c323018c74f62e1176f248bcc00b3cf16769b81c202b49cb0e7228e3ca8bd3b5519c8d7ca99001c026868d29605b488430a6662f5eb
data/README.md CHANGED
@@ -18,6 +18,8 @@ gem install claude-inbox
18
18
  claude-inbox
19
19
  ```
20
20
 
21
+ `claude-inbox update` installs the latest release.
22
+
21
23
  Put arguments you want on every launch in `~/.config/claude-inbox/config`.
22
24
 
23
25
  To add the pane to every Claude Code session:
@@ -26,6 +28,7 @@ To add the pane to every Claude Code session:
26
28
  claude-inbox install
27
29
  ```
28
30
 
31
+ The inbox offers to do this once, the first time it starts without the pane.
29
32
  `claude-inbox install --force` also replaces any other copy of the pane.
30
33
 
31
34
  ## Claude Code pane
@@ -37,7 +40,7 @@ a fullscreen terminal.
37
40
 
38
41
  `/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
39
42
  ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
40
- `P`, `X`, `Ctrl-x`, `/` or `R`. `Enter`, or a click on a row, switches to the
43
+ `P`, `X`, `Ctrl-x`, `/`, `R` or `U`. `Enter`, or a click on a row, switches to the
41
44
  session when you attached through `claude-inbox`.
42
45
 
43
46
  It opens when a session starts. To stop that, set
@@ -88,6 +91,7 @@ Vim bindings. Arrows and the mouse work too.
88
91
  | `Ctrl-x` | delete the session, transcript and worktree for good |
89
92
  | `/` | filter; `Enter` keeps it, `Esc` clears |
90
93
  | `R` | poll now |
94
+ | `U` | update claude-inbox when the header says a newer one is out |
91
95
  | `q` | quit |
92
96
 
93
97
  `X` and `Ctrl-x` both ask for `y` first.
@@ -95,8 +99,9 @@ Vim bindings. Arrows and the mouse work too.
95
99
  ### New session
96
100
 
97
101
  `n` opens a form for the prompt, name, directory, model, effort, permissions,
98
- worktree and Remote Control. `Tab` moves between fields, `Ctrl-S` starts the
99
- 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.
100
105
 
101
106
  Paste an image on macOS, or drop a file into the prompt, and refer to it as
102
107
  `[Image #1]`. Type `/` for a menu of your skills.
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
 
@@ -70,14 +90,27 @@ else
70
90
  opts[:snapshot] = ClaudeInbox::Snapshot.new
71
91
  opts[:switch] = ClaudeInbox::Switch.new
72
92
  opts[:actions] = ClaudeInbox::Actions.new
93
+ opts[:pane_offer] = ClaudeInbox::Mod::Offer.new
73
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
74
98
  end
75
99
  opts[:color] = false if argv.include?("--no-color")
76
100
 
77
101
  if argv.include?("--headless")
78
102
  abort "claude-inbox: --headless takes no --fixture" if fixture
79
103
  require "claude_inbox/headless"
80
- 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}")
81
114
  end
82
115
 
83
116
  # The first inbox you can see is the snapshot's writer: a headless one gives
@@ -89,6 +122,7 @@ unless writer_lock.take(role: "inbox")
89
122
  opts[:reaper] = ClaudeInbox::Reaper.disabled
90
123
  opts[:snapshot] = ClaudeInbox::Snapshot.disabled
91
124
  opts[:actions] = ClaudeInbox::Actions.disabled
125
+ opts[:pane_offer] = ClaudeInbox::Mod::Offer.disabled
92
126
  end
93
127
  # So the pane settings.json names is the installed version's, after a gem update too.
94
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.
@@ -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
@@ -59,17 +59,23 @@ module ClaudeInbox
59
59
  watchdog&.kill
60
60
  end
61
61
 
62
- def stop(id) = run(@bin, "stop", id)
62
+ def stop(id) = run("stop", id)
63
63
 
64
- def rm(id) = run(@bin, "rm", id)
64
+ def rm(id) = run("rm", id)
65
65
 
66
66
  MODELS = %w[default fable opus sonnet haiku].freeze
67
67
  EFFORTS = %w[default low medium high xhigh max].freeze
68
68
  PERMISSION_MODES = %w[default acceptEdits auto plan bypassPermissions].freeze
69
-
70
- # Start a background session. Returns its short id.
71
- def spawn(prompt:, cwd:, **opts)
72
- 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
73
79
  end
74
80
 
75
81
  # How a prompt attaches a file: the same @ mention the CLI's own prompt
@@ -239,10 +245,9 @@ module ClaudeInbox
239
245
  nil
240
246
  end
241
247
 
242
- def run(*argv)
243
- r = Subprocess.capture(*argv)
244
- raise Error, "#{argv[1]} failed: #{r.err.strip}" unless r.success?
245
- true
248
+ def run(verb, id)
249
+ r = Subprocess.capture(@bin, verb, id)
250
+ raise Error, "#{verb} failed: #{r.err.strip}" unless r.success?
246
251
  end
247
252
 
248
253
  def start_bg(argv, cwd, what)
@@ -275,7 +280,7 @@ module ClaudeInbox
275
280
  # terminal round-trips through cooked mode and back.
276
281
  def attach(id) = system("sh", "-c", "printf 'fake attach to %s\\npress enter to detach: ' \"$1\"; read -r _", "attach", id)
277
282
 
278
- def stop(_id) = true
283
+ def stop(_id) = nil
279
284
 
280
285
  def spawn(prompt:, cwd:, **)
281
286
  sleep 0.5
@@ -284,6 +289,6 @@ module ClaudeInbox
284
289
 
285
290
  def adopt(session_id:, cwd:, pid:) = "adop7ed0"
286
291
 
287
- def rm(_id) = true
292
+ def rm(_id) = nil
288
293
  end
289
294
  end
@@ -24,6 +24,8 @@ require_relative "actions"
24
24
  require_relative "snapshot"
25
25
  require_relative "switch"
26
26
  require_relative "trust"
27
+ require_relative "mod"
28
+ require_relative "updater"
27
29
 
28
30
  module ClaudeInbox
29
31
  # Owns the terminal and the key loop. The only class allowed to spawn a
@@ -33,14 +35,17 @@ module ClaudeInbox
33
35
  # so both are off unless `bin/claude-inbox` switches them on.
34
36
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
35
37
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
36
- actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true, trust: Trust.method(:projects),
37
- 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)
38
40
  @client = client
39
41
  @store = store
40
42
  @snapshot = snapshot
41
43
  @switch = switch
42
44
  @rate_limits = rate_limits
43
45
  @terminal = terminal
46
+ @updater = updater
47
+ @argv = argv
48
+ @out = out
44
49
  @color = color
45
50
  @trust = trust
46
51
  @renderer = Renderer.new(color: color)
@@ -65,6 +70,8 @@ module ClaudeInbox
65
70
  @last_poll = nil
66
71
  @quit = false
67
72
  @resize = false
73
+ @pane_offer = pane_offer
74
+ @modal = Dialog::PaneOffer.new if pane_offer.take
68
75
  end
69
76
 
70
77
  def run
@@ -83,6 +90,7 @@ module ClaudeInbox
83
90
  end
84
91
 
85
92
  def step(input = nil)
93
+ check_for_update unless @update_status
86
94
  drain_queue
87
95
  @logs.tick
88
96
  if @resize
@@ -124,6 +132,7 @@ module ClaudeInbox
124
132
  @error = nil
125
133
  when :error then @error = rest[0]
126
134
  when :notice then notice(rest[0])
135
+ when :update then update_checked(*rest)
127
136
  when :select then @pending_select = rest[0]
128
137
  when :attach then attach(rest[0])
129
138
  when :form_started then @modal = nil if @modal.equal?(rest[0])
@@ -131,8 +140,6 @@ module ClaudeInbox
131
140
  when :remote_started then remote_started(*rest)
132
141
  end
133
142
  end
134
- rescue ThreadError
135
- nil
136
143
  end
137
144
 
138
145
  # ----- main loop --------------------------------------------------------
@@ -208,7 +215,49 @@ module ClaudeInbox
208
215
  def status_text(now)
209
216
  return @notice[0] if @notice && now < @notice[1]
210
217
  return "⚠ #{@error}" if @error
211
- "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}"
212
261
  end
213
262
 
214
263
  def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
@@ -227,7 +276,7 @@ module ClaudeInbox
227
276
 
228
277
  def select(selection)
229
278
  @selected = selection
230
- @peek.select(selection, selected_session)
279
+ @peek.select(selected_session)
231
280
  end
232
281
 
233
282
  def session_for(key) = @store.sessions.find { |s| s.key == key }
@@ -349,6 +398,7 @@ module ClaudeInbox
349
398
  when :stop then open_confirm(:stop)
350
399
  when :delete then open_confirm(:delete)
351
400
  when :refresh then @poller.soon
401
+ when :update then update_inbox
352
402
  when :toggle_peek then toggle_peek
353
403
  when :new_session then open_new_session
354
404
  when :remote_pairing then @modal = @listener.pairing_dialog
@@ -538,6 +588,7 @@ module ClaudeInbox
538
588
  when :stop then stop_session(id)
539
589
  when :delete then delete_session(id)
540
590
  when :adopt then adopt_session(id)
591
+ when :install then install_pane
541
592
  end
542
593
  when :save then save_prompt
543
594
  when :copy then @listener.copy_pairing_url
@@ -576,6 +627,13 @@ module ClaudeInbox
576
627
  end
577
628
  end
578
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
+
579
637
  def delete_session(id)
580
638
  notice("deleting #{id}…")
581
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,18 +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:, lock: WriterLock.new)
17
+ def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
18
+ lock: WriterLock.new, updater: nil)
17
19
  @lock = lock
20
+ @snapshot = snapshot
21
+ @updater = updater
18
22
  @queue = Queue.new
19
- @poller = Poller.new(client: client, store: store, pull_requests: PullRequests.new, reaper: Reaper.disabled,
20
- queue: @queue, snapshot: Snapshot.new, actions: Actions.new)
23
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
24
+ queue: @queue, snapshot: snapshot, actions: actions)
21
25
  end
22
26
 
23
27
  # False when the lock is held, by an inbox or another headless process.
24
28
  def run
25
29
  return false unless @lock.take(role: "headless")
26
30
  @poller.start
31
+ check_for_update if @updater
27
32
  loop { @queue.pop }
28
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
29
46
  end
30
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
@@ -4,7 +4,8 @@ require "json"
4
4
 
5
5
  module ClaudeInbox
6
6
  # What the daemon writes about a background session in
7
- # ~/.claude/jobs/<id>/state.json. The only reader of that file.
7
+ # ~/.claude/jobs/<id>/state.json. The only reader of that file, and the
8
+ # only writer: a new session's color.
8
9
  #
9
10
  # `claude agents --json` reports one word, `state`, and "working" covers two
10
11
  # different situations: the agent is thinking, or the agent is idle and
@@ -50,6 +51,19 @@ module ClaudeInbox
50
51
  nil
51
52
  end
52
53
 
54
+ # The job file is there by the time `claude --bg` returns, and the daemon
55
+ # updates it read-modify-write, so the color stays through work, stop and
56
+ # respawn (checked on 2.1.296). A color is cosmetic: failing to write one
57
+ # must not report a started session as failed.
58
+ # ponytail: a daemon write racing this one can drop the color; re-apply on poll if that shows up.
59
+ def self.write_color(id, color, jobs_dir:)
60
+ path = File.join(jobs_dir, id, "state.json")
61
+ File.write("#{path}.tmp", JSON.generate(JSON.parse(File.read(path)).merge("color" => color)))
62
+ File.rename("#{path}.tmp", path)
63
+ rescue JSON::ParserError, SystemCallError
64
+ nil
65
+ end
66
+
53
67
  attr_reader :pr_urls, :color, :intent, :bridge_id
54
68
 
55
69
  def initialize(hash)
@@ -59,7 +73,7 @@ module ClaudeInbox
59
73
  @tempo = hash["tempo"]
60
74
  @kinds = (hash["fan"] || []).filter_map { |f| f["kind"] }
61
75
  @tasks = (hash["inFlight"] || {})["tasks"].to_i
62
- @pr_urls = (hash["children"] || []).select { |c| c["kind"] == "pr" && c["href"] }.map { |c| c["href"] }
76
+ @pr_urls = (hash["children"] || []).filter_map { |c| c["href"] if c["kind"] == "pr" }
63
77
  @color = hash["color"]
64
78
  @intent = hash["intent"]
65
79
  @bridge_id = hash["bridgeSessionId"]
@@ -27,7 +27,7 @@ module ClaudeInbox
27
27
  "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
28
28
  :ctrl_x => :delete,
29
29
  "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
30
- "R" => :refresh, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
30
+ "R" => :refresh, "U" => :update, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
31
31
  :tab => :next_section, :back_tab => :prev_section,
32
32
  "/" => :filter, :escape => :escape,
33
33
  "q" => :quit, :ctrl_c => :quit
@@ -30,7 +30,6 @@ module ClaudeInbox
30
30
  end
31
31
 
32
32
  def want(id)
33
- return if id.nil?
34
33
  @pending = id
35
34
  @pending_at = @clock.call
36
35
  end
@@ -11,6 +11,7 @@ module ClaudeInbox
11
11
  DIR = File.expand_path("../../mod", __dir__)
12
12
  LINK = File.join(Dir.home, ".config", "claude-inbox", "mod")
13
13
  NAME = "inbox-pane"
14
+ SETTINGS = File.join(Dir.home, ".claude", "settings.json")
14
15
  CHECKOUT = File.exist?(File.expand_path("../../.git", __dir__))
15
16
  # What the pane runs headless: a checkout's own, or the gem's from PATH.
16
17
  COMMAND = CHECKOUT ? File.expand_path("../../bin/claude-inbox", __dir__) : nil
@@ -38,7 +39,7 @@ module ClaudeInbox
38
39
  # drops the other folders holding this plugin, such as a worktree's, and
39
40
  # sets the command the pane runs to this copy's. Raises JSON::ParserError
40
41
  # rather than rewrite a settings file it cannot read.
41
- def self.install(settings: File.join(Dir.home, ".claude", "settings.json"), link: LINK, dir: DIR, force: false, command: COMMAND)
42
+ def self.install(settings: SETTINGS, link: LINK, dir: DIR, force: false, command: COMMAND)
42
43
  self.link(link: link, dir: dir, force: true)
43
44
  json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
44
45
  env = json["env"] ||= {}
@@ -61,6 +62,40 @@ module ClaudeInbox
61
62
  installed
62
63
  end
63
64
 
65
+ # Whether any folder in CLAUDE_CODE_PLUGIN_DIRS holds the pane, a
66
+ # worktree's included. A settings file it cannot read counts as yes, since
67
+ # install would refuse to touch it.
68
+ def self.installed?(settings: SETTINGS)
69
+ json = File.exist?(settings) ? JSON.parse(File.read(settings)) : {}
70
+ json.dig("env", "CLAUDE_CODE_PLUGIN_DIRS").to_s.split(File::PATH_SEPARATOR).any? { |d| plugin?(d.strip) }
71
+ rescue JSON::ParserError, Errno::EACCES
72
+ true
73
+ end
74
+
75
+ # Asks once, on the first launch that finds the pane missing, whether to
76
+ # add it. Whatever the answer, a marker file keeps it from asking again.
77
+ class Offer
78
+ MARKER = File.join(Dir.home, ".config", "claude-inbox", "pane-offered")
79
+
80
+ def self.disabled = new(marker: nil)
81
+
82
+ def initialize(marker: MARKER, settings: SETTINGS, link: LINK)
83
+ @marker = marker
84
+ @settings = settings
85
+ @link = link
86
+ end
87
+
88
+ # True once: the first time it is asked while the pane is missing.
89
+ def take
90
+ return false if @marker.nil? || File.exist?(@marker) || Mod.installed?(settings: @settings)
91
+ FileUtils.mkdir_p(File.dirname(@marker))
92
+ FileUtils.touch(@marker)
93
+ true
94
+ end
95
+
96
+ def install = Mod.install(settings: @settings, link: @link)
97
+ end
98
+
64
99
  def self.plugin?(folder)
65
100
  JSON.parse(File.read(File.join(folder, ".claude-plugin", "plugin.json")))["name"] == NAME
66
101
  rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES, JSON::ParserError
@@ -14,7 +14,7 @@ module ClaudeInbox
14
14
  # "esc gg" glues onto one read (see App#split_keys), so this scans
15
15
  # rather than matching once.
16
16
  def self.events(raw)
17
- raw.to_s.scan(SEQUENCE).filter_map do |cb, col, row, type|
17
+ raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
18
18
  kind = kind_for(cb.to_i, type)
19
19
  Event.new(kind, row.to_i, col.to_i) if kind
20
20
  end
@@ -3,6 +3,7 @@
3
3
  require "pastel"
4
4
  require_relative "agents_client"
5
5
  require_relative "images"
6
+ require_relative "palette"
6
7
  require_relative "session_request"
7
8
  require_relative "settings"
8
9
  require_relative "slash_commands"
@@ -37,19 +38,21 @@ module ClaudeInbox
37
38
  def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
38
39
  @p = Pastel.new(enabled: color)
39
40
  @theme = Theme.new(enabled: color)
41
+ @palette = Palette.new(enabled: color)
40
42
  @home = home
41
43
  @clipboard = clipboard
42
44
  @places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
43
- @dir_settled = cwd.to_s
45
+ @dir_settled = cwd
44
46
  @fields = [
45
47
  Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
46
48
  Field.new(:name, "Name", :text, TextBuffer.new),
47
- Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd.to_s)),
49
+ Field.new(:cwd, "Directory", :text, TextBuffer.new(cwd)),
48
50
  Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
49
51
  Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
50
52
  Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
51
53
  Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
52
- Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
54
+ Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"]),
55
+ Field.new(:color, "Color", :choice, DEFAULT, AgentsClient::COLORS)
53
56
  ]
54
57
  @focus = 0
55
58
  @error = nil
@@ -320,6 +323,7 @@ module ClaudeInbox
320
323
  f.choices.map { |c|
321
324
  text = (c == DEFAULT) ? default_text(f) : c
322
325
  if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
326
+ elsif f.key == :color then @palette.paint(" #{text} ", c)
323
327
  else @p.dim(" #{text} ")
324
328
  end
325
329
  }.join(" ")
@@ -327,6 +331,7 @@ module ClaudeInbox
327
331
  end
328
332
 
329
333
  def default_text(f)
334
+ return "none" if f.key == :color
330
335
  resolved = defaults[f.key]
331
336
  resolved ? "#{resolved} (settings)" : "auto (cli default)"
332
337
  end
@@ -22,7 +22,7 @@ module ClaudeInbox
22
22
 
23
23
  RESET = "\e[39m"
24
24
 
25
- def initialize(enabled: true)
25
+ def initialize(enabled:)
26
26
  @enabled = enabled
27
27
  end
28
28