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.
- checksums.yaml +4 -4
- data/README.md +21 -8
- data/lib/claude_inbox/actions.rb +10 -2
- data/lib/claude_inbox/agents_client.rb +1 -1
- data/lib/claude_inbox/app/browsing.rb +35 -4
- data/lib/claude_inbox/app/triaging.rb +5 -0
- data/lib/claude_inbox/app.rb +10 -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 +45 -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 +43 -6
- data/lib/claude_inbox/remote/icon.icns +0 -0
- data/lib/claude_inbox/renderer.rb +45 -15
- data/lib/claude_inbox/session.rb +14 -1
- 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 +23 -10
- data/mod/tests/contract.test.tsx +1 -0
- data/mod/tests/pane.test.tsx +27 -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: 6194693f8175ae0767d58a3f9f90e746122f831a33675c2928e2120ccba4e895
|
|
4
|
+
data.tar.gz: d1f08c4e9341d115a7775fff9702fef6342b3771b9a76e3bc0fd957af79d04ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
63
|
-
|
|
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
|
|
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:
|
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,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
|
-
|
|
12
|
-
return notice("no pull request linked — P sets one") unless pr
|
|
13
|
-
|
|
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
|
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 = []
|
|
@@ -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
|
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
|
|
@@ -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
|
|
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):"
|
|
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.
|
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
|