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.
- checksums.yaml +4 -4
- data/README.md +19 -7
- data/lib/claude_inbox/actions.rb +10 -2
- data/lib/claude_inbox/agents_client.rb +1 -1
- data/lib/claude_inbox/app/browsing.rb +20 -1
- data/lib/claude_inbox/app/navigating.rb +5 -1
- data/lib/claude_inbox/app/triaging.rb +5 -0
- data/lib/claude_inbox/app.rb +9 -3
- data/lib/claude_inbox/cli.rb +7 -2
- data/lib/claude_inbox/clipboard.rb +35 -0
- data/lib/claude_inbox/config.rb +1 -1
- data/lib/claude_inbox/dialog.rb +7 -5
- data/lib/claude_inbox/headless.rb +3 -2
- data/lib/claude_inbox/images.rb +4 -1
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/mouse.rb +5 -5
- data/lib/claude_inbox/new_session_form.rb +1 -1
- data/lib/claude_inbox/notifier.rb +85 -0
- data/lib/claude_inbox/notifier_app.rb +82 -0
- data/lib/claude_inbox/poller.rb +14 -6
- data/lib/claude_inbox/pull_requests.rb +29 -4
- data/lib/claude_inbox/remote/icon.icns +0 -0
- data/lib/claude_inbox/renderer.rb +43 -15
- data/lib/claude_inbox/snapshot.rb +1 -1
- data/lib/claude_inbox/snooze_time.rb +69 -0
- data/lib/claude_inbox/store/entry.rb +20 -0
- data/lib/claude_inbox/store/row.rb +22 -1
- data/lib/claude_inbox/store/sections.rb +0 -4
- data/lib/claude_inbox/store.rb +1 -1
- data/lib/claude_inbox/subprocess.rb +17 -2
- data/lib/claude_inbox/terminal.rb +0 -3
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +19 -5
- data/mod/tests/contract.test.tsx +1 -0
- data/mod/tests/pane.test.tsx +5 -1
- data/mod/tests/snapshot.fixture.ts +4 -2
- data/mod/types/index.d.ts +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f240b8de798a2fea0c318d9bd7882e2d05cf7bdcbdc48fea7698f2ac8e6dc82
|
|
4
|
+
data.tar.gz: a94912ebe0d438922d9f5bdc334e0be3465e7997fb9767bc1e2a6ef9f7ed55b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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
|
|
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
|
|
63
|
-
|
|
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
|
|
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:
|
data/lib/claude_inbox/actions.rb
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -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
|
data/lib/claude_inbox/cli.rb
CHANGED
|
@@ -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
|
data/lib/claude_inbox/config.rb
CHANGED
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -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
|
|
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):"
|
|
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.
|
data/lib/claude_inbox/images.rb
CHANGED
|
@@ -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)
|
|
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
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -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,
|
data/lib/claude_inbox/mouse.rb
CHANGED
|
@@ -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
|
|
7
|
-
#
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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, ¬ice)
|
|
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
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -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
|
|
11
|
-
# asked for. Nothing here touches App's
|
|
12
|
-
# whole of the interface. One worker runs
|
|
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
|
-
|
|
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
|
|