claude-inbox 0.7.1 → 0.9.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -8
  3. data/lib/claude_inbox/actions.rb +10 -2
  4. data/lib/claude_inbox/agents_client.rb +1 -1
  5. data/lib/claude_inbox/app/browsing.rb +35 -4
  6. data/lib/claude_inbox/app/triaging.rb +5 -0
  7. data/lib/claude_inbox/app.rb +10 -3
  8. data/lib/claude_inbox/cli.rb +7 -2
  9. data/lib/claude_inbox/clipboard.rb +35 -0
  10. data/lib/claude_inbox/config.rb +1 -1
  11. data/lib/claude_inbox/dialog.rb +45 -5
  12. data/lib/claude_inbox/headless.rb +3 -2
  13. data/lib/claude_inbox/images.rb +4 -1
  14. data/lib/claude_inbox/keymap.rb +1 -1
  15. data/lib/claude_inbox/mouse.rb +5 -5
  16. data/lib/claude_inbox/new_session_form.rb +1 -1
  17. data/lib/claude_inbox/notifier.rb +85 -0
  18. data/lib/claude_inbox/notifier_app.rb +82 -0
  19. data/lib/claude_inbox/poller.rb +14 -6
  20. data/lib/claude_inbox/pull_requests.rb +43 -6
  21. data/lib/claude_inbox/remote/icon.icns +0 -0
  22. data/lib/claude_inbox/renderer.rb +45 -15
  23. data/lib/claude_inbox/session.rb +14 -1
  24. data/lib/claude_inbox/snapshot.rb +1 -1
  25. data/lib/claude_inbox/snooze_time.rb +69 -0
  26. data/lib/claude_inbox/store/entry.rb +20 -0
  27. data/lib/claude_inbox/store/row.rb +22 -1
  28. data/lib/claude_inbox/store/sections.rb +0 -4
  29. data/lib/claude_inbox/store.rb +1 -1
  30. data/lib/claude_inbox/subprocess.rb +17 -2
  31. data/lib/claude_inbox/terminal.rb +0 -3
  32. data/lib/claude_inbox.rb +1 -1
  33. data/mod/hooks/register.tsx +23 -10
  34. data/mod/tests/contract.test.tsx +1 -0
  35. data/mod/tests/pane.test.tsx +27 -1
  36. data/mod/tests/snapshot.fixture.ts +4 -2
  37. data/mod/types/index.d.ts +1 -0
  38. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 389bed9e67e4ee7dbcadbd64b1b70e8ca1118e54d2f06dae2d23e53d4acdf5cb
4
- data.tar.gz: c3de7db6f528d68cff26d6b55dbae00f34cd5289491ad3b25bbc992f49b1b7b3
3
+ metadata.gz: 6194693f8175ae0767d58a3f9f90e746122f831a33675c2928e2120ccba4e895
4
+ data.tar.gz: d1f08c4e9341d115a7775fff9702fef6342b3771b9a76e3bc0fd957af79d04ef
5
5
  SHA512:
6
- metadata.gz: c619164c4ae1ec60af3f2b86b35f97d7b85ae81983ee807a4654a6eb38cba557956788bae49069368e93dff8a38a158c665be47d44970f3fdb21075958e4fd59
7
- data.tar.gz: 47ee2bce01e277e185329a6770627176f1f046733d005aa31df952250a12eeb1816bc0878ae3fb898f4c87e9cdac7ebb8fa75f3038008702cffbff47ba6c34ea
6
+ metadata.gz: 721c56943e7dbf8155b99c32befb179b471b5f1971ce7031fa9e0d33e747c083fa6a1dfd2441f5d72c6bebc56897ac342bc783abaa36b34bc2eff5aee0ebd7ca
7
+ data.tar.gz: 7424b14a131fdfd6ff488e2cef38781bbb046b375d847d094ada5be48f6789db5b9135335a3d6592374028f2c92a506235e10e8adfe91fb53cfe4f2d5c6c4fff
data/README.md CHANGED
@@ -40,7 +40,7 @@ a fullscreen terminal.
40
40
 
