claude-inbox 0.10.0 → 0.11.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56e25a548ff2899f765523fbf990e66b96f970915c8efafa39fb8834af99f6db
4
- data.tar.gz: 590e72d23244e04517692beee562c53c59cecba10028b16ea9a371560392fcec
3
+ metadata.gz: 4be74e826e16c816d3589869198467177926961e11d885d7c811e6218b67fd2f
4
+ data.tar.gz: b09b5adfa01af2b64b00479faba7ce3c3312a8aec82916e1c0a003e7eec8f4ba
5
5
  SHA512:
6
- metadata.gz: ffb0d572f03f482149ddf17f599d936a99de569340e6dbc61d587a5b736aa1919d78d300ddde35689e6b31b15a020eef350830668b046288df6925e021768ecb
7
- data.tar.gz: 56805ff4fd994c5a22ef94742a801a6693b04b577f3ebfee63bb99271ff5de68f343e54f33011558852c7dbaeb6851124a944aa2b091190f95479ce51d2736fd
6
+ metadata.gz: 93865715d77fe8092727f66c32da8e6cdbbac67068c2ead4af429f35d488d034834c188dc74905e42b0c4ecdea8470a3770d870251c8f229a532f3006cc463b0
7
+ data.tar.gz: 5334bb968b0fc31caea4249abe7b838266a70e9513c2693baa68432bc20f6c8eef3f9ff2491a14af15caa168550a27fffc0d20a2879c953904029a526206f4e1
@@ -47,6 +47,15 @@ module ClaudeInbox
47
47
  select(stops[(idx + delta).clamp(0, stops.size - 1)])
48
48
  end
49
49
 
50
+ # Before a row leaves the list: the stop below it, or above it at the
51
+ # bottom, so working down a section doesn't throw the cursor to the top.
52
+ def select_neighbour
53
+ stops = filtered.selections(@expanded)
54
+ idx = stops.index(@selected)
55
+ rest = stops - [@selected]
56
+ select(rest[idx.clamp(..rest.size - 1)]) if idx && rest.any?
57
+ end
58
+
50
59
  # Tab / Shift-Tab: first selectable row of the next / previous section.
51
60
  def jump_section(dir)
52
61
  sections = filtered
@@ -9,7 +9,7 @@ module ClaudeInbox
9
9
  def open_new_session
10
10
  cwd = selected_session&.cwd || Dir.pwd
11
11
  dirs = SessionRequest.directories(@store.sessions, @trust.call)
12
- @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
12
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), dirs: dirs)
13
13
  end
14
14
 
15
15
  # `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
@@ -33,7 +33,9 @@ module ClaudeInbox
33
33
 
34
34
  def settle_selected
35
35
  return unless require_storable
36
- @store.settle(@selected.key)
36
+ key = @selected.key
37
+ select_neighbour
38
+ @store.settle(key)
37
39
  notice("settled — u brings it back")
38
40
  end
39
41
 
@@ -44,10 +46,16 @@ module ClaudeInbox
44
46
 
45
47
  def snooze_chosen
46
48
  return @modal = Dialog::Prompt.new(:snooze, @modal.id, "") if @modal.choice == :custom
47
- @store.snooze(@modal.id, @modal.choice)
49
+ snooze_modal_row(@modal.choice)
48
50
  @modal = nil
49
51
  end
50
52
 
53
+ # A poll can move the cursor while the menu is up, so only step off a row still under it.
54
+ def snooze_modal_row(choice)
55
+ select_neighbour if @selected == Store::Selection.row(@modal.id)
56
+ @store.snooze(@modal.id, choice)
57
+ end
58
+
51
59
  def open_alias_editor
52
60
  return unless require_storable && require_writable("rename")
53
61
  current = @store.alias_for(@selected.key) || ""
@@ -67,7 +75,7 @@ module ClaudeInbox
67
75
  elsif @modal.kind == :snooze
68
76
  wake_at = SnoozeTime.parse(value, Time.now)
69
77
  return notice("try 45m, 2h, 3d, 17:00, 5pm, fri, tomorrow or tonight") unless wake_at
70
- @store.snooze(@modal.id, wake_at)
78
+ snooze_modal_row(wake_at)
71
79
  elsif value.empty? || PullRequests.valid_url?(value)
72
80
  @store.set_pr(@modal.id, value)
