claude-inbox 0.7.1 → 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.
@@ -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
- PullRequest = Struct.new(:number, :url, :state, :resolved_at) do
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", "number,state,isDraft,url,closedAt")
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
- ].compact
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.cyan_bold("▶") : " "
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
- marker = sel ? @theme.cyan_bold("▶") : " "
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 = (section == :settled) ? @p.dim(project_text) : @theme.cyan(project_text)
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, section, sel)
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 %i[pinned needs_you active].include?(section)
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
- # Settled stays dim — the section is meant to be quiet.
333
- def style_label(label, row, section, sel)
334
- return @p.dim(label) if section == :settled
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 row.alias_name then sel ? @p.bold.italic(label) : @p.italic(label)
337
- elsif sel then @p.bold(label)
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". Only the first PR is shown.
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) } })
@@ -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) if !@writable && action
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
- def capture(*argv, chdir: nil)
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
@@ -98,9 +98,6 @@ module ClaudeInbox
98
98
 
99
99
  def paint(lines) = @painter.paint(lines)
100
100
 
101
- # Repaint every row on the next frame.
102
- def invalidate = @painter.invalidate
103
-
104
101
  private
105
102
 
106
103
  def measure
data/lib/claude_inbox.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.7.1"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -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: '●', failed: '✗', working: '◐', idle: '◌', done: '✓', stopped: '✓' }
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' : undefined}>{isHere ? '› ' : ' '}</Text>
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={one.wakeAt !== undefined ? `${one.label} · ${wakeLabel(one.wakeAt)}` : one.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 ? (
@@ -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
  })
@@ -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 · 2/ })).toBeDefined()
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
@@ -10,6 +10,7 @@ export type InboxRow = {
10
10
  state: string
11
11
  actionable: boolean
12
12
  isWaiting: boolean
13
+ isUnread: boolean
13
14
  line?: string
14
15
  wakeAt?: number | 'until_woken'
15
16
  remote?: string
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.7.1
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