41
41
  `/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
42
42
  ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
43
- `P`, `X`, `Ctrl-x`, `/`, `R` or `U`. `Enter`, or a click on a row, switches to the
43
+ `P`, `X`, `Ctrl-x`, `/`, `R` or `U`, and `s` has no `5`. `Enter`, or a click on a row, switches to the
44
44
  session when you attached through `claude-inbox`.
45
45
 
46
46
  It opens when a session starts. To stop that, set
@@ -50,7 +50,8 @@ It opens when a session starts. To stop that, set
50
50
  ## Sections
51
51
 
52
52
  1. **Pinned.** Rows you pinned with `t`.
53
- 2. **Needs you.** Blocked or failed sessions you haven't attached to yet.
53
+ 2. **Needs you.** Blocked or failed sessions, or ones whose pull request has
54
+ failing checks or changes requested, that you haven't attached to yet.
54
55
  3. **Active.** Everything else that's running or not yet settled, including
55
56
  interactive terminals.
56
57
  4. **Snoozed.** Sorted by wake time. Collapsed.
@@ -59,8 +60,12 @@ It opens when a session starts. To stop that, set
59
60
 
60
61
  Under each row is what the session is waiting on, what it produced, or its
61
62
  status line. A row with a pull request shows it as `#885 open`, `#885 merged`
62
- and so on. A steady `◌` with `idle · 1 shell` means the agent stopped and is
63
- waiting on a shell or sub-agent it started.
63
+ and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
64
+ something is wrong, and `+2` after it when the session has two more.
65
+ A steady `◌` with `idle · 1 shell` means the agent stopped
66
+ and is waiting on a shell or sub-agent it started. A `●` marks a session you
67
+ haven't attached to since it got blocked, in red, or finished, in blue; one
68
+ you have attached to since is dimmed, unless `/color` tints it.
64
69
 
65
70
  ## Keys
66
71
 
@@ -78,12 +83,13 @@ Vim bindings. Arrows and the mouse work too.
78
83
  | `n` | new session |
79
84
  | `N` | pair a phone |
80
85
  | `t` | pin / unpin |
81
- | `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken |
86
+ | `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken · `5` custom |
82
87
  | `u` | wake a snoozed session, or bring back a settled one |
83
88
  | `x` | settle by hand |
84
89
  | `a` | set a local alias |
85
- | `o` | open the pull request in the browser |
90
+ | `o` | open the pull request in the browser; with several, `o` again opens the one that matters, `1`… another |
86
91
  | `w` | open the session at claude.ai/code |
92
+ | `y` | copy the pull request url, or the session's address; with several, `y` again copies the one that matters, `1`… another |
87
93
  | `P` | link a pull request by hand (empty clears) |
88
94
  | `X` | stop the session; `Enter` resumes it later |
89
95
  | `Ctrl-x` | delete the session, transcript and worktree for good |
@@ -112,8 +118,8 @@ lists them all.
112
118
  A snoozed session wakes when its timer runs out, when you press `u`, or when
113
119
  it becomes blocked or failed.
114
120
 
115
- Attaching to a needs-you session moves it to Active until its state changes
116
- again.
121
+ Attaching to a needs-you session moves it to Active until its state, or its
122
+ pull request's health, changes again.
117
123
 
118
124
  A session settles when you press `x` or when all its pull requests are merged
119
125
  or closed. A session without a pull request only settles by hand.
@@ -149,6 +155,13 @@ covers ssh, the JSON API and an iOS Shortcut.
149
155
  Pull requests show up when a session's transcript links to them. Install
150
156
  `gh` to keep their state current. Use `P` to link one by hand.
151
157
 