73
81
  @poller.soon
@@ -64,7 +64,7 @@ module ClaudeInbox
64
64
  @out = out
65
65
  @color = color
66
66
  @trust = trust
67
- @renderer = Renderer.new(color: color)
67
+ @renderer = Renderer.new
68
68
  @input = Input.new(input, out)
69
69
  @queue = queue
70
70
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
@@ -275,6 +275,7 @@ module ClaudeInbox
275
275
  tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
276
276
  )
277
277
  frame = @renderer.frame(sections, view)
278
+ frame = frame.plain unless @color # --no-color: decided here and nowhere else
278
279
  placed(frame)
279
280
  @terminal.paint(frame.lines)
280
281
  dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
@@ -3,6 +3,7 @@
3
3
  require "tty-box"
4
4
  require_relative "text"
5
5
  require_relative "text_buffer"
6
+ require_relative "theme"
6
7
 
7
8
  module ClaudeInbox
8
9
  # A small box over the list that claims every key until it answers. Like
@@ -18,6 +19,7 @@ module ClaudeInbox
18
19
  def initialize(kind, id)
19
20
  @kind = kind
20
21
  @id = id
22
+ @theme = Theme.new
21
23
  end
22
24
 
23
25
  def frame(width, caret = nil)
@@ -29,6 +31,13 @@ module ClaudeInbox
29
31
  # How wide the box wants to be; the frame caps it at the terminal.
30
32
  def fit = self.class::WIDTH
31
33
 
34
+ private
35
+
36
+ def key(name) = @theme.cyan_bold(name)
37
+
38
+ # A key and what it does.
39
+ def hint(name, action) = "#{key(name)} #{@theme.dim(action)}"
40
+
32
41
  # `s` snooze: one key per choice, and the choice is read from `choice`.
33
42
  class Snooze < Dialog
34
43
  MENU = [
@@ -45,11 +54,11 @@ module ClaudeInbox
45
54
 
46
55
  def title = " Snooze "
47
56
 
48
- def lines(_width, _caret) = MENU.map { |k, label, _| " #{k} #{label}" } + ["", " esc cancel"]
57
+ def lines(_width, _caret) = MENU.map { |k, label, _| " #{key(k)} #{label}" } + ["", " " + hint("esc", "cancel")]
49
58
 
50
59
  def press(name, key)
51
60
  return :cancel if name == :escape || key == "q"
52
- entry = MENU.find { |k, _, _| k == key }
61
+ entry = MENU.assoc(key)
53
62
  return nil unless entry
54
63
  @choice = entry[2]
55
64
  :snooze
@@ -77,8 +86,8 @@ module ClaudeInbox
77
86
  def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
78
87
 
79
88
  def lines(width, _caret)
80
- (@prs.each_with_index.map { |pr, i| " #{i + 1} #{label(pr)}" } +
81
- ["", " #{KEYS.fetch(kind)} #{VERBS.fetch(kind)} #{@prs.first.short} · esc cancel"])
89
+ (@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
90
+ ["", " #{hint(KEYS.fetch(kind), "#{VERBS.fetch(kind)} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
82
91
  .map { |line| Text.truncate(line, width) }
83
92
  end
84
93
 
@@ -91,10 +100,11 @@ module ClaudeInbox
91
100
 
92
101
  private
93
102
 
94
- # A bare #190 is ambiguous only once the list spans repos.
103
+ # The list's badge, then the title. A bare #190 is ambiguous only
104
+ # once the list spans repos.
95
105
  def label(pr)
96
106
  name = (@prs.map(&:repo).uniq.size > 1) ? pr.qualified : pr.short
97
- ["#{name} #{pr.state&.downcase}".strip, pr.trouble, pr.title].compact.join(" · ")
107
+ pr.title ? @theme.pr_badge(pr, name) + @theme.dim(" · ") + pr.title : @theme.pr_badge(pr, name)
98
108
  end
99
109
  end
100
110
 
@@ -108,13 +118,13 @@ module ClaudeInbox
108
118
 
109
119
  def lines(_width, _caret)
110
120
  case kind
111
- when :stop then [" Stop session #{id}?", "", " y stop it", " esc cancel"]
121
+ when :stop then [" Stop session #{id}?", "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
112
122
  when :delete
113
123
  [" Delete session #{id}?", " Its worktree and conversation", " go with it.",
114
- "", " y delete it", " esc keep it"]
124
+ "", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
115
125
  when :adopt
116
126
  [" Pull this session into the daemon?", " Its phone session ends and a",
117
- " new one takes its place.", "", " y adopt it", " esc cancel"]
127
+ " new one takes its place.", "", " " + hint("y", "adopt it"), " " + hint("esc", "cancel")]
118
128
  end
119
129
  end
120
130
 
@@ -160,7 +170,7 @@ module ClaudeInbox
160
170
  "",
161
171
  " You can install this later with claude-inbox install",
162
172
  "",
163
- " y add it to Claude Code esc not now"]
173
+ " #{hint("y", "add it to Claude Code")} #{hint("esc", "not now")}"]
164
174
  end
165
175
  end
166
176
 
@@ -181,7 +191,8 @@ module ClaudeInbox
181
191
  def title = TITLES.fetch(kind)
182
192
 
183
193
  def lines(width, caret)
184
- [QUESTIONS.fetch(kind), "", " > " + @buffer.row(width - 4, cursor: caret), "", " ⏎ save · esc cancel"]
194
+ [QUESTIONS.fetch(kind), "", " > " + @buffer.row(width - 4, cursor: caret), "",
195
+ " #{key("⏎")} #{@theme.dim("save")} · #{key("esc")} #{@theme.dim("cancel")}"]
185
196
  end
186
197
 
187
198
  def press(name, key)
@@ -15,11 +15,15 @@ module ClaudeInbox
15
15
  # reaps, since deleting sessions stays with the inbox you can see, and it
16
16
  # exits at once while an inbox holds the writer lock.
17
17
  class Headless
18
+ RECHECK_EVERY = 60
19
+
18
20
  def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
19
- lock: WriterLock.new, updater: nil, notifier: Notifier.disabled)
21
+ lock: WriterLock.new, updater: nil, notifier: Notifier.disabled, recheck_every: RECHECK_EVERY)
20
22
  @lock = lock
