claude-inbox 0.7.0 → 0.8.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 +19 -7
  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 +20 -1
  6. data/lib/claude_inbox/app/navigating.rb +5 -1
  7. data/lib/claude_inbox/app/triaging.rb +5 -0
  8. data/lib/claude_inbox/app.rb +9 -3
  9. data/lib/claude_inbox/cli.rb +7 -2
  10. data/lib/claude_inbox/clipboard.rb +35 -0
  11. data/lib/claude_inbox/config.rb +1 -1
  12. data/lib/claude_inbox/dialog.rb +7 -5
  13. data/lib/claude_inbox/headless.rb +3 -2
  14. data/lib/claude_inbox/images.rb +4 -1
  15. data/lib/claude_inbox/keymap.rb +1 -1
  16. data/lib/claude_inbox/mouse.rb +5 -5
  17. data/lib/claude_inbox/new_session_form.rb +1 -1
  18. data/lib/claude_inbox/notifier.rb +85 -0
  19. data/lib/claude_inbox/notifier_app.rb +82 -0
  20. data/lib/claude_inbox/poller.rb +14 -6
  21. data/lib/claude_inbox/pull_requests.rb +29 -4
  22. data/lib/claude_inbox/remote/icon.icns +0 -0
  23. data/lib/claude_inbox/renderer.rb +43 -15
  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 +19 -5
  34. data/mod/tests/contract.test.tsx +1 -0
  35. data/mod/tests/pane.test.tsx +5 -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: b432b1272fd3e3b923eaefe34fa4e3c30cbd9dfcf57d4beacd761f1cef70785b
4
- data.tar.gz: '090ff2d41f9701cc7476196af19e4604241b3b3079376a896b441ab9dbf0ce9d'
3
+ metadata.gz: 5f240b8de798a2fea0c318d9bd7882e2d05cf7bdcbdc48fea7698f2ac8e6dc82
4
+ data.tar.gz: a94912ebe0d438922d9f5bdc334e0be3465e7997fb9767bc1e2a6ef9f7ed55b4
5
5
  SHA512:
6
- metadata.gz: a075eee59befc0de0e667d5b4325db8086a457040d544534f75b62d8a05b91a5e703b370bd1f3ce52fe89638cefa93403b1d6acd7170f143a86111a9d4c75277
7
- data.tar.gz: 1d3dbb6ead15764e91834af2de5b8cfa9873493830e0cf2cee6080a4884fe3d74476b2a260b47565f6e70c99f5611d9c57bb422950435b80cc90b12eb55bc859
6
+ metadata.gz: da9407631ff4ec1f734e9e69baf306cace5042f50e473a9d98487629c59109df9bda5e511d15db5db575cebb9e7b95bf02bcc504b8a617a43602e7421a1f1418
7
+ data.tar.gz: 5d79b77a64ffde8d41933bfaa7946ee550b791f55b7131c6a7057a1caab476164591eb7f2baeaf5285e96f9a7a84b9f30aeeac891196b6d7baa8ac3bafe25b54
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`, `U` or `y`, 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,11 @@ 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. A steady `◌` with `idle · 1 shell` means the agent stopped
65
+ and is waiting on a shell or sub-agent it started. A `●` marks a session you
66
+ haven't attached to since it got blocked, in red, or finished, in blue; one
67
+ you have attached to since is dimmed, unless `/color` tints it.
64
68
 
65
69
  ## Keys
66
70
 
@@ -78,12 +82,13 @@ Vim bindings. Arrows and the mouse work too.
78
82
  | `n` | new session |
79
83
  | `N` | pair a phone |
80
84
  | `t` | pin / unpin |
81
- | `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken |
85
+ | `s` | snooze: `1` 15m · `2` 1h · `3` tomorrow 9am · `4` until woken · `5` custom |
82
86
  | `u` | wake a snoozed session, or bring back a settled one |
83
87
  | `x` | settle by hand |
84
88
  | `a` | set a local alias |
85
89
  | `o` | open the pull request in the browser |
86
90
  | `w` | open the session at claude.ai/code |
91
+ | `y` | copy the pull request url, or the session's address |
87
92
  | `P` | link a pull request by hand (empty clears) |
88
93
  | `X` | stop the session; `Enter` resumes it later |
89
94
  | `Ctrl-x` | delete the session, transcript and worktree for good |
@@ -112,8 +117,8 @@ lists them all.
112
117
  A snoozed session wakes when its timer runs out, when you press `u`, or when
113
118
  it becomes blocked or failed.
114
119
 
115
- Attaching to a needs-you session moves it to Active until its state changes
116
- again.
120
+ Attaching to a needs-you session moves it to Active until its state, or its
121
+ pull request's health, changes again.
117
122
 
118
123
  A session settles when you press `x` or when all its pull requests are merged
119
124
  or closed. A session without a pull request only settles by hand.