158
+ ## Notifications
159
+
160
+ `--notify` sends a macOS notification through
161
+ [terminal-notifier](https://github.com/julienXX/terminal-notifier) when a
162
+ session needs you or finishes. `brew install terminal-notifier` first;
163
+ the first launch asks permission to notify.
164
+
152
165
  ## Usage bars
153
166
 
154
167
  The header can show your 5-hour and 7-day rate limit usage:
@@ -74,6 +74,13 @@ module ClaudeInbox
74
74
  at.positive? && Time.at(at) >= @clock.call - GRACE
75
75
  end
76
76
 
77
+ # A preset by name, or the wake time a typed snooze became, which the
78
+ # relay sends as the epoch's digits.
79
+ def snooze_choice(choice)
80
+ return choice.to_sym if SNOOZES.include?(choice)
81
+ choice.to_i if choice.is_a?(String) && choice.match?(/\A\d+\z/)
82
+ end
83
+
77
84
  def apply(store, request, keys)
78
85
  id = request["id"]
79
86
  return false unless keys.include?(id)
@@ -83,8 +90,9 @@ module ClaudeInbox
83
90
  when "pin" then store.toggle_pin(id)
84
91
  when "acknowledge" then store.acknowledge(id)
85
92
  when "snooze"
86
- return false unless SNOOZES.include?(request["choice"])
87
- store.snooze(id, request["choice"].to_sym)
93
+ choice = snooze_choice(request["choice"])
94
+ return false unless choice
95
+ store.snooze(id, choice)
88
96
  else
89
97
  return false
90
98
  end
@@ -179,7 +179,7 @@ module ClaudeInbox
179
179
  # merely flagged: attach lands on the session that asked for it, so there
180
180
  # is nothing useful to show or act on directly.
181
181
  def classify_origins(sessions)
182
- pids = sessions.select { |s| s.interactive? && s.pid }.map(&:pid)
182
+ pids = sessions.filter_map { |s| s.pid if s.interactive? }
183
183
  return sessions if pids.empty?
184
184
  rows = ps_rows(pids)
185
185
  assign_origins(sessions, origins_from(rows), self.class.bridge_ids(rows))
@@ -3,14 +3,17 @@
3
3
  module ClaudeInbox
4
4
  class App
5
5
  # o opens the selected session's pull request and w its claude.ai/code
6
- # page, through whatever opens URLs on this platform.
6
+ # page, through whatever opens URLs on this platform; y copies the one
7
+ # of those the session has, or its id. With several PRs, o and y ask
8
+ # which first.
7
9
  module Browsing
8
10
  private
9
11
 
10
12
  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)
13
+ session = selected_session
14
+ return notice("no pull request linked — P sets one") unless session&.pr
15
+ return @modal = Dialog::PrPicker.new(:open, session) if session.prs.size > 1
16
+ open_in_browser(session.pr.url, session.pr.short)
14
17
  end
15
18
 
16
19
  def open_remote
@@ -19,6 +22,34 @@ module ClaudeInbox
19
22
  open_in_browser(url, "claude.ai/code")
20
23
  end
21
24
 
25
+ def yank
26
+ session = selected_session
27
+ return notice("nothing to copy") unless session
28
+ return @modal = Dialog::PrPicker.new(:copy, session) if session.prs.size > 1
29
+ copy_to_clipboard(*yank_target(session))
30
+ end
31
+
32
+ def pr_picked
33
+ kind, pr = @modal.kind, @modal.choice
34
+ @modal = nil
35
+ (kind == :open) ? open_in_browser(pr.url, pr.short) : copy_to_clipboard(pr.url, pr.short)
36
+ end
37
+
38
+ def copy_to_clipboard(text, what)
39
+ in_background do
40
+ @queue << [:notice, @clipboard.call(text) ? "copied #{what}" : "no clipboard tool found"]
41
+ end
42
+ end
43
+
44
+ # A link set by hand wins over the scan, as it does once the poll lands.
45
+ def yank_target(session)
46
+ linked = @store.pr_for(session.key)
47
+ return [linked, "#" + linked[%r{/pull/(\d+)}, 1]] if linked
48
+ return [session.pr.url, session.pr.short] if session.pr
49
+ return [session.remote_url, session.remote_url] if session.remote_url
50
+ [session.key, session.key]
51
+ end
52
+
22
53
  def open_in_browser(url, what)
23
54
  opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
24
55
  notice("opening #{what}")
@@ -43,6 +43,7 @@ module ClaudeInbox
43
43
  end
44
44
 
45
45
  def snooze_chosen
46
+ return @modal = Dialog::Prompt.new(:snooze, @modal.id, "") if @modal.choice == :custom
46
47
  @store.snooze(@modal.id, @modal.choice)
47
48
  @modal = nil
48
49
  end
@@ -63,6 +64,10 @@ module ClaudeInbox
63
64
  value = @modal.value.strip
64
65
  if @modal.kind == :alias
65
66
  @store.set_alias(@modal.id, value)