21
23
  @snapshot = snapshot
22
24
  @updater = updater
25
+ @recheck_every = recheck_every
26
+ @notice = nil
23
27
  @queue = Queue.new
24
28
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
25
29
  queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
@@ -29,7 +33,7 @@ module ClaudeInbox
29
33
  def run
30
34
  return false unless @lock.take(role: "headless")
31
35
  @poller.start
32
- check_for_update if @updater
36
+ Thread.new { watch_for_updates } if @updater
33
37
  loop { @queue.pop }
34
38
  end
35
39
 
@@ -38,8 +42,19 @@ module ClaudeInbox
38
42
  # After the poller is off, since the check may wait on the network. The
39
43
  # pane cannot swap the process under Claude Code, so the snapshot only
40
44
  # says a newer one is out, and a check that cannot tell says nothing.
45
+ # Re-checked because this process outlives an update applied from a
46
+ # terminal; the updater's day-long cache keeps that off the network.
47
+ def watch_for_updates
48
+ loop do
49
+ check_for_update
50
+ sleep @recheck_every
51
+ end
52
+ end
53
+
41
54
  def check_for_update
42
- @snapshot.update = @updater.check&.notice
55
+ notice = @updater.check&.notice
56
+ return if notice == @notice
57
+ @notice = @snapshot.update = notice
43
58
  @poller.soon
44
59
  rescue Updater::Error
45
60
  nil
@@ -14,10 +14,11 @@ module ClaudeInbox
14
14
  # The file tells them apart: `tempo` is the agent's own pulse, `fan` names
15
15
  # each outstanding piece of work, and `detail` is the session's own status
16
16
  # line, with `needs` naming what it is waiting on while blocked and
17
- # `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
18
- # transcript, which PullRequests reads through here, the color `/color`
19
- # set on the session, which `claude agents --json` drops, and `intent`, the
20
- # prompt the session was started from, and `createdAt`, when it was.
17
+ # `output.result` what it produced once done. It also carries the PR links
18
+ # the daemon scanned out of the transcript and the transcript's path, which
19
+ # PullRequests reads through here, the color `/color` set on the session,
20
+ # which `claude agents --json` drops, and `intent`, the prompt the session
21
+ # was started from, and `createdAt`, when it was.
21
22
  #
