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
|
@@ -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
|
|
|
@@ -6,13 +6,31 @@ require_relative "records"
|
|
|
6
6
|
require_relative "subprocess"
|
|
7
7
|
|
|
8
8
|
module ClaudeInbox
|
|
9
|
+
# What is wrong with an open PR, in the words its badge and the picker
|
|
10
|
+
# use. CLEAN and unknown say nothing.
|
|
11
|
+
PR_TROUBLE = {"FAILING" => "✗ checks", "CHANGES_REQUESTED" => "changes requested", "CONFLICTING" => "conflicts"}.freeze
|
|
12
|
+
|
|
9
13
|
# A pull request tied to a session. `state` uses GitHub's vocabulary plus
|
|
10
14
|
# DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
|
|
11
15
|
# or nil when nothing has told us yet. `resolved_at` is when it was merged
|
|
12
|
-
# or closed, in epoch seconds, once gh has said.
|
|
13
|
-
|
|
16
|
+
# or closed, in epoch seconds, once gh has said. `health` is what gh last
|
|
17
|
+
# said is wrong with an open PR: FAILING (a check), CHANGES_REQUESTED or
|
|
18
|
+
# CONFLICTING; CLEAN when nothing is, nil when nothing has said. Claude
|
|
19
|
+
# Code's cache knows nothing of it, and a resolved PR has none. `title`
|
|
20
|
+
# is whatever gh or the cache last called it, nil until one has.
|
|
21
|
+
PullRequest = Struct.new(:number, :url, :state, :resolved_at, :health, :title) do
|
|
14
22
|
def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
|
|
15
23
|
|
|
24
|
+
def repo = url.to_s[%r{\Ahttps://github\.com/([^/\s]+/[^/\s]+)/pull/\d+}, 1]
|
|
25
|
+
|
|
26
|
+
def qualified = (number && repo) ? "#{repo}##{number}" : short
|
|
27
|
+
|
|
28
|
+
def trouble = PR_TROUBLE[health]
|
|
29
|
+
|
|
30
|
+
# Failing checks or requested changes want you back; a conflict only
|
|
31
|
+
# waits on whoever rebases.
|
|
32
|
+
def needs_you? = %w[FAILING CHANGES_REQUESTED].include?(health)
|
|
33
|
+
|
|
16
34
|
def merged? = state == "MERGED"
|
|
17
35
|
|
|
18
36
|
def closed? = state == "CLOSED"
|
|
@@ -56,6 +74,7 @@ module ClaudeInbox
|
|
|
56
74
|
# open PR, and the poller calls it after the list has already gone up.
|
|
57
75
|
class PullRequests
|
|
58
76
|
REFRESH_AFTER = 60
|
|
77
|
+
FAILED_CHECKS = %w[FAILURE ERROR TIMED_OUT CANCELLED ACTION_REQUIRED STARTUP_FAILURE].freeze
|
|
59
78
|
|
|
60
79
|
CACHE_PATH = File.join(Dir.home, ".claude", "gh-pr-status-cache.json")
|
|
61
80
|
RESOLVED_PATH = File.join(Dir.home, ".config", "claude-inbox", "prs.json")
|
|
@@ -114,11 +133,27 @@ module ClaudeInbox
|
|
|
114
133
|
h = JSON.parse(json)
|
|
115
134
|
state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
|
|
116
135
|
resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
|
|
117
|
-
PullRequest.new(number: h["number"], url: h["url"] || url, state: state, resolved_at: resolved_at
|
|
136
|
+
PullRequest.new(number: h["number"], url: h["url"] || url, state: state, resolved_at: resolved_at,
|
|
137
|
+
health: (health(h) if h["state"] == "OPEN"), title: h["title"])
|
|
118
138
|
rescue JSON::ParserError, ArgumentError
|
|
119
139
|
nil
|
|
120
140
|
end
|
|
121
141
|
|
|
142
|
+
# A check run reports a `conclusion`, a commit status a `state`; the
|
|
143
|
+
# rollup mixes both. It also keeps every run of a check on the head
|
|
144
|
+
# commit, so a run a newer one cancelled or replaced still shows its
|
|
145
|
+
# verdict there; only the latest run of each check counts.
|
|
146
|
+
def self.health(h)
|
|
147
|
+
checks = (h["statusCheckRollup"] || [])
|
|
148
|
+
.group_by { |c| [c["workflowName"], c["name"] || c["context"]] }
|
|
149
|
+
.map { |_, runs| runs.max_by { |c| c["startedAt"].to_s } }
|
|
150
|
+
if checks.any? { |c| FAILED_CHECKS.include?(c["conclusion"] || c["state"]) } then "FAILING"
|
|
151
|
+
elsif h["reviewDecision"] == "CHANGES_REQUESTED" then "CHANGES_REQUESTED"
|
|
152
|
+
elsif h["mergeable"] == "CONFLICTING" then "CONFLICTING"
|
|
153
|
+
else "CLEAN"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
122
157
|
# Only an https://github.com/<owner>/<repo>/pull/<n> link makes sense here.
|
|
123
158
|
def self.valid_url?(url)
|
|
124
159
|
url.to_s.match?(%r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/pull/\d+/?\z})
|
|
@@ -139,7 +174,7 @@ module ClaudeInbox
|
|
|
139
174
|
number = url[%r{/pull/(\d+)}, 1]&.to_i
|
|
140
175
|
cached = resolved[url] || claude_cache[url]
|
|
141
176
|
PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
|
|
142
|
-
resolved_at: cached&.dig("resolved_at"))
|
|
177
|
+
resolved_at: cached&.dig("resolved_at"), title: cached&.dig("title"))
|
|
143
178
|
end
|
|
144
179
|
|
|
145
180
|
# Same shape as Claude Code's cache, so `seed` reads both alike.
|
|
@@ -153,13 +188,15 @@ module ClaudeInbox
|
|
|
153
188
|
|
|
154
189
|
# Under @mutex.
|
|
155
190
|
def remember(pr)
|
|
156
|
-
resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "resolved_at" => pr.resolved_at
|
|
191
|
+
resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "resolved_at" => pr.resolved_at,
|
|
192
|
+
"title" => pr.title}
|
|
157
193
|
return unless @resolved_path
|
|
158
194
|
Records.save(@resolved_path, resolved)
|
|
159
195
|
end
|
|
160
196
|
|
|
161
197
|
def fetch(url)
|
|
162
|
-
r = Subprocess.capture(@gh, "pr", "view", url, "--json",
|
|
198
|
+
r = Subprocess.capture(@gh, "pr", "view", url, "--json",
|
|
199
|
+
"number,state,isDraft,url,closedAt,statusCheckRollup,reviewDecision,mergeable,title")
|
|
163
200
|
r.success? ? self.class.parse(url, r.out) : nil
|
|
164
201
|
rescue SystemCallError
|
|
165
202
|
nil
|
|
Binary file
|
|
@@ -39,6 +39,10 @@ module ClaudeInbox
|
|
|
39
39
|
# the table isn't named on the badge at all.
|
|
40
40
|
PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
|
|
41
41
|
|
|
42
|
+
# The hue of PR_TROUBLE after a badge. Red for what puts the row in
|
|
43
|
+
# Needs You; a conflict dims, since it waits on a rebase, not on you.
|
|
44
|
+
PR_TROUBLE_HUES = {"FAILING" => :red, "CHANGES_REQUESTED" => :red}.freeze
|
|
45
|
+
|
|
42
46
|
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
43
47
|
|
|
44
48
|
# The first poll usually lands inside a second. Past LOADING_QUIET the
|
|
@@ -172,7 +176,7 @@ module ClaudeInbox
|
|
|
172
176
|
header_chip(remotes, "⇅", "remote", compact) { |s| @theme.blue(s) },
|
|
173
177
|
header_chip(snoozed, "z", "snoozed", compact) { |s| @theme.purple(s) },
|
|
174
178
|
header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
|
|
175
|
-
]
|
|
179
|
+
]
|
|
176
180
|
chips << @p.dim("nothing running") if sections.all.empty?
|
|
177
181
|
[*chips, listening_chip(listening)].compact.join(compact ? " " : @p.dim(" · "))
|
|
178
182
|
end
|
|
@@ -293,15 +297,20 @@ module ClaudeInbox
|
|
|
293
297
|
|
|
294
298
|
def fold_toggle_line(name, count, selected, width)
|
|
295
299
|
sel = selected == name
|
|
296
|
-
marker = sel ? @theme.
|
|
300
|
+
marker = sel ? @theme.yellow_bold("▶") : " "
|
|
297
301
|
text = @p.dim("… #{count} #{SECTION_TITLES[name].downcase}") + (sel ? @p.dim(" ⏎ or zo to expand") : "")
|
|
298
302
|
Text.pad(" #{marker} " + text, width)
|
|
299
303
|
end
|
|
300
304
|
|
|
301
305
|
def row_lines(row, section, selected, width, now, tick)
|
|
302
306
|
s = row.session
|
|
307
|
+
live = %i[pinned needs_you active].include?(section)
|
|
303
308
|
sel = row.selectable? && selected == row.key
|
|
304
|
-
|
|
309
|
+
quiet = section == :settled || (live && row.read?)
|
|
310
|
+
unread = live && row.unread?
|
|
311
|
+
# A terminal cannot be attached to, so it is never read: while it waits on you it keeps its dot.
|
|
312
|
+
alert = live && s.needs_you? && (unread || !s.actionable?)
|
|
313
|
+
marker = marker_for(sel, unread, alert)
|
|
305
314
|
glyph = glyph_for(s, section, tick)
|
|
306
315
|
meta = meta_for(row, section, now)
|
|
307
316
|
|
|
@@ -311,14 +320,14 @@ module ClaudeInbox
|
|
|
311
320
|
# so the row can never exceed `width` and fall into Text.pad's blind
|
|
312
321
|
# tail-chop (which used to land mid-project-name with no ellipsis).
|
|
313
322
|
project_text = Text.truncate(s.project, [width - chrome, 0].max)
|
|
314
|
-
project =
|
|
323
|
+
project = quiet ? @p.dim(project_text) : @theme.cyan(project_text)
|
|
315
324
|
|
|
316
325
|
label_w = [width - chrome - Text.width(project_text), 0].max
|
|
317
326
|
label = Text.truncate(row.label, label_w)
|
|
318
|
-
label = style_label(label, row,
|
|
327
|
+
label = style_label(label, row, quiet, sel || (live && row.unread?))
|
|
319
328
|
first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
|
|
320
329
|
|
|
321
|
-
return [Text.pad(first, width)] unless
|
|
330
|
+
return [Text.pad(first, width)] unless live
|
|
322
331
|
|
|
323
332
|
# The session's own line when it has one; the path is what is left to
|
|
324
333
|
# say about a terminal, which has no job file.
|
|
@@ -326,15 +335,29 @@ module ClaudeInbox
|
|
|
326
335
|
[Text.pad(first, width), Text.pad(detail, width)]
|
|
327
336
|
end
|
|
328
337
|
|
|
338
|
+
# The caret takes the column. Otherwise one filled dot asks for a look:
|
|
339
|
+
# red while the row needs you, blue when it only finished. Read, it
|
|
340
|
+
# clears, and the meta still says needs you.
|
|
341
|
+
def marker_for(sel, unread, alert)
|
|
342
|
+
if sel then @theme.yellow_bold("▶")
|
|
343
|
+
elsif alert then @theme.red_bold("●")
|
|
344
|
+
elsif unread then @theme.blue("●")
|
|
345
|
+
else " "
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
329
349
|
# The label is the one part of a row you own: `/color` tints it, and
|
|
330
350
|
# nothing else on the line. Glyph, badge and PR keep the state's colors,
|
|
331
351
|
# so no color you pick can make a blocked session stop looking blocked.
|
|
332
|
-
#
|
|
333
|
-
|
|
334
|
-
|
|
352
|
+
# A quiet row (settled, or finished and read) is dim, unless it has a
|
|
353
|
+
# tint: faint blue or pink is unreadable on a dark background, and the
|
|
354
|
+
# rest of the row already dims. Bold marks an unread row, as a mail
|
|
355
|
+
# client does, and the selected one under its caret.
|
|
356
|
+
def style_label(label, row, quiet, bold)
|
|
335
357
|
styled =
|
|
336
|
-
if
|
|
337
|
-
elsif
|
|
358
|
+
if quiet then Palette::SEQUENCES.key?(row.session.color) ? label : @p.dim(label)
|
|
359
|
+
elsif row.alias_name then bold ? @p.bold.italic(label) : @p.italic(label)
|
|
360
|
+
elsif bold then @p.bold(label)
|
|
338
361
|
else label
|
|
339
362
|
end
|
|
340
363
|
@palette.paint(styled, row.session.color)
|
|
@@ -344,12 +367,12 @@ module ClaudeInbox
|
|
|
344
367
|
return @theme.purple("z") if section == :snoozed
|
|
345
368
|
return @p.dim("◦") if section == :settled
|
|
346
369
|
case s.effective_state
|
|
347
|
-
when "blocked" then @theme.red_bold("
|
|
370
|
+
when "blocked" then @theme.red_bold("?")
|
|
348
371
|
when "failed" then @theme.red_bold("✗")
|
|
349
372
|
when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(SPINNER[tick % SPINNER.size])
|
|
350
373
|
when "done" then @theme.green("✓")
|
|
351
374
|
when "stopped" then @p.dim("■")
|
|
352
|
-
else @p.dim("
|
|
375
|
+
else @p.dim("·")
|
|
353
376
|
end
|
|
354
377
|
end
|
|
355
378
|
|
|
@@ -376,13 +399,20 @@ module ClaudeInbox
|
|
|
376
399
|
pr ? base + @p.dim(" · ") + pr : base
|
|
377
400
|
end
|
|
378
401
|
|
|
379
|
-
# "#885 open"
|
|
402
|
+
# "#885 open", or "#885 open · ✗ checks" when something is wrong. The
|
|
403
|
+
# headline PR is shown, with a count of the rest.
|
|
380
404
|
def pr_badge(s, section)
|
|
381
405
|
pr = s.pr
|
|
382
406
|
return nil unless pr
|
|
407
|
+
more = (s.prs.size > 1) ? @p.dim(" · +#{s.prs.size - 1}") : ""
|
|
408
|
+
pr_state_badge(pr, section) + more
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def pr_state_badge(pr, section)
|
|
383
412
|
return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
|
|
384
413
|
return @p.dim(pr.short) unless PR_HUES.key?(pr.state)
|
|
385
|
-
tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
|
|
414
|
+
badge = tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
|
|
415
|
+
pr.trouble ? badge + @p.dim(" · ") + tint(PR_TROUBLE_HUES[pr.health], pr.trouble) : badge
|
|
386
416
|
end
|
|
387
417
|
|
|
388
418
|
def state_badge(s)
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -99,6 +99,19 @@ module ClaudeInbox
|
|
|
99
99
|
|
|
100
100
|
def project = cwd ? File.basename(cwd) : ""
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
# The PR you would act on. One resolved before the session began was
|
|
103
|
+
# only mentioned (as Row#settled? judges), so it loses to any live one.
|
|
104
|
+
def pr = prs.each_with_index.min_by { |pr, i| [headline_rank(pr), i] }&.first
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def headline_rank(pr)
|
|
109
|
+
if !pr.known? || pr.resolved_before?(created_at) then 4
|
|
110
|
+
elsif pr.needs_you? then 0
|
|
111
|
+
elsif pr.health == "CONFLICTING" then 1
|
|
112
|
+
elsif !pr.resolved? then 2
|
|
113
|
+
else 3
|
|
114
|
+
end
|
|
115
|
+
end
|
|
103
116
|
end
|
|
104
117
|
end
|
|
@@ -44,7 +44,7 @@ module ClaudeInbox
|
|
|
44
44
|
pr = s.pr
|
|
45
45
|
{
|
|
46
46
|
"id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
|
|
47
|
-
"waiting" => s.waiting_on_work? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url, "color" => s.color,
|
|
47
|
+
"waiting" => s.waiting_on_work? || nil, "unread" => row.unread? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url, "color" => s.color,
|
|
48
48
|
"pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
|
|
49
49
|
}.compact
|
|
50
50
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# The wake time a typed snooze means, as an epoch, or nil for text it does
|
|
7
|
+
# not read. Takes a duration (`45m`, `2h`, `3d`), a clock time (`17:00`,
|
|
8
|
+
# `5pm`) for the next such time, a weekday (`fri`, `friday`) for that day
|
|
9
|
+
# at 9am, `tomorrow`, or `tonight`/`evening` for the next 6pm. A weekday
|
|
10
|
+
# or `tomorrow` may carry a time after it (`fri 5pm`).
|
|
11
|
+
module SnoozeTime
|
|
12
|
+
UNITS = {"m" => 60, "min" => 60, "mins" => 60, "minute" => 60, "minutes" => 60,
|
|
13
|
+
"h" => 3600, "hr" => 3600, "hrs" => 3600, "hour" => 3600, "hours" => 3600,
|
|
14
|
+
"d" => 86_400, "day" => 86_400, "days" => 86_400}.freeze
|
|
15
|
+
WEEKDAYS = {"sun" => 0, "mon" => 1, "tue" => 2, "wed" => 3, "thu" => 4, "fri" => 5, "sat" => 6}.freeze
|
|
16
|
+
DURATION = /\A(\d+)\s*(#{UNITS.keys.join("|")})\z/
|
|
17
|
+
WEEKDAY = /\A(#{WEEKDAYS.keys.join("|")})[a-z]*\z/
|
|
18
|
+
CLOCK = /\A(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\z/
|
|
19
|
+
MORNING = [9, 0].freeze
|
|
20
|
+
EVENING = [18, 0].freeze
|
|
21
|
+
|
|
22
|
+
def self.parse(text, now)
|
|
23
|
+
text = text.to_s.strip.downcase
|
|
24
|
+
if (m = DURATION.match(text))
|
|
25
|
+
return m[1].to_i.positive? ? now.to_i + m[1].to_i * UNITS[m[2]] : nil
|
|
26
|
+
end
|
|
27
|
+
head, rest = text.split(/\s+/, 2)
|
|
28
|
+
case head
|
|
29
|
+
when nil then nil
|
|
30
|
+
when "tonight", "evening" then rest.nil? ? next_at(now, EVENING) : nil
|
|
31
|
+
when "tomorrow" then (hm = rest ? clock(rest) : MORNING) && at(now, 1, hm)
|
|
32
|
+
when WEEKDAY then (hm = rest ? clock(rest) : MORNING) && weekday(now, WEEKDAYS[head[0, 3]], hm)
|
|
33
|
+
else (hm = clock(text)) && next_at(now, hm)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# [hour, minute] for `17:00`, `5pm` or `5:30pm`; a bare number is not a time.
|
|
38
|
+
def self.clock(text)
|
|
39
|
+
m = CLOCK.match(text)
|
|
40
|
+
return nil if m.nil? || (m[2].nil? && m[3].nil?)
|
|
41
|
+
hour, minute = m[1].to_i, m[2].to_i
|
|
42
|
+
return nil if minute > 59
|
|
43
|
+
if m[3]
|
|
44
|
+
return nil unless (1..12).cover?(hour)
|
|
45
|
+
hour = hour % 12 + ((m[3] == "pm") ? 12 : 0)
|
|
46
|
+
elsif hour > 23
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
[hour, minute]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# That day at `hm`, or a week on when it has already passed.
|
|
53
|
+
def self.weekday(now, wday, hm)
|
|
54
|
+
t = at(now, (wday - now.wday) % 7, hm)
|
|
55
|
+
(t > now.to_i) ? t : t + 7 * 86_400
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Today at `hm`, or tomorrow when it has already passed.
|
|
59
|
+
def self.next_at(now, hm)
|
|
60
|
+
t = at(now, 0, hm)
|
|
61
|
+
(t > now.to_i) ? t : at(now, 1, hm)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.at(now, days, (hour, minute))
|
|
65
|
+
day = now.to_date + days
|
|
66
|
+
Time.new(day.year, day.month, day.day, hour, minute, 0, now.utc_offset).to_i
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -20,8 +20,10 @@ module ClaudeInbox
|
|
|
20
20
|
(hash ? new(hash) : first_seen(session, now)).observe(session, now)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# An Integer choice is the wake time itself, as typed after `5`.
|
|
23
24
|
def self.snooze_until(choice, now)
|
|
24
25
|
case choice
|
|
26
|
+
when Integer then choice
|
|
25
27
|
when :m15 then now.to_i + 15 * 60
|
|
26
28
|
when :h1 then now.to_i + 3600
|
|
27
29
|
when :tomorrow_9am
|
|
@@ -64,11 +66,17 @@ module ClaudeInbox
|
|
|
64
66
|
|
|
65
67
|
def acknowledged_at = @h["acknowledged_at"]
|
|
66
68
|
|
|
69
|
+
# When you last attached. Never cleared, unlike `acknowledged_at`.
|
|
70
|
+
def last_visited_at = @h["last_visited_at"]
|
|
71
|
+
|
|
67
72
|
def revived_at = @h["revived_at"]
|
|
68
73
|
|
|
69
74
|
# When `claude rm` last refused this session, so the Reaper backs off.
|
|
70
75
|
def reap_failed_at = @h["reap_failed_at"]
|
|
71
76
|
|
|
77
|
+
# What was last wrong with the PR, while something is.
|
|
78
|
+
def pr_health = @h["pr_health"]
|
|
79
|
+
|
|
72
80
|
def stale?(now) = last_seen && now.to_i - last_seen > PRUNE_AFTER
|
|
73
81
|
|
|
74
82
|
def observe(session, now)
|
|
@@ -77,6 +85,14 @@ module ClaudeInbox
|
|
|
77
85
|
@h["last_state"] = session.effective_state
|
|
78
86
|
@h["state_since"] = now_i
|
|
79
87
|
end
|
|
88
|
+
# Trouble on the PR is a state change too, so an attach answers it
|
|
89
|
+
# like a blocked state and it sorts to the top of Needs You. Only the
|
|
90
|
+
# trouble is kept, and a PR gh has not described leaves it as it was.
|
|
91
|
+
health = session.pr&.health
|
|
92
|
+
if health && health != (pr_health || "CLEAN")
|
|
93
|
+
(health == "CLEAN") ? @h.delete("pr_health") : @h["pr_health"] = health
|
|
94
|
+
@h["state_since"] = now_i
|
|
95
|
+
end
|
|
80
96
|
@h["last_seen"] = now_i
|
|
81
97
|
row = Row.new(session: session, entry: self)
|
|
82
98
|
@h.delete("wake_at") if row.woken?(now_i)
|
|
@@ -109,8 +125,12 @@ module ClaudeInbox
|
|
|
109
125
|
@h.delete("revived_at")
|
|
110
126
|
end
|
|
111
127
|
|
|
128
|
+
# One attach, two clocks: `acknowledged_at` lapses with the state and
|
|
129
|
+
# moves the row between sections; `last_visited_at` keeps, so a row
|
|
130
|
+
# that finishes after you looked reads as unread until you look again.
|
|
112
131
|
def acknowledge(now)
|
|
113
132
|
@h["acknowledged_at"] = now.to_i
|
|
133
|
+
@h["last_visited_at"] = now.to_i
|
|
114
134
|
end
|
|
115
135
|
|
|
116
136
|
def toggle_pin(now)
|
|
@@ -55,12 +55,33 @@ module ClaudeInbox
|
|
|
55
55
|
entry&.acknowledged_at && entry.state_since.to_i <= entry.acknowledged_at.to_i
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Finished or needing you since you last attached. Only a session you
|
|
59
|
+
# can attach to counts, since nothing else ever clears the mark.
|
|
60
|
+
def unread?
|
|
61
|
+
return false unless session.actionable?
|
|
62
|
+
return false unless session.finished? || session.needs_you?
|
|
63
|
+
entry&.last_visited_at.nil? || entry.state_since.to_i > entry.last_visited_at.to_i
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A finished or blocked session you have attached to since: you have seen what it wants.
|
|
67
|
+
def read? = session.actionable? && (session.finished? || session.needs_you?) && !unread?
|
|
68
|
+
|
|
58
69
|
# `u` overrides hand-settle and the PR rule alike, and holds until the
|
|
59
70
|
# state actually changes rather than just for the next poll.
|
|
60
71
|
def revived?
|
|
61
72
|
entry&.revived_at && entry.state_since.to_i <= entry.revived_at.to_i
|
|
62
73
|
end
|
|
63
74
|
|
|
75
|
+
# Failing checks or requested changes on the PR want you back the way
|
|
76
|
+
# a blocked session does, once the agent has stopped working on it.
|
|
77
|
+
# Only an open PR gh has described has a health, so the unknown and
|
|
78
|
+
# the long-resolved PRs `settled?` guards against never count here.
|
|
79
|
+
# Entry#observe treats a change of health as a state change, so
|
|
80
|
+
# attaching answers it until the health changes again.
|
|
81
|
+
def pr_needs_you?
|
|
82
|
+
session.effective_state != "working" && session.pr&.needs_you? == true
|
|
83
|
+
end
|
|
84
|
+
|
|
64
85
|
# Resolved PRs settle a `blocked` row too: asking "anything need
|
|
65
86
|
# changing?" leaves it blocked, and merging answers it. `failed` stays
|
|
66
87
|
# in view even if its PR landed; a PR nobody knows the state of yet
|
|
@@ -94,7 +115,7 @@ module ClaudeInbox
|
|
|
94
115
|
elsif pinned? then :pinned
|
|
95
116
|
elsif snoozed?(now) then :snoozed
|
|
96
117
|
elsif !revived? && settled? then :settled # a resolved PR outranks Needs You
|
|
97
|
-
elsif session.needs_you? then acknowledged? ? :active : :needs_you
|
|
118
|
+
elsif session.needs_you? || pr_needs_you? then acknowledged? ? :active : :needs_you
|
|
98
119
|
else :active
|
|
99
120
|
end
|
|
100
121
|
end
|
|
@@ -6,10 +6,6 @@ module ClaudeInbox
|
|
|
6
6
|
Sections = Struct.new(:pinned, :needs_you, :active, :snoozed, :settled) do
|
|
7
7
|
def all = values.flatten(1)
|
|
8
8
|
|
|
9
|
-
def row(selection)
|
|
10
|
-
all.find { |r| r.key == selection.key } if selection&.row?
|
|
11
|
-
end
|
|
12
|
-
|
|
13
9
|
def matching(query)
|
|
14
10
|
return self if query.nil? || query.empty?
|
|
15
11
|
self.class.new(**to_h.transform_values { |rows| rows.select { |r| r.matches?(query) } })
|
data/lib/claude_inbox/store.rb
CHANGED
|
@@ -162,7 +162,7 @@ module ClaudeInbox
|
|
|
162
162
|
yield Entry.new(@entries[id] ||= Entry.blank(@clock.call).to_h)
|
|
163
163
|
save
|
|
164
164
|
end
|
|
165
|
-
@relay&.request(action, id, **detail)
|
|
165
|
+
@relay&.request(action, id, **detail) unless @writable
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
def load
|
|
@@ -20,14 +20,18 @@ module ClaudeInbox
|
|
|
20
20
|
# argv as exec and Process.spawn take it, with CHILD_ENV in place of ours.
|
|
21
21
|
def command(*argv) = CHILD_ENV ? [CHILD_ENV, *argv, {unsetenv_others: true}] : argv
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# `input` is written before any output is read, so it is for the few
|
|
24
|
+
# bytes a clipboard takes, not a stream.
|
|
25
|
+
def capture(*argv, chdir: nil, input: nil)
|
|
26
|
+
in_r, in_w = IO.pipe if input
|
|
24
27
|
out_r, out_w = IO.pipe
|
|
25
28
|
err_r, err_w = IO.pipe
|
|
26
29
|
pid = fork do
|
|
27
30
|
Process.setsid
|
|
28
|
-
$stdin.reopen(File::NULL)
|
|
31
|
+
$stdin.reopen(input ? in_r : File::NULL)
|
|
29
32
|
$stdout.reopen(out_w)
|
|
30
33
|
$stderr.reopen(err_w)
|
|
34
|
+
in_w&.close
|
|
31
35
|
out_r.close
|
|
32
36
|
err_r.close
|
|
33
37
|
Dir.chdir(chdir) if chdir
|
|
@@ -38,6 +42,7 @@ module ClaudeInbox
|
|
|
38
42
|
end
|
|
39
43
|
out_w.close
|
|
40
44
|
err_w.close
|
|
45
|
+
feed(in_r, in_w, input) if input
|
|
41
46
|
out = +""
|
|
42
47
|
err = +""
|
|
43
48
|
readers = [out_r, err_r]
|
|
@@ -58,5 +63,15 @@ module ClaudeInbox
|
|
|
58
63
|
_, status = Process.wait2(pid)
|
|
59
64
|
Result.new(out, err, status)
|
|
60
65
|
end
|
|
66
|
+
|
|
67
|
+
# A child that exits without reading closes the pipe under us.
|
|
68
|
+
def feed(reader, writer, input)
|
|
69
|
+
reader.close
|
|
70
|
+
writer.write(input)
|
|
71
|
+
rescue Errno::EPIPE
|
|
72
|
+
nil
|
|
73
|
+
ensure
|
|
74
|
+
writer.close
|
|
75
|
+
end
|
|
61
76
|
end
|
|
62
77
|
end
|
data/lib/claude_inbox.rb
CHANGED