67
+ elsif @modal.kind == :snooze
68
+ wake_at = SnoozeTime.parse(value, Time.now)
69
+ return notice("try 45m, 2h, 3d, 17:00, 5pm, fri, tomorrow or tonight") unless wake_at
70
+ @store.snooze(@modal.id, wake_at)
66
71
  elsif value.empty? || PullRequests.valid_url?(value)
67
72
  @store.set_pr(@modal.id, value)
68
73
  @poller.soon
@@ -13,11 +13,14 @@ require_relative "remote/listener"
13
13
  require_relative "new_session_form"
14
14
  require_relative "pull_requests"
15
15
  require_relative "reaper"
16
+ require_relative "notifier"
16
17
  require_relative "poller"
17
18
  require_relative "rate_limits"
18
19
  require_relative "session_request"
19
20
  require_relative "actions"
21
+ require_relative "clipboard"
20
22
  require_relative "snapshot"
23
+ require_relative "snooze_time"
21
24
  require_relative "switch"
22
25
  require_relative "trust"
23
26
  require_relative "mod"
@@ -46,9 +49,11 @@ module ClaudeInbox
46
49
  # so both are off unless `bin/claude-inbox` switches them on.
47
50
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
48
51
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
49
- actions: Actions.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
50
- trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled)
52
+ actions: Actions.disabled, notifier: Notifier.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
53
+ trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled,
54
+ clipboard: Clipboard.method(:copy))
51
55
  @client = client
56
+ @clipboard = clipboard
52
57
  @store = store
53
58
  @snapshot = snapshot
54
59
  @switch = switch
@@ -63,7 +68,7 @@ module ClaudeInbox
63
68
  @input = Input.new(input, out)
64
69
  @queue = queue
65
70
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
66
- reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions)
71
+ reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
67
72
  @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
68
73
  @selected = nil
69
74
  @row_items = []
@@ -193,6 +198,7 @@ module ClaudeInbox
193
198
  case @modal.press(name, key)
194
199
  when :cancel then @modal = nil
195
200
  when :snooze then snooze_chosen
201
+ when :pick then pr_picked
196
202
  when :confirm
197
203
  kind, id = @modal.kind, @modal.id
198
204
  @modal = nil
@@ -233,6 +239,7 @@ module ClaudeInbox
233
239
  when :link_pr then open_pr_editor
234
240
  when :open_pr then open_pr
235
241
  when :open_remote then open_remote
242
+ when :yank then yank
236
243
  when :stop then open_confirm(:stop)
237
244
  when :delete then open_confirm(:delete)
238
245
  when :refresh then @poller.soon
@@ -6,6 +6,7 @@ require_relative "app"
6
6
  require_relative "config"
7
7
  require_relative "headless"
8
8
  require_relative "mod"
9
+ require_relative "notifier"
9
10
  require_relative "updater"
10
11
  require_relative "writer_lock"
11
12
 
@@ -56,9 +57,10 @@ module ClaudeInbox
56
57
  def app_options
57
58
  opts = options[:fixture] ? fixture_options : live_options
58
59
  opts[:color] = options[:color]
60
+ opts[:notifier] = notifier
59
61
  return opts if options[:fixture] || writer_lock.take(role: "inbox")
60
62
  opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
61
- snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled)
63
+ snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled, notifier: Notifier.disabled)
62
64
  end
63
65
 
64
66
  private
@@ -111,7 +113,7 @@ module ClaudeInbox
111
113
 
112
114
  def headless
113
115
  abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
114
- Headless.new(client: AgentsClient.new, store: Store.new, updater: updater).run
116
+ Headless.new(client: AgentsClient.new, store: Store.new, updater: updater, notifier: notifier).run
115
117
  end
116
118
 
117
119
  # Only what a check within the day already found: a cold launch never waits
@@ -154,6 +156,8 @@ module ClaudeInbox
154
156
  @updater ||= Updater.new if options[:update_check] && !options[:fixture]
155
157
  end
156
158
 
159
+ def notifier = options[:notify] ? Notifier.new : Notifier.disabled
160
+
157
161
  # Held for the life of this process by this reference.
158
162
  def writer_lock = (@lock ||= WriterLock.new)