22
23
  # Never cached. A color changes the moment you type `/color`, so every poll
23
24
  # asks the file again.
@@ -65,7 +66,7 @@ module ClaudeInbox
65
66
  nil
66
67
  end
67
68
 
68
- attr_reader :pr_urls, :color, :intent, :bridge_id, :created_at
69
+ attr_reader :pr_urls, :transcript_path, :color, :intent, :bridge_id, :created_at
69
70
 
70
71
  def initialize(hash)
71
72
  @detail = hash["detail"]
@@ -75,6 +76,7 @@ module ClaudeInbox
75
76
  @kinds = (hash["fan"] || []).filter_map { |f| f["kind"] }
76
77
  @tasks = (hash["inFlight"] || {})["tasks"].to_i
77
78
  @pr_urls = (hash["children"] || []).filter_map { |c| c["href"] if c["kind"] == "pr" }
79
+ @transcript_path = hash["linkScanPath"]
78
80
  @color = hash["color"]
79
81
  @intent = hash["intent"]
80
82
  @bridge_id = hash["bridgeSessionId"]
@@ -35,10 +35,10 @@ module ClaudeInbox
35
35
  # editable field spends these on its own text instead.
36
36
  CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
37
37
 
38
- def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
39
- @p = Pastel.new(enabled: color)
40
- @theme = Theme.new(enabled: color)
41
- @palette = Palette.new(enabled: color)
38
+ def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
39
+ @p = Pastel.new(enabled: true)
40
+ @theme = Theme.new
41
+ @palette = Palette.new
42
42
  @home = home
43
43
  @clipboard = clipboard
44
44
  @places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
@@ -46,6 +46,7 @@ module ClaudeInbox
46
46
  elsif row.session.effective_state == "done" then "finished"
47
47
  end
48
48
  end
49
+ private_class_method :alerts, :rows, :alert
49
50
 
50
51
  def initialize(app: NotifierApp.new, run: Subprocess.method(:capture), enabled: true)
51
52
  @app = app
@@ -22,12 +22,7 @@ module ClaudeInbox
22
22
 
23
23
  RESET = "\e[39m"
24
24
 
25
- def initialize(enabled:)
26
- @enabled = enabled
27
- end
28
-
29
25
  def paint(text, name)
30
- return text unless @enabled
31
26
  seq = SEQUENCES[name]
32
27
  seq ? seq + text + RESET : text
33
28
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "time"
5
5
  require_relative "records"
6
+ require_relative "stack_links"
6
7
  require_relative "subprocess"
7
8
 
8
9
  module ClaudeInbox
@@ -58,6 +59,8 @@ module ClaudeInbox
58
59
  # file, which JobState reads onto `job_state`; `claude agents --json` does
59
60
  # not expose them.
60
61
  # It is a link scan, so a session that merely mentions a PR gets it too.
62
+ # The daemon keeps one PR per session, so StackLinks adds the rest of a
63
+ # stack the session opened.
61
64
  # Interactive sessions have no job file; for those the store's `pr` override
62
65
  # is the only source.
63
66
  #
@@ -87,6 +90,7 @@ module ClaudeInbox
87
90
  @known = {} # url => PullRequest
88
91
  @checked_at = {} # url => epoch seconds of the last gh call
89
92
  @mutex = Mutex.new
93
+ @stacks = StackLinks.new
90
94
  end
91
95
 
92
96
  # Each session with its `prs` set from what is already known, asking
@@ -96,7 +100,7 @@ module ClaudeInbox
96
100
  # override replaces the scanned list.
97
101
  def enrich(sessions, overrides)
98
102
  sessions.map do |s|
99
- urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
103
+ urls = overrides[s.key] ? [overrides[s.key]] : scanned(s.job_state)
100
104
  s.with(prs: urls.map { |u| known(u) })
101
105
  end
102
106
  end
@@ -106,13 +110,8 @@ module ClaudeInbox
106
110
  # the caller knows whether the list is worth publishing again. A merge
107
111
  # date alone counts: it is what lets a PR the cache called merged settle.
108
112
  def refresh(sessions)
