claude-inbox 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56e25a548ff2899f765523fbf990e66b96f970915c8efafa39fb8834af99f6db
4
- data.tar.gz: 590e72d23244e04517692beee562c53c59cecba10028b16ea9a371560392fcec
3
+ metadata.gz: 7e14abc910988446bead1ad4edc841f64fe256e396765cea5a6b77cd6ae28777
4
+ data.tar.gz: 8e705e40ce80454dd1d8265f567128fae801286b7112d84f3e63ef00b0228913
5
5
  SHA512:
6
- metadata.gz: ffb0d572f03f482149ddf17f599d936a99de569340e6dbc61d587a5b736aa1919d78d300ddde35689e6b31b15a020eef350830668b046288df6925e021768ecb
7
- data.tar.gz: 56805ff4fd994c5a22ef94742a801a6693b04b577f3ebfee63bb99271ff5de68f343e54f33011558852c7dbaeb6851124a944aa2b091190f95479ce51d2736fd
6
+ metadata.gz: 895a773cf9237ad41e23826fb4b29f863b55dbbe81954abb9fe3337530b4f9ef77e19642a2a7bfed1bad1710d59e1ca705deabee42205fe52d9e63c7079af1a5
7
+ data.tar.gz: 211a75470bb66188394cfbb7b0809f0e685e37e4d6c59366b32a8dc3e90fc4cb5f78d5b6ff4c1b42c78ef68d720dcfd29327446f283f0f2343233d0710505e3a
@@ -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
@@ -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,7 +54,7 @@ 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"
@@ -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)
@@ -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.
@@ -161,6 +160,11 @@ module ClaudeInbox
161
160
 
162
161
  private
163
162
 
163
+ def scanned(job_state)
164
+ return [] unless job_state
165
+ (job_state.pr_urls + @stacks.urls(job_state.transcript_path)).uniq
166
+ end
167
+
164
168
  # Best known state for a url without asking gh.
165
169
  def known(url)
166
170
  @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?
@@ -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"
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.0"
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.0
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