159
163
 
@@ -172,6 +176,7 @@ module ClaudeInbox
172
176
  p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
173
177
  p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
174
178
  p.on("--no-color", "Draw without color") { opts[:color] = false }
179
+ p.on("--notify", "Send a desktop notification when a session needs you or finishes") { opts[:notify] = true }
175
180
  p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
176
181
  p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
177
182
  # --listen-lan wins over --listen, whichever came last.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subprocess"
4
+
5
+ module ClaudeInbox
6
+ # Puts text on the system clipboard through the tool this platform has:
7
+ # pbcopy on macOS, wl-copy or xclip elsewhere.
8
+ module Clipboard
9
+ TOOLS = {"pbcopy" => [], "wl-copy" => [], "xclip" => %w[-selection clipboard]}.freeze
10
+
11
+ class Failed < StandardError; end
12
+
13
+ module_function
14
+
15
+ # True once a tool took the text, false when none is on PATH.
16
+ def copy(text, platform: RUBY_PLATFORM, path: ENV.fetch("PATH", ""), run: Subprocess.method(:capture))
17
+ argv = command(platform: platform, path: path)
18
+ return false unless argv
19
+ result = run.call(*argv, input: text)
20
+ raise Failed, "#{File.basename(argv.first)} failed: #{result.err.strip}" unless result.success?
21
+ true
22
+ end
23
+
24
+ # The argv that writes the clipboard here, or nil without a tool on PATH.
25
+ def command(platform: RUBY_PLATFORM, path: ENV.fetch("PATH", ""))
26
+ names = platform.include?("darwin") ? %w[pbcopy] : %w[wl-copy xclip]
27
+ dirs = path.split(File::PATH_SEPARATOR)
28
+ names.each do |name|
29
+ dir = dirs.find { |d| File.executable?(File.join(d, name)) }
30
+ return [File.join(dir, name), *TOOLS[name]] if dir
31
+ end
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -8,7 +8,7 @@ module ClaudeInbox
8
8
  module Config
9
9
  PATH = File.join(Dir.home, ".config", "claude-inbox", "config")
10
10
 
11
- def self.argv(typed, path: PATH)
11
+ def self.argv(typed, path:)
12
12
  args(path) + typed
13
13
  end
14
14
 
@@ -32,7 +32,8 @@ module ClaudeInbox
32
32
  ["1", "15 minutes", :m15],
33
33
  ["2", "1 hour", :h1],
34
34
  ["3", "tomorrow 9am", :tomorrow_9am],
35
- ["4", "until I wake it", :until_woken]
35
+ ["4", "until I wake it", :until_woken],
36
+ ["5", "custom", :custom]
36
37
  ].freeze
37
38
 
38
39
  attr_reader :choice
@@ -52,6 +53,44 @@ module ClaudeInbox
52
53
  end
53
54
  end
54
55
 
56
+ # `o` open and `y` copy on a session with several PRs: the headline
57
+ # first, and the same key again takes it. The PR is read from `choice`.
58
+ class PrPicker < Dialog
59
+ WIDTH = 64
60
+ KEYS = {open: "o", copy: "y"}.freeze
61
+ VERBS = {open: "open", copy: "copy"}.freeze
62
+
63
+ attr_reader :choice
64
+
65
+ def initialize(kind, session)
66
+ super(kind, session.key)
67
+ @prs = [session.pr, *session.prs.reject { |pr| pr.equal?(session.pr) }].first(9)
68
+ end
69
+
70
+ def title = " #{VERBS.fetch(kind).capitalize} pull request "
71
+
72
+ def lines(width, _caret)
73
+ (@prs.each_with_index.map { |pr, i| " #{i + 1} #{label(pr)}" } +
74
+ ["", " #{KEYS.fetch(kind)} #{VERBS.fetch(kind)} #{@prs.first.short} · esc cancel"])
75
+ .map { |line| Text.truncate(line, width) }
76
+ end
77
+
78
+ def press(name, key)
79
+ return :cancel if name == :escape || key == "q"
80
+ @choice = @prs.first if key == KEYS.fetch(kind) || name == :return || name == :enter
81
+ @choice = @prs[key.to_i - 1] if key.to_s.match?(/\A[1-9]\z/)
82
+ @choice && :pick
83
+ end
84
+
85
+ private
86
+
87
+ # A bare #190 is ambiguous only once the list spans repos.
88
+ def label(pr)
89
+ name = (@prs.map(&:repo).uniq.size > 1) ? pr.qualified : pr.short
90
+ ["#{name} #{pr.state&.downcase}".strip, pr.trouble, pr.title].compact.join(" · ")
91
+ end
92
+ end
93
+
55
94
  # `X` stop, `Ctrl-x` delete and Enter on a remote row all ask first and