109
- changed = false
110
- fresh = sessions.map do |s|
111
- s.with(prs: s.prs.map { |pr|
112
- status(pr.url).tap { |now| changed = true if now != pr }
113
- })
114
- end
115
- [fresh, changed]
113
+ fresh = sessions.map { |s| s.with(prs: s.prs.map { |pr| status(pr.url) }) }
114
+ [fresh, fresh.map(&:prs) != sessions.map(&:prs)]
116
115
  end
117
116
 
118
117
  # Best known state for a url, refreshed through gh when due.
@@ -159,8 +158,15 @@ module ClaudeInbox
159
158
  url.to_s.match?(%r{\Ahttps://github\.com/[^/\s]+/[^/\s]+/pull/\d+/?\z})
160
159
  end
161
160
 
161
+ private_class_method :health
162
+
162
163
  private
163
164
 
165
+ def scanned(job_state)
166
+ return [] unless job_state
167
+ (job_state.pr_urls + @stacks.urls(job_state.transcript_path)).uniq
168
+ end
169
+
164
170
  # Best known state for a url without asking gh.
165
171
  def known(url)
166
172
  @mutex.synchronize { @known[url] ||= seed(url) }
@@ -86,7 +86,8 @@ module ClaudeInbox
86
86
 
87
87
  def key_lines(s)
88
88
  return [" paired phones get 401 until they pair again —", " rotate? y/n"] if @asking
89
- (s.state == :listening) ? [" c copy r new token esc close"] : [" esc close"]
89
+ keys = (s.state == :listening) ? [%w[c copy], ["r", "new token"], %w[esc close]] : [%w[esc close]]
90
+ [" " + keys.map { |k, action| "#{key(k)} #{@theme.dim(action)}" }.join(" ")]
90
91
  end
91
92
  end
92
93
  end
@@ -11,7 +11,10 @@ module ClaudeInbox
11
11
  # `width` columns wide) plus a parallel Array of selectable items.
12
12
  # Pure: no terminal, no IO, no clock beyond the `now` it is handed.
13
13
  class Renderer
14
- Frame = Struct.new(:lines, :items, :top)
14
+ Frame = Struct.new(:lines, :items, :top) do
15
+ # The same frame for a terminal drawn without color.
16
+ def plain = self.class.new(lines.map { |l| Text.strip_ansi(l) }, items, top)
17
+ end
15
18
 
16
19
  CHROME_ROWS = 2 # header + footer
17
20
 
@@ -35,14 +38,6 @@ module ClaudeInbox
35
38
 
36
39
  SECTION_HUES = {pinned: :cyan, needs_you: :red, active: :yellow, snoozed: :purple}.freeze
37
40
 
38
- # GitHub's colors. A draft has no hue, so it dims; a state missing from
39
- # the table isn't named on the badge at all.
40
- PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
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
-
46
41
  SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
47
42
 
48
43
  # The first poll usually lands inside a second. Past LOADING_QUIET the
@@ -67,10 +62,10 @@ module ClaudeInbox
67
62
  ["za", "fold"], ["/", "filter"], ["q", "quit"]
68
63
  ].freeze
69
64
 
70
- def initialize(color:, home: Dir.home)
71
- @p = Pastel.new(enabled: color)
72
- @theme = Theme.new(enabled: color)
73
- @palette = Palette.new(enabled: color)
65
+ def initialize(home: Dir.home)
66
+ @p = Pastel.new(enabled: true)
67
+ @theme = Theme.new
68
+ @palette = Palette.new
74
69
  @home = home
75
70
  end
76
71
 
@@ -178,15 +173,19 @@ module ClaudeInbox
178
173
  header_chip(settled, "◦", "settled", compact) { |s| @p.dim(s) }
179
174
  ]
180
175
  chips << @p.dim("nothing running") if sections.all.empty?
181
- [*chips, listening_chip(listening)].compact.join(compact ? " " : @p.dim(" · "))
182
- end
183
-
184
- # Red when the listener was asked for and isn't up. Nothing when it
185
- # wasn't asked for.
186
- def listening_chip(listening)
187
- case listening&.state
188
- when :listening then @theme.blue("◉ #{"lan" if listening.lan}:#{listening.port}")
189
- when :in_use, :held, :failed then @theme.red("◉ !")
176
+ [*chips, listening_chip(listening, compact)].compact.join(compact ? " " : @p.dim(" · "))
177
+ end
178
+
179
+ # Red, and saying why, when the listener was asked for and isn't up.
180
+ # Nothing when it wasn't asked for.
181
+ def listening_chip(listening, compact)
182
+ return unless listening
183
+ address = "#{"lan" if listening.lan}:#{listening.port}"
184
+ case listening.state
185
+ when :listening then @theme.blue("◉ #{address}")
186
+ when :held then @theme.red(compact ? "◉ #{address} held" : "◉ #{address} held by another inbox")
187
+ when :in_use then @theme.red("◉ #{address} in use")
188
+ when :failed then @theme.red("◉ #{address} failed")
190
189
  end
