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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -8
  3. data/lib/claude_inbox/actions.rb +10 -2
  4. data/lib/claude_inbox/agents_client.rb +1 -1
  5. data/lib/claude_inbox/app/browsing.rb +35 -4
  6. data/lib/claude_inbox/app/triaging.rb +5 -0
  7. data/lib/claude_inbox/app.rb +10 -3
  8. data/lib/claude_inbox/cli.rb +7 -2
  9. data/lib/claude_inbox/clipboard.rb +35 -0
  10. data/lib/claude_inbox/config.rb +1 -1
  11. data/lib/claude_inbox/dialog.rb +45 -5
  12. data/lib/claude_inbox/headless.rb +3 -2
  13. data/lib/claude_inbox/images.rb +4 -1
  14. data/lib/claude_inbox/keymap.rb +1 -1
  15. data/lib/claude_inbox/mouse.rb +5 -5
  16. data/lib/claude_inbox/new_session_form.rb +1 -1
  17. data/lib/claude_inbox/notifier.rb +85 -0
  18. data/lib/claude_inbox/notifier_app.rb +82 -0
  19. data/lib/claude_inbox/poller.rb +14 -6
  20. data/lib/claude_inbox/pull_requests.rb +43 -6
  21. data/lib/claude_inbox/remote/icon.icns +0 -0
  22. data/lib/claude_inbox/renderer.rb +45 -15
  23. data/lib/claude_inbox/session.rb +14 -1
  24. data/lib/claude_inbox/snapshot.rb +1 -1
  25. data/lib/claude_inbox/snooze_time.rb +69 -0
  26. data/lib/claude_inbox/store/entry.rb +20 -0
  27. data/lib/claude_inbox/store/row.rb +22 -1
  28. data/lib/claude_inbox/store/sections.rb +0 -4
  29. data/lib/claude_inbox/store.rb +1 -1
  30. data/lib/claude_inbox/subprocess.rb +17 -2
  31. data/lib/claude_inbox/terminal.rb +0 -3
  32. data/lib/claude_inbox.rb +1 -1
  33. data/mod/hooks/register.tsx +23 -10
  34. data/mod/tests/contract.test.tsx +1 -0
  35. data/mod/tests/pane.test.tsx +27 -1
  36. data/mod/tests/snapshot.fixture.ts +4 -2
  37. data/mod/types/index.d.ts +1 -0
  38. 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
@@ -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 applies what another front end
11
- # asked for. Nothing here touches App's state directly; the queue is the
12
- # whole of the interface. One worker runs every poll, so two can never
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
- write_snapshot
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
- PullRequest = Struct.new(:number, :url, :state, :resolved_at) do
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", "number,state,isDraft,url,closedAt")
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
- ].compact
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.cyan_bold("▶") : " "
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
- marker = sel ? @theme.cyan_bold("▶") : " "
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 = (section == :settled) ? @p.dim(project_text) : @theme.cyan(project_text)
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, section, sel)
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 %i[pinned needs_you active].include?(section)
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
- # 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
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 row.alias_name then sel ? @p.bold.italic(label) : @p.italic(label)
337
- elsif sel then @p.bold(label)
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". Only the first PR is shown.
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)
@@ -99,6 +99,19 @@ module ClaudeInbox
99
99
 
100
100
  def project = cwd ? File.basename(cwd) : ""
101
101
 
102
- def pr = prs.first
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) } })
@@ -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.9.0"
5
5
  end