56
95
  # all take `y`, but the words differ because the outcomes do: read the
57
96
  # box before answering.
@@ -118,11 +157,12 @@ module ClaudeInbox
118
157
  end
119
158
  end
120
159
 
121
- # `a` alias and `P` pull request: one line of text, read from `value`
122
- # once :save comes back. Empty means clear.
160
+ # `a` alias, `P` pull request and `s` `5` snooze: one line of text, read
161
+ # from `value` once :save comes back. Empty means clear.
123
162
  class Prompt < Dialog
124
- TITLES = {alias: " Alias ", pr: " Pull request "}.freeze
125
- QUESTIONS = {alias: " New alias:", pr: " Pull request URL (empty clears):"}.freeze
163
+ TITLES = {alias: " Alias ", pr: " Pull request ", snooze: " Snooze "}.freeze
164
+ QUESTIONS = {alias: " New alias:", pr: " Pull request URL (empty clears):",
165
+ snooze: " Wake at (2h, 5pm, fri, tonight):"}.freeze
126
166
 
127
167
  def initialize(kind, id, value)
128
168
  super(kind, id)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "actions"
4
+ require_relative "notifier"
4
5
  require_relative "poller"
5
6
  require_relative "pull_requests"
6
7
  require_relative "reaper"
@@ -15,13 +16,13 @@ module ClaudeInbox
15
16
  # exits at once while an inbox holds the writer lock.
16
17
  class Headless
17
18
  def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
18
- lock: WriterLock.new, updater: nil)
19
+ lock: WriterLock.new, updater: nil, notifier: Notifier.disabled)
19
20
  @lock = lock
20
21
  @snapshot = snapshot
21
22
  @updater = updater
22
23
  @queue = Queue.new
23
24
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
24
- queue: @queue, snapshot: snapshot, actions: actions)
25
+ queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
25
26
  end
26
27
 
27
28
  # False when the lock is held, by an inbox or another headless process.
@@ -53,7 +53,10 @@ module ClaudeInbox
53
53
  end
54
54
 
55
55
  # ".png", ".jpg", ".gif" or ".webp" by the magic number, or nil.
56
- def self.extension(bytes) = MAGIC.find { |_, magic| magic.match?(bytes.b) }&.first
56
+ def self.extension(bytes)
57
+ data = bytes.b
58
+ MAGIC.find { |_, magic| magic.match?(data) }&.first
59
+ end
57
60
 
58
61
  CLIPBOARD_PNG = <<~APPLESCRIPT
59
62
  on run argv
@@ -24,7 +24,7 @@ module ClaudeInbox
24
24
  "h" => :fold_close,
25
25
  "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
26
26
  :ctrl_x => :delete,
27
- "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
27
+ "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote, "y" => :yank,
28
28
  "R" => :refresh, "U" => :update, "n" => :new_session, "N" => :remote_pairing,
29
29
  :tab => :next_section, :back_tab => :prev_section,
30
30
  "/" => :filter, :escape => :escape,
@@ -3,20 +3,20 @@
3
3
  module ClaudeInbox
4
4
  # Parses SGR mouse reports ("\e[<Cb;Cx;Cy(M|m)", emitted once App turns on
5
5
  # \e[?1000h\e[?1006h) into clicks and wheel ticks. Pure: no terminal, no
6
- # IO. Row/col are the terminal's own 1-based coordinates, so callers can
7
- # index straight into what they just painted.
6
+ # IO. Row is the terminal's own 1-based row, so callers can index straight
7
+ # into what they just painted.
8
8
  module Mouse