191
190
  end
192
191
 
@@ -216,20 +215,14 @@ module ClaudeInbox
216
215
  Text.pad(" " + section_color(name, "▎") + section_color(name, @p.bold(title)) + @p.dim("─" * fill) + @p.dim(count_s), width)
217
216
  end
218
217
 
219
- def section_color(name, s) = tint(SECTION_HUES[name], s)
220
-
221
- def tint(hue, s)
222
- hue ? @theme.public_send(hue, s) : @p.dim(s)
223
- end
218
+ def section_color(name, s) = @theme.tint(SECTION_HUES[name], s)
224
219
 
225
220
  # ----- body ---------------------------------------------------------------
226
221
 
227
222
  def body_lines(sections, width, view)
223
+ return [empty_state(width), []] if sections.all.empty?
228
224
  lines = []
229
225
  items = []
230
- if sections.all.empty?
231
- return [empty_state(width), []]
232
- end
233
226
  selected = view.selected
234
227
  sections.each_pair do |name, rows|
235
228
  next if rows.empty?
@@ -324,7 +317,7 @@ module ClaudeInbox
324
317
 
325
318
  label_w = [width - chrome - Text.width(project_text), 0].max
326
319
  label = Text.truncate(row.label, label_w)
327
- label = style_label(label, row, quiet, sel || (live && row.unread?))
320
+ label = style_label(label, row, quiet, sel || unread)
328
321
  first = " #{marker} #{glyph} " + Text.pad(label, label_w) + " " + meta + " " + project + " "
329
322
 
330
323
  return [Text.pad(first, width)] unless live
@@ -399,20 +392,13 @@ module ClaudeInbox
399
392
  pr ? base + @p.dim(" · ") + pr : base
400
393
  end
401
394
 
402
- # "#885 open", or "#885 open · ✗ checks" when something is wrong. The
403
- # headline PR is shown, with a count of the rest.
395
+ # The headline PR, with a count of the rest. A settled row dims it.
404
396
  def pr_badge(s, section)
405
397
  pr = s.pr
406
398
  return nil unless pr
399
+ badge = (section == :settled) ? @p.dim("#{pr.short} #{pr.state&.downcase}".strip) : @theme.pr_badge(pr)
407
400
  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)
412
- return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
413
- return @p.dim(pr.short) unless PR_HUES.key?(pr.state)
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
401
+ badge + more
416
402
  end
417
403
 
418
404
  def state_badge(s)
@@ -65,5 +65,6 @@ module ClaudeInbox
65
65
  day = now.to_date + days
66
66
  Time.new(day.year, day.month, day.day, hour, minute, 0, now.utc_offset).to_i
67
67
  end
68
+ private_class_method :clock, :weekday, :next_at, :at
68
69
  end
