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
|
@@ -9,10 +9,17 @@ module ClaudeInbox
|
|
|
9
9
|
# A pull request tied to a session. `state` uses GitHub's vocabulary plus
|
|
10
10
|
# DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
|
|
11
11
|
# 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
|
-
|
|
12
|
+
# or closed, in epoch seconds, once gh has said. `health` is what gh last
|
|
13
|
+
# said is wrong with an open PR: FAILING (a check), CHANGES_REQUESTED or
|
|
14
|
+
# CONFLICTING; CLEAN when nothing is, nil when nothing has said. Claude
|
|
15
|
+
# Code's cache knows nothing of it, and a resolved PR has none.
|
|
16
|
+
PullRequest = Struct.new(:number, :url, :state, :resolved_at, :health) do
|
|
14
17
|
def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
|
|
15
18
|
|
|
19
|
+
# Failing checks or requested changes want you back; a conflict only
|
|
20
|
+
# waits on whoever rebases.
|
|
21
|
+
def needs_you? = %w[FAILING CHANGES_REQUESTED].include?(health)
|
|
22
|
+
|
|
16
23
|
def merged? = state == "MERGED"
|
|
17
24
|
|
|
18
25
|
def closed? = state == "CLOSED"
|
|
@@ -56,6 +63,7 @@ module ClaudeInbox
|
|
|
56
63
|
# open PR, and the poller calls it after the list has already gone up.
|
|
57
64
|
class PullRequests
|
|
58
65
|
REFRESH_AFTER = 60
|
|
66
|
+
FAILED_CHECKS = %w[FAILURE ERROR TIMED_OUT CANCELLED ACTION_REQUIRED STARTUP_FAILURE].freeze
|
|
59
67
|
|
|
60
68
|
CACHE_PATH = File.join(Dir.home, ".claude", "gh-pr-status-cache.json")
|
|
61
69
|
RESOLVED_PATH = File.join(Dir.home, ".config", "claude-inbox", "prs.json")
|
|
@@ -114,11 +122,27 @@ module ClaudeInbox
|
|
|
114
122
|
h = JSON.parse(json)
|
|
115
123
|
state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
|
|
116
124
|
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
|
|
125
|
+
PullRequest.new(number: h["number"], url: h["url"] || url, state: state, resolved_at: resolved_at,
|
|
126
|
+
health: (health(h) if h["state"] == "OPEN"))
|
|
118
127
|
rescue JSON::ParserError, ArgumentError
|
|
119
128
|
nil
|
|
120
129
|
end
|
|
121
130
|
|
|
131
|
+
# A check run reports a `conclusion`, a commit status a `state`; the
|
|
132
|
+
# rollup mixes both. It also keeps every run of a check on the head
|
|
133
|
+
# commit, so a run a newer one cancelled or replaced still shows its
|
|
134
|
+
# verdict there; only the latest run of each check counts.
|
|
135
|
+
def self.health(h)
|
|
136
|
+
checks = (h["statusCheckRollup"] || [])
|
|
137
|
+
.group_by { |c| [c["workflowName"], c["name"] || c["context"]] }
|
|
138
|
+
.map { |_, runs| runs.max_by { |c| c["startedAt"].to_s } }
|
|
139
|
+
if checks.any? { |c| FAILED_CHECKS.include?(c["conclusion"] || c["state"]) } then "FAILING"
|
|
140
|
+
elsif h["reviewDecision"] == "CHANGES_REQUESTED" then "CHANGES_REQUESTED"
|
|
141
|
+
elsif h["mergeable"] == "CONFLICTING" then "CONFLICTING"
|
|
142
|
+
else "CLEAN"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
122
146
|
# Only an https://github.com/<owner>/<repo>/pull/<n> link makes sense here.
|
|
123
147
|
def self.valid_url?(url)
|
|
124
148
|
url.to_s.match?(%r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/pull/\d+/?\z})
|
|
@@ -159,7 +183,8 @@ module ClaudeInbox
|
|
|
159
183
|
end
|
|
160
184
|
|
|
161
185
|
def fetch(url)
|
|
162
|
-
r = Subprocess.capture(@gh, "pr", "view", url, "--json",
|
|
186
|
+
r = Subprocess.capture(@gh, "pr", "view", url, "--json",
|
|
187
|
+
"number,state,isDraft,url,closedAt,statusCheckRollup,reviewDecision,mergeable")
|
|
163
188
|
r.success? ? self.class.parse(url, r.out) : nil
|
|
164
189
|
rescue SystemCallError
|
|
165
190
|
nil
|
|
Binary file
|
|
@@ -39,6 +39,12 @@ 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
|
+
# What is wrong with an open PR, after its badge. Red for what puts the
|
|
43
|
+
# row in Needs You; a conflict dims, since it waits on a rebase, not on
|
|
44
|
+
# you. CLEAN and unknown say nothing.
|
|
45
|
+
PR_TROUBLE = {"FAILING" => [:red, "✗ checks"], "CHANGES_REQUESTED" => [:red, "changes requested"],
|
|
46
|
+
"CONFLICTING" => [nil, "conflicts"]}.freeze
|
|
47
|
+
|
|
42
48
|
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
43
49
|
|
|
44
50
|
# The first poll usually lands inside a second. Past LOADING_QUIET the
|
|
@@ -172,7 +178,7 @@ module ClaudeInbox
|
|
|
172
178
|
header_chip(remotes, "⇅", "remote", compact) { |s| @theme.blue(s) },
|
|
173
179
|
header_chip(snoozed, "z", "snoozed", compact) { |s| @theme.purple(s) },
|
|
174
180
|
header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
|
|
175
|
-
]
|
|
181
|
+
]
|
|
176
182
|
chips << @p.dim("nothing running") if sections.all.empty?
|
|
177
183
|
[*chips, listening_chip(listening)].compact.join(compact ? " " : @p.dim(" · "))
|
|
178
184
|
end
|
|
@@ -293,15 +299,20 @@ module ClaudeInbox
|
|
|
293
299
|
|
|
294
300
|
def fold_toggle_line(name, count, selected, width)
|
|
295
301
|
sel = selected == name
|
|
296
|
-
marker = sel ? @theme.
|
|
302
|
+
marker = sel ? @theme.yellow_bold("▶") : " "
|
|
297
303
|
text = @p.dim("… #{count} #{SECTION_TITLES[name].downcase}") + (sel ? @p.dim(" ⏎ or zo to expand") : "")
|
|
298
304
|
Text.pad(" #{marker} " + text, width)
|
|
299
305
|
end
|
|
300
306
|
|
|
301
307
|
def row_lines(row, section, selected, width, now, tick)
|
|
302
308
|
s = row.session
|
|
309
|
+
live = %i[pinned needs_you active].include?(section)
|
|
303
310
|
sel = row.selectable? && selected == row.key
|
|
304
|
-
|
|
311
|
+
quiet = section == :settled || (live && row.read?)
|
|
312
|
+
unread = live && row.unread?
|
|
313
|
+
# A terminal cannot be attached to, so it is never read: while it waits on you it keeps its dot.
|
|
314
|
+
alert = live && s.needs_you? && (unread || !s.actionable?)
|
|
315
|
+
marker = marker_for(sel, unread, alert)
|
|
305
316
|
glyph = glyph_for(s, section, tick)
|
|
306
317
|
meta = meta_for(row, section, now)
|
|
307
318
|
|
|
@@ -311,14 +322,14 @@ module ClaudeInbox
|
|
|
311
322
|
# so the row can never exceed `width` and fall into Text.pad's blind
|
|
312
323
|
# tail-chop (which used to land mid-project-name with no ellipsis).
|
|
313
324
|
project_text = Text.truncate(s.project, [width - chrome, 0].max)
|
|
314
|
-
project =
|
|
325
|
+
project = quiet ? @p.dim(project_text) : @theme.cyan(project_text)
|
|
315
326
|
|
|
316
327
|
label_w = [width - chrome - Text.width(project_text), 0].max
|
|
317
328
|
label = Text.truncate(row.label, label_w)
|
|
318
|
-
label = style_label(label, row,
|
|
329
|
+
label = style_label(label, row, quiet, sel || (live && row.unread?))
|
|
319
330
|
first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
|
|
320
331
|
|
|
321
|
-
return [Text.pad(first, width)] unless
|
|
332
|
+
return [Text.pad(first, width)] unless live
|
|
322
333
|
|
|
323
334
|
# The session's own line when it has one; the path is what is left to
|
|
324
335
|
# say about a terminal, which has no job file.
|
|
@@ -326,15 +337,29 @@ module ClaudeInbox
|
|
|
326
337
|
[Text.pad(first, width), Text.pad(detail, width)]
|
|
327
338
|
end
|
|
328
339
|
|
|
340
|
+
# The caret takes the column. Otherwise one filled dot asks for a look:
|
|
341
|
+
# red while the row needs you, blue when it only finished. Read, it
|
|
342
|
+
# clears, and the meta still says needs you.
|
|
343
|
+
def marker_for(sel, unread, alert)
|
|
344
|
+
if sel then @theme.yellow_bold("▶")
|
|
345
|
+
elsif alert then @theme.red_bold("●")
|
|
346
|
+
elsif unread then @theme.blue("●")
|
|
347
|
+
else " "
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
329
351
|
# The label is the one part of a row you own: `/color` tints it, and
|
|
330
352
|
# nothing else on the line. Glyph, badge and PR keep the state's colors,
|
|
331
353
|
# so no color you pick can make a blocked session stop looking blocked.
|
|
332
|
-
#
|
|
333
|
-
|
|
334
|
-
|
|
354
|
+
# A quiet row (settled, or finished and read) is dim, unless it has a
|
|
355
|
+
# tint: faint blue or pink is unreadable on a dark background, and the
|
|
356
|
+
# rest of the row already dims. Bold marks an unread row, as a mail
|
|
357
|
+
# client does, and the selected one under its caret.
|
|
358
|
+
def style_label(label, row, quiet, bold)
|
|
335
359
|
styled =
|
|
336
|
-
if
|
|
337
|
-
elsif
|
|
360
|
+
if quiet then Palette::SEQUENCES.key?(row.session.color) ? label : @p.dim(label)
|
|
361
|
+
elsif row.alias_name then bold ? @p.bold.italic(label) : @p.italic(label)
|
|
362
|
+
elsif bold then @p.bold(label)
|
|
338
363
|
else label
|
|
339
364
|
end
|
|
340
365
|
@palette.paint(styled, row.session.color)
|
|
@@ -344,12 +369,12 @@ module ClaudeInbox
|
|
|
344
369
|
return @theme.purple("z") if section == :snoozed
|
|
345
370
|
return @p.dim("◦") if section == :settled
|
|
346
371
|
case s.effective_state
|
|
347
|
-
when "blocked" then @theme.red_bold("
|
|
372
|
+
when "blocked" then @theme.red_bold("?")
|
|
348
373
|
when "failed" then @theme.red_bold("✗")
|
|
349
374
|
when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(SPINNER[tick % SPINNER.size])
|
|
350
375
|
when "done" then @theme.green("✓")
|
|
351
376
|
when "stopped" then @p.dim("■")
|
|
352
|
-
else @p.dim("
|
|
377
|
+
else @p.dim("·")
|
|
353
378
|
end
|
|
354
379
|
end
|
|
355
380
|
|
|
@@ -376,13 +401,16 @@ module ClaudeInbox
|
|
|
376
401
|
pr ? base + @p.dim(" · ") + pr : base
|
|
377
402
|
end
|
|
378
403
|
|
|
379
|
-
# "#885 open"
|
|
404
|
+
# "#885 open", or "#885 open · ✗ checks" when something is wrong. Only
|
|
405
|
+
# the first PR is shown.
|
|
380
406
|
def pr_badge(s, section)
|
|
381
407
|
pr = s.pr
|
|
382
408
|
return nil unless pr
|
|
383
409
|
return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
|
|
384
410
|
return @p.dim(pr.short) unless PR_HUES.key?(pr.state)
|
|
385
|
-
tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
|
|
411
|
+
badge = tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
|
|
412
|
+
hue, trouble = PR_TROUBLE[pr.health]
|
|
413
|
+
trouble ? badge + @p.dim(" · ") + tint(hue, trouble) : badge
|
|
386
414
|
end
|
|
387
415
|
|
|
388
416
|
def state_badge(s)
|
|
@@ -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
data/mod/hooks/register.tsx
CHANGED
|
@@ -35,6 +35,7 @@ export type SnapshotRow = {
|
|
|
35
35
|
state?: string
|
|
36
36
|
actionable?: boolean
|
|
37
37
|
waiting?: boolean
|
|
38
|
+
unread?: boolean
|
|
38
39
|
line?: string
|
|
39
40
|
wake_at?: number | 'until_woken'
|
|
40
41
|
remote?: string
|
|
@@ -51,7 +52,7 @@ type Action = { action: 'settle' | 'wake' | 'pin'; id: string } | { action: 'sno
|
|
|
51
52
|
|
|
52
53
|
const ORDER: Section[] = ['pinned', 'needs_you', 'active', 'snoozed', 'settled']
|
|
53
54
|
const TITLE: Record<Section, string> = { pinned: 'Pinned', needs_you: 'Needs you', active: 'Active', snoozed: 'Snoozed', settled: 'Settled' }
|
|
54
|
-
const GLYPH: Record<string, string> = { blocked: '
|
|
55
|
+
const GLYPH: Record<string, string> = { blocked: '?', failed: '✗', working: '◐', idle: '◌', done: '✓', stopped: '✓' }
|
|
55
56
|
const COLOR: Record<string, string> = { blocked: 'red', failed: 'red', working: 'green', idle: 'cyan' }
|
|
56
57
|
const PR_COLOR: Record<string, string> = { open: 'green', draft: 'yellow', merged: 'magenta', closed: 'red' }
|
|
57
58
|
// `/color`'s names as a label's background. A Button keeps the terminal's own ink, light on a dark
|
|
@@ -138,6 +139,7 @@ export function rowOf(section: Section, one: SnapshotRow): InboxRow {
|
|
|
138
139
|
state: one.state ?? 'unknown',
|
|
139
140
|
actionable: one.actionable === true,
|
|
140
141
|
isWaiting: one.waiting === true,
|
|
142
|
+
isUnread: one.unread === true,
|
|
141
143
|
line: one.line,
|
|
142
144
|
wakeAt: one.wake_at,
|
|
143
145
|
remote: one.remote,
|
|
@@ -456,11 +458,20 @@ export const register: Register = (on, options) => {
|
|
|
456
458
|
}
|
|
457
459
|
const one = item.row
|
|
458
460
|
const isQuiet = isFold(one.section)
|
|
461
|
+
// A finished or blocked row you have attached to since dims, as the gem's Active section draws it,
|
|
462
|
+
// unless it is tinted: faint ink on a /color background is unreadable.
|
|
463
|
+
const isRead = !isQuiet && one.actionable && ['done', 'stopped', 'blocked', 'failed'].includes(one.state) && !one.isUnread
|
|
464
|
+
// One dot asks for a look: red while the row needs you, blue when it only finished. A terminal
|
|
465
|
+
// cannot be attached to, so it is never read and keeps its dot while it waits.
|
|
466
|
+
const needsYou = one.state === 'blocked' || one.state === 'failed'
|
|
467
|
+
const alert = !isQuiet && needsYou && (one.isUnread || !one.actionable)
|
|
468
|
+
const dot = alert || (one.isUnread && !isQuiet)
|
|
469
|
+
const text = one.wakeAt !== undefined ? `${one.label} · ${wakeLabel(one.wakeAt)}` : one.label
|
|
459
470
|
const tint = one.color ? TINT[one.color] : undefined
|
|
460
471
|
return (
|
|
461
472
|
<Box key={viewKey(item)} flexDirection="column">
|
|
462
473
|
<Box>
|
|
463
|
-
<Text color={isHere ? 'yellow' :
|
|
474
|
+
<Text color={isHere ? 'yellow' : alert ? 'red' : 'blue'}>{isHere ? '› ' : dot ? '● ' : ' '}</Text>
|
|
464
475
|
<Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
|
|
465
476
|
{GLYPH[one.state] ?? '·'}{' '}
|
|
466
477
|
</Text>
|
|
@@ -470,11 +481,14 @@ export const register: Register = (on, options) => {
|
|
|
470
481
|
<Button
|
|
471
482
|
key={itemKey(item)}
|
|
472
483
|
plain
|
|
473
|
-
dimColor={isQuiet && !isHere}
|
|
474
|
-
label={
|
|
484
|
+
dimColor={(isQuiet || isRead) && !isHere && tint === undefined}
|
|
485
|
+
label={text}
|
|
475
486
|
onPress={enter(item)}
|
|
476
487
|
autoFocus={isHere || undefined}
|
|
477
|
-
|
|
488
|
+
>
|
|
489
|
+
{/* An unread row is bold, as a mail client marks one; a Button has no weight of its own. */}
|
|
490
|
+
{one.isUnread && !isQuiet ? <Text bold>{text}</Text> : text}
|
|
491
|
+
</Button>
|
|
478
492
|
</Box>
|
|
479
493
|
{/* The label gives up its cells to the PR chip, which would otherwise wrap under it. */}
|
|
480
494
|
{one.pr ? (
|
data/mod/tests/contract.test.tsx
CHANGED
|
@@ -19,4 +19,5 @@ test("the rows the pane reads are the ones the gem's Snapshot writes", () => {
|
|
|
19
19
|
])
|
|
20
20
|
expect(rows[1]?.pr).toEqual({ short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' })
|
|
21
21
|
expect(rows[0]?.isWaiting).toBe(false)
|
|
22
|
+
expect(rows.map(row => row.isUnread)).toEqual([true, false, true])
|
|
22
23
|
})
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -13,6 +13,7 @@ const ROWS = [
|
|
|
13
13
|
{ section: 'needs_you', id: 'a1', label: 'needs an answer', state: 'blocked', actionable: true, line: 'confirm: drop it?' },
|
|
14
14
|
{ section: 'active', id: 'b2', label: 'still going', state: 'working', actionable: true, line: 'running bin/ci', pr: { short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' } },
|
|
15
15
|
{ section: 'active', id: 'uuid-term', label: 'a terminal', state: 'working', actionable: false },
|
|
16
|
+
{ section: 'active', id: 'd4', label: 'finished unseen', state: 'done', actionable: true, isUnread: true },
|
|
16
17
|
{ section: 'settled', id: 'c3', label: 'put away', state: 'done', actionable: true },
|
|
17
18
|
]
|
|
18
19
|
|
|
@@ -97,9 +98,12 @@ test("rows sit under the gem's sections, Settled folded until za opens it", asyn
|
|
|
97
98
|
for (const surface of ['terminal', 'desktop'] as const) {
|
|
98
99
|
const ui = await $.ui.mount({ ...PANE, surface })
|
|
99
100
|
expect(await ui.find({ type: 'Text', text: /Needs you · 1/ })).toBeDefined()
|
|
100
|
-
expect(await ui.find({ type: 'Text', text: /Active ·
|
|
101
|
+
expect(await ui.find({ type: 'Text', text: /Active · 3/ })).toBeDefined()
|
|
101
102
|
expect(await ui.find({ type: 'Text', text: /Settled · 1/ })).toBeDefined()
|
|
102
103
|
expect(await ui.find({ type: 'Text', text: /confirm: drop it\?/ })).toBeDefined()
|
|
104
|
+
expect((await ui.findAll({ type: 'Text', text: '● ' })).filter(dot => dot.props.color === 'blue')).toHaveLength(1)
|
|
105
|
+
expect((await ui.find({ type: 'Text', text: 'finished unseen' }))?.props.bold).toBe(true)
|
|
106
|
+
expect(await ui.find({ type: 'Text', text: 'still going' })).toBeUndefined()
|
|
103
107
|
expect(await ui.find({ type: 'Text', text: /#7 open/ })).toBeDefined()
|
|
104
108
|
expect(await ui.find({ text: /1 folded/ })).toBeDefined()
|
|
105
109
|
expect(await ui.find({ text: /put away/ })).toBeUndefined()
|
|
@@ -14,7 +14,8 @@ export const SNAPSHOT = `{
|
|
|
14
14
|
"session": "u-a1",
|
|
15
15
|
"label": "needs an answer",
|
|
16
16
|
"state": "blocked",
|
|
17
|
-
"actionable": true
|
|
17
|
+
"actionable": true,
|
|
18
|
+
"unread": true
|
|
18
19
|
}
|
|
19
20
|
],
|
|
20
21
|
"active": [
|
|
@@ -38,7 +39,8 @@ export const SNAPSHOT = `{
|
|
|
38
39
|
"session": "u-c3",
|
|
39
40
|
"label": "put away",
|
|
40
41
|
"state": "done",
|
|
41
|
-
"actionable": true
|
|
42
|
+
"actionable": true,
|
|
43
|
+
"unread": true
|
|
42
44
|
}
|
|
43
45
|
]
|
|
44
46
|
}
|
data/mod/types/index.d.ts
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-inbox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Byron
|
|
@@ -116,6 +116,7 @@ files:
|
|
|
116
116
|
- lib/claude_inbox/app/triaging.rb
|
|
117
117
|
- lib/claude_inbox/app/updating.rb
|
|
118
118
|
- lib/claude_inbox/cli.rb
|
|
119
|
+
- lib/claude_inbox/clipboard.rb
|
|
119
120
|
- lib/claude_inbox/config.rb
|
|
120
121
|
- lib/claude_inbox/debug.rb
|
|
121
122
|
- lib/claude_inbox/dialog.rb
|
|
@@ -127,6 +128,8 @@ files:
|
|
|
127
128
|
- lib/claude_inbox/mod.rb
|
|
128
129
|
- lib/claude_inbox/mouse.rb
|
|
129
130
|
- lib/claude_inbox/new_session_form.rb
|
|
131
|
+
- lib/claude_inbox/notifier.rb
|
|
132
|
+
- lib/claude_inbox/notifier_app.rb
|
|
130
133
|
- lib/claude_inbox/painter.rb
|
|
131
134
|
- lib/claude_inbox/palette.rb
|
|
132
135
|
- lib/claude_inbox/paste.rb
|
|
@@ -136,6 +139,7 @@ files:
|
|
|
136
139
|
- lib/claude_inbox/reaper.rb
|
|
137
140
|
- lib/claude_inbox/records.rb
|
|
138
141
|
- lib/claude_inbox/remote/http.rb
|
|
142
|
+
- lib/claude_inbox/remote/icon.icns
|
|
139
143
|
- lib/claude_inbox/remote/icon.png
|
|
140
144
|
- lib/claude_inbox/remote/icon.svg
|
|
141
145
|
- lib/claude_inbox/remote/listener.rb
|
|
@@ -150,6 +154,7 @@ files:
|
|
|
150
154
|
- lib/claude_inbox/settings.rb
|
|
151
155
|
- lib/claude_inbox/slash_commands.rb
|
|
152
156
|
- lib/claude_inbox/snapshot.rb
|
|
157
|
+
- lib/claude_inbox/snooze_time.rb
|
|
153
158
|
- lib/claude_inbox/store.rb
|
|
154
159
|
- lib/claude_inbox/store/entry.rb
|
|
155
160
|
- lib/claude_inbox/store/row.rb
|