9
9
  SEQUENCE = /\e\[<(\d+);(\d+);(\d+)([Mm])/
10
10
 
11
- Event = Struct.new(:kind, :row, :col)
11
+ Event = Struct.new(:kind, :row)
12
12
 
13
13
  # A raw keypress can glue several reports together the same way a fast
14
14
  # "esc gg" glues onto one read (see Input#split_keys), so this scans
15
15
  # rather than matching once.
16
16
  def self.events(raw)
17
- raw.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
- Event.new(kind, row.to_i, col.to_i) if kind
19
+ Event.new(kind, row.to_i) if kind
20
20
  end
21
21
  end
22
22
 
@@ -303,7 +303,7 @@ module ClaudeInbox
303
303
 
304
304
  def field_label(f)
305
305
  on = f.equal?(focused)
306
- (on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
306
+ (on ? @theme.yellow_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
307
307
  end
308
308
 
309
309
  # The cell the cursor sits on, drawn as a block by inverting it: a bar
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "notifier_app"
4
+ require_relative "subprocess"
5
+
6
+ module ClaudeInbox
7
+ # Desktop notifications for --notify: a row that came to need you or a
8
+ # background session that finished between one poll and the next, said
9
+ # through terminal-notifier. `diff` decides, `deliver` says it.
10
+ class Notifier
11
+ MISSING = "terminal-notifier not found"
12
+ # The sections a row is heard from: not snoozed, not settled.
13
+ WATCHED = %i[pinned needs_you active].freeze
14
+
15
+ # Marks the one that wants you, so it stands out from a finished one.
16
+ BADGE = {"needs you" => "🔴 "}.freeze
17
+
18
+ Notification = Data.define(:key, :kind, :label, :line) do
19
+ def message = "#{BADGE[kind]}#{line ? "#{kind}: #{line}" : kind}"
20
+ end
21
+
22
+ def self.disabled = new(enabled: false)
23
+
24
+ # The notifications `after` earns over `before`, two Sections of the same
25
+ # moment: one per row whose alert changed, with the row's own line. A row
26
+ # that only changed section has nothing new to say: the first gh refresh
27
+ # un-settles one whose open PR the cache did not know, and a snoozed
28
+ # row's timer runs out.
29
+ def self.diff(before, after)
30
+ was = alerts(before)
31
+ rows(after).filter_map do |row|
32
+ kind = alert(row)
33
+ Notification.new(key: row.key, kind: kind, label: row.label, line: row.session.summary) if kind && was[row.key] != kind
34
+ end
35
+ end
36
+
37
+ # key => "needs you", "finished" or nil, over every row, snoozed and settled too.
38
+ def self.alerts(sections) = sections.all.to_h { |row| [row.key, alert(row)] }
39
+
40
+ def self.rows(sections) = WATCHED.flat_map { |name| sections[name] }
41
+
42
+ # Interactive sessions stay quiet: you are in that terminal.
43
+ def self.alert(row)
44
+ return nil unless row.session.background?
45
+ if row.session.needs_you? then "needs you" unless row.acknowledged?
46
+ elsif row.session.effective_state == "done" then "finished"
47
+ end
48
+ end
49
+
50
+ def initialize(app: NotifierApp.new, run: Subprocess.method(:capture), enabled: true)
51
+ @app = app
52
+ @run = run
53
+ @enabled = enabled
54
+ @primed = false
55
+ @given_up = false
56
+ end
57
+
58
+ # Sends what `diff` finds, off the calling thread, and yields the one
59
+ # notice a binary that will not run earns. The first call only primes,
60
+ # and readies the app: every row already blocked at startup would
61
+ # otherwise fire at once.
62
+ def deliver(before, after, &notice)
63
+ return unless @enabled
64
+ return prime unless @primed
65
+ notifications = self.class.diff(before, after)
66
+ return if notifications.empty? || @given_up
67
+ Thread.new do
68
+ notifications.each do |n|
69
+ r = @run.call(*@app.command, "-title", n.label, "-message", n.message, "-group", n.key)
70
+ next if r.success?
71
+ @given_up = true
72
+ notice&.call((r.status.exitstatus == 127) ? MISSING : "terminal-notifier: #{r.err.lines.first.to_s.strip}")
73
+ break
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def prime
81
+ @primed = true
82
+ Thread.new { @app.prepare }
83
+ end
84
+ end
85
+ end