69
70
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeInbox
6
+ # The PRs a session opened with `gh stack submit`, read from its
7
+ # transcript. The daemon links a session to one PR only, so a stack of
8
+ # three shows as its first; this finds the rest.
9
+ #
10
+ # Transcripts run to megabytes and every poll asks, so each file is read
11
+ # once and then only for what was appended since.
12
+ class StackLinks
13
+ # gh-stack's line for each PR it opens. Matched against a Bash result's
14
+ # own stdout, at the start of a line, so a session that merely quotes
15
+ # one doesn't count.
16
+ CREATED = %r{^✓ Created PR #\d+ \((https://github\.com/[^/\s]+/[^/\s]+/pull/\d+)\)}
17
+
18
+ def initialize
19
+ @seen = {} # path => [bytes read, urls]
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # => the PR urls in creation order, [] for a missing or unreadable file.
24
+ def urls(path)
25
+ return [] unless path
26
+ @mutex.synchronize do
27
+ offset, urls = @seen.fetch(path, [0, []])
28
+ offset, urls = 0, [] if File.size(path) < offset
29
+ added = File.open(path, "r:UTF-8") { |f| f.seek(offset) && f.read }
30
+ complete = added.include?("\n") ? added[..added.rindex("\n")] : ""
31
+ @seen[path] = [offset + complete.bytesize, (urls + scan(complete)).uniq]
32
+ @seen[path].last
33
+ end
34
+ rescue SystemCallError
35
+ []
36
+ end
37
+
38
+ private
39
+
40
+ def scan(text)
41
+ text.each_line.select { |l| l.include?("Created PR #") }.flat_map do |line|
42
+ stdout = JSON.parse(line).dig("toolUseResult", "stdout")
43
+ stdout.is_a?(String) ? stdout.scan(CREATED).flatten : []
44
+ rescue JSON::ParserError
45
+ []
46
+ end
47
+ end
48
+ end
49
+ end
@@ -14,8 +14,7 @@ module ClaudeInbox
14
14
  def section_of(selection)
15
15
  return nil if selection.nil?
16
16
  return selection.key if selection.fold?
17
- each_pair { |name, rows| return name if rows.any? { |r| r.key == selection.key } }
18
- nil
17
+ each_pair.find { |_, rows| rows.any? { |r| r.key == selection.key } }&.first
19
18
  end
20
19
 
21
20
  def selections(expanded) = stops(expanded).flat_map(&:last)
@@ -7,6 +7,8 @@ module ClaudeInbox
7
7
  # session names carry emoji and glyphs that break byte/char slicing.
8
8
  module Text
9
9
  ANSI = /\e\[[0-9;?]*[A-Za-z]/
10
+ # A grapheme, or an escape that takes no column.
11
+ GLYPH = /#{ANSI}|\X/
10
12
  ELLIPSIS = "…"
11
13
 
12
14
  module_function
@@ -21,25 +23,26 @@ module ClaudeInbox
21
23
 
22
24
  def width(s) = Unicode::DisplayWidth.of(strip_ansi(s))
23
25
 
24
- # Truncate plain (uncolored) text to `w` columns, appending an ellipsis
25
- # when anything was cut. Handles wide glyphs by stepping grapheme by grapheme.
26
+ # Truncate to `w` columns, appending an ellipsis when anything was cut.
27
+ # Handles wide glyphs by stepping grapheme by grapheme.
26
28
  def truncate(s, w)
27
29
  return "" if w <= 0
28
30
  return s if width(s) <= w
29
31
  take(s, w - width(ELLIPSIS)) << ELLIPSIS
30
32
  end
31
33
 
32
- # First `n` columns of plain text, no ellipsis.
34
+ # First `n` columns, no ellipsis. Colors survive the cut, and a reset
35
+ # closes whichever one the cut left open.
33
36
  def take(s, n)
34
37
  out = +""
35
38
  used = 0
36
- s.each_grapheme_cluster do |g|
37
- gw = Unicode::DisplayWidth.of(g)
39
+ s.scan(GLYPH) do |g|
40
+ gw = g.start_with?("\e") ? 0 : Unicode::DisplayWidth.of(g)
38
41
  break if used + gw > n
39
42
  out << g
40
43
  used += gw
41
44
  end
42
- out
45
+ (out.include?("\e") && !out.end_with?("\e[0m")) ? out << "\e[0m" : out
43
46
  end
44
47
 
45
48
  # Plain text with its first `n` columns removed.
@@ -56,14 +59,10 @@ module ClaudeInbox
56
59
  out
57
60
  end
58
61
 
59
- # Right-pad (ANSI-aware) to exactly `w` columns. Truncates if too long.
62
+ # Right-pad to exactly `w` columns. Truncates if too long.
60
63
  def pad(s, w)
61
- cur = width(s)
62
- if cur > w
63
- s = truncate(strip_ansi(s), w)
64
- cur = width(s)
65
- end
66
- s + (" " * [w - cur, 0].max)
64
+ s = truncate(s, w)
65
+ s + (" " * [w - width(s), 0].max)
67
66
  end
68
67
 
69
68
  # Greedy word wrap on display width. Words wider than `w` are split.
@@ -8,26 +8,39 @@ module ClaudeInbox
8
8
  HUES = {red: 203, green: 108, yellow: 179, blue: 75, purple: 176, cyan: 73}.freeze
9
9
  BG = 236
10
10
 
11
+ # GitHub's colors. A draft has no hue, so it dims; a state missing from
12
+ # the table isn't named on the badge at all.
13
+ PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
14
+
15
+ # The hue of PR_TROUBLE after a badge. Red for what puts the row in
16
+ # Needs You; a conflict dims, since it waits on a rebase, not on you.
17
+ PR_TROUBLE_HUES = {"FAILING" => :red, "CHANGES_REQUESTED" => :red}.freeze
18
+
11
19
  HUES.each_key do |hue|
12
20
  define_method(hue) { |text| color(text, hue) }
13
21
  define_method(:"#{hue}_bold") { |text| color(text, hue, bold: true) }
14
22
  end
15
23
 
16
- def initialize(enabled:)
17
- @enabled = enabled
24
+ def dim(text) = "\e[2m#{text}\e[0m"
25
+
26
+ # The hue, or dim when there is none.
27
+ def tint(hue, text) = hue ? public_send(hue, text) : dim(text)
28
+
29
+ # "#885 open", or "#885 open · ✗ checks" when something is wrong.
30
+ # `name` replaces the bare number where that would be ambiguous.
31
+ def pr_badge(pr, name = pr.short)
32
+ return dim(name) unless PR_HUES.key?(pr.state)
33
+ badge = tint(PR_HUES[pr.state], "#{name} #{pr.state.downcase}")
34
+ pr.trouble ? badge + dim(" · ") + tint(PR_TROUBLE_HUES[pr.health], pr.trouble) : badge
18
35
  end
19
36
 
20
37
  # A filled pill: editor-background text on the hue, for a selected row
21
38
  # or choice — the inverse of `color`, which puts the hue on the text.
22
- def pill(text, hue)
23
- return text unless @enabled
24
- "\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
25
- end
39
+ def pill(text, hue) = "\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
26
40
 
27
41
  private
28
42
 
29
43
  def color(text, hue, bold: false)
30
- return text unless @enabled
31
44
  codes = ["38;5;#{HUES.fetch(hue)}"]
32
45
  codes << "1" if bold
33
46
  "\e[#{codes.join(";")}m#{text}\e[0m"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "subprocess"
4
5
 
5
6
  module ClaudeInbox
6
7
  # One snapshot writer per machine: the inbox you can see, else a headless
@@ -25,8 +26,8 @@ module ClaudeInbox
25
26
  file = File.open(@path, File::RDWR | File::CREAT, 0o600)
26
27
  return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
27
28
  holder_role, holder = file.read.split
28
- return refuse(file) unless role == "inbox" && holder_role == "headless"
29
- Process.kill("TERM", holder.to_i) if holder.to_i.positive? && holder.to_i != Process.pid
29
+ return refuse(file) unless role == "inbox" && holder_role == "headless" && headless?(holder.to_i)
30
+ Process.kill("TERM", holder.to_i)
30
31
  EVICT_TRIES.times do
31
32
  return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
32
33
  sleep 0.1
@@ -39,6 +40,14 @@ module ClaudeInbox
39
40
 
40
41
  private
41
42
 
43
+ # The file can name a pid that no longer holds the lock (#322), which by
44
+ # now may be some other process; only a headless claude-inbox is ended.
45
+ def headless?(pid)
46
+ return false unless pid.positive? && pid != Process.pid
47
+ command = Subprocess.capture("ps", "-o", "command=", "-p", pid.to_s).out
48
+ command.include?("claude-inbox") && command.split.include?("--headless")
49
+ end
50
+
42
51
  # After reading the holder the position sits at the end, and truncate does
43
52
  # not move it, so the write is rewound first or it lands after NUL padding.
44
53
  def hold(file, role)
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.10.0"
4
+ VERSION = "0.11.1"
5
5
  end
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.10.0
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron
@@ -155,6 +155,7 @@ files:
155
155
  - lib/claude_inbox/slash_commands.rb
156
156
  - lib/claude_inbox/snapshot.rb
157
157
  - lib/claude_inbox/snooze_time.rb
158
+ - lib/claude_inbox/stack_links.rb
158
159
  - lib/claude_inbox/store.rb
159
160
  - lib/claude_inbox/store/entry.rb
160
161
  - lib/claude_inbox/store/row.rb