@@ -149,6 +154,13 @@ covers ssh, the JSON API and an iOS Shortcut.
149
154
  Pull requests show up when a session's transcript links to them. Install
150
155
  `gh` to keep their state current. Use `P` to link one by hand.
151
156
 
157
+ ## Notifications
158
+
159
+ `--notify` sends a macOS notification through
160
+ [terminal-notifier](https://github.com/julienXX/terminal-notifier) when a
161
+ session needs you or finishes. `brew install terminal-notifier` first;
162
+ the first launch asks permission to notify.
163
+
152
164
  ## Usage bars
153
165
 
154
166
  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,7 +3,8 @@
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.
7
8
  module Browsing
8
9
  private
9
10
 
@@ -19,6 +20,24 @@ module ClaudeInbox
19
20
  open_in_browser(url, "claude.ai/code")
20
21
  end
21
22
 
23
+ def yank
24
+ session = selected_session
25
+ return notice("nothing to copy") unless session
26
+ text, what = yank_target(session)
27
+ in_background do
28
+ @queue << [:notice, @clipboard.call(text) ? "copied #{what}" : "no clipboard tool found"]
29
+ end
30
+ end
31
+
32
+ # A link set by hand wins over the scan, as it does once the poll lands.
33
+ def yank_target(session)
34
+ linked = @store.pr_for(session.key)
35
+ return [linked, "#" + linked[%r{/pull/(\d+)}, 1]] if linked
36
+ return [session.pr.url, session.pr.short] if session.pr
37
+ return [session.remote_url, session.remote_url] if session.remote_url
38
+ [session.key, session.key]
39
+ end
40
+
22
41
  def open_in_browser(url, what)
23
42
  opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
24
43
  notice("opening #{what}")
@@ -63,9 +63,13 @@ module ClaudeInbox
63
63
  name if Store::FOLDABLE_SECTIONS.include?(name)
64
64
  end
65
65
 
66
+ # Opening lands on the section's first row and closing on its fold
67
+ # line, rather than leaving the cursor on a stop that just vanished.
66
68
  def set_expanded(value)
67
69
  name = current_fold_section
68
- @expanded[name] = value if name
70
+ return if name.nil? || !!@expanded[name] == value
71
+ @expanded[name] = value
72
+ select(value ? filtered.heads(@expanded).to_h.fetch(name, @selected) : Store::Selection.fold(name))
69
73
  end
70
74
 
71
75
  def toggle_expanded = set_expanded(!@expanded[current_fold_section])
@@ -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 = []
@@ -233,6 +238,7 @@ module ClaudeInbox
233
238
  when :link_pr then open_pr_editor
234
239
  when :open_pr then open_pr
235
240
  when :open_remote then open_remote
241
+ when :yank then yank
236
242
  when :stop then open_confirm(:stop)
237
243
  when :delete then open_confirm(:delete)
238
244
  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
@@ -118,11 +119,12 @@ module ClaudeInbox
118
119
  end
119
120
  end
120
121
 
121
- # `a` alias and `P` pull request: one line of text, read from `value`
122
- # once :save comes back. Empty means clear.
122
+ # `a` alias, `P` pull request and `s` `5` snooze: one line of text, read
123
+ # from `value` once :save comes back. Empty means clear.
123
124
  class Prompt < Dialog
124
- TITLES = {alias: " Alias ", pr: " Pull request "}.freeze
125
- QUESTIONS = {alias: " New alias:", pr: " Pull request URL (empty clears):"}.freeze
125
+ TITLES = {alias: " Alias ", pr: " Pull request ", snooze: " Snooze "}.freeze
126
+ QUESTIONS = {alias: " New alias:", pr: " Pull request URL (empty clears):",
127
+ snooze: " Wake at (2h, 5pm, fri, tonight):"}.freeze
126
128
 
127
129
  def initialize(kind, id, value)
128
130
  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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "subprocess"
5
+
6
+ module ClaudeInbox
7
+ # A copy of Homebrew's terminal-notifier.app under our own bundle id and
8
+ # the web app's icon, since macOS draws a notification's icon from the
9
+ # bundle that sends it and offers no way to override it.
10
+ class NotifierApp
11
+ DEFAULT_PATH = File.join(Dir.home, ".config", "claude-inbox", "notifier.app")
12
+ ID = "dev.claude-inbox.notifier"
13
+ # Both drawn from remote/icon.svg; the .icns through iconutil, from a
14
+ # 16-1024px iconset of rsvg-convert renders.
15
+ ICNS = File.join(__dir__, "remote", "icon.icns")
16
+ PNG = File.join(__dir__, "remote", "icon.png")
17
+ # Plain terminal-notifier carries the icon as a thumbnail instead.
18
+ FALLBACK = ["terminal-notifier", "-contentImage", PNG].freeze
19
+
20
+ def self.disabled = new(path: nil)
21
+
22
+ def initialize(path: DEFAULT_PATH, run: Subprocess.method(:capture))
23
+ @path = path
24
+ @run = run
25
+ end
26
+
27
+ # Builds the copy when terminal-notifier's own app is new to us, and
28
+ # sends one notification through `open`: macOS asks permission only of
29
+ # an app LaunchServices started, never of its binary run from a shell.
30
+ def prepare
31
+ return unless @path && (app = source) && !current?(app)
32
+ return unless build(app)
33
+ @run.call("open", "-n", @path, "--args", "-title", "claude-inbox", "-message", "Notifications active.")
34
+ end
35
+
36
+ # The argv that sends one notification, short of its own flags.
37
+ def command = (@path && current?(source)) ? [File.join(@path, "Contents", "MacOS", "terminal-notifier")] : FALLBACK
38
+
39
+ private
40
+
41
+ def source
42
+ return @source if defined?(@source)
43
+ r = @run.call("brew", "--prefix", "terminal-notifier")
44
+ app = File.join(r.out.strip, "terminal-notifier.app") if r.success?
45
+ @source = (app && File.directory?(app)) ? File.realpath(app) : nil
46
+ end
47
+
48
+ # The copy names the app it came from, whose Cellar path carries the
49
+ # version, so an upgrade rebuilds it.
50
+ def current?(app)
51
+ app && File.read(stamp(@path)) == app
52
+ rescue SystemCallError
53
+ false
54
+ end
55
+
56
+ def stamp(bundle) = File.join(bundle, "Contents", "Resources", "source")
57
+
58
+ # Built aside and moved into place, so `command` never sees half a copy.
59
+ def build(app)
60
+ tmp = "#{@path}.tmp"
61
+ FileUtils.rm_rf(tmp)
62
+ FileUtils.mkdir_p(File.dirname(tmp))
63
+ FileUtils.cp_r(app, tmp)
64
+ FileUtils.cp(ICNS, File.join(tmp, "Contents", "Resources", "claude-inbox.icns"))
65
+ File.write(stamp(tmp), app)
66
+ plist = File.join(tmp, "Contents", "Info.plist")
67
+ steps = [
68
+ ["plutil", "-replace", "CFBundleIdentifier", "-string", ID, plist],
69
+ ["plutil", "-replace", "CFBundleName", "-string", "claude-inbox", plist],
70
+ ["plutil", "-replace", "CFBundleIconFile", "-string", "claude-inbox", plist],
71
+ ["codesign", "--force", "--sign", "-", tmp]
72
+ ]
73
+ unless steps.all? { |argv| @run.call(*argv).success? }
74
+ FileUtils.rm_rf(tmp)
75
+ return false
76
+ end
77
+ FileUtils.rm_rf(@path)
78
+ File.rename(tmp, @path)
79
+ true
80
+ end
81
+ end
82
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "notifier"
3
4
  require_relative "sessions"
4
5
 
5
6
  module ClaudeInbox
@@ -7,16 +8,16 @@ module ClaudeInbox
7
8
  # seconds and on demand, puts what it finds in the store and the snapshot,
8
9
  # and tells App over the shared queue: [:sessions] after each
9
10
  # hand-over, [:error, msg] when a poll fails, [:notice, text] when the
10
- # reaper took something. Between polls it applies what another front end
11
- # asked for. Nothing here touches App's state directly; the queue is the
12
- # whole of the interface. One worker runs every poll, so two can never
13
- # overlap and publish the list out of order.
11
+ # reaper took something or terminal-notifier is missing. Between polls it
12
+ # applies what another front end asked for. Nothing here touches App's
13
+ # state directly; the queue is the whole of the interface. One worker runs
14
+ # every poll, so two can never overlap and publish the list out of order.
14
15
  class Poller
15
16
  INTERVAL = 4
16
17
  ACTIONS_INTERVAL = 0.5
17
18
 
18
19
  def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
19
- interval: INTERVAL, clock: -> { Time.now })
20
+ notifier: Notifier.disabled, interval: INTERVAL, clock: -> { Time.now })
20
21
  @client = client
21
22
  @clock = clock
22
23
  @store = store
@@ -25,6 +26,7 @@ module ClaudeInbox
25
26
  @queue = queue
26
27
  @snapshot = snapshot
27
28
  @actions = actions
29
+ @notifier = notifier
28
30
  @interval = interval
29
31
  @wake = Queue.new
30
32
  end
@@ -77,9 +79,15 @@ module ClaudeInbox
77
79
 
78
80
  private
79
81
 
82
+ # The notifier compares the sections from just before the hand-over, so
83
+ # a row you woke or unpinned yourself since the last poll says nothing.
80
84
  def publish(sessions)
85
+ now = @clock.call
86
+ before = @store.sections(now)
81
87
  @store.update(sessions)
82
- write_snapshot
88
+ after = @store.sections(now)
89
+ @snapshot.write(after, now)
90
+ @notifier.deliver(before, after) { |msg| @queue << [:notice, msg] }
83
91
  @queue << [:sessions]
84
92
  end
85
93