claude-inbox 0.13.1 → 0.14.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: 36fa381f8d03173896c6b44dff47534ed54119c9ff529833a9783c7f5c524506
4
- data.tar.gz: '0386da94a6ffa5259bbad165857821f44b082dc5a64d957beae352c46dc61100'
3
+ metadata.gz: 6f7809d0909d4391440edb2d01d0452b976b28ed59832d21a05518a11ef164b1
4
+ data.tar.gz: 106434e33d35e297d312b441bfa1ff333a16a7faddd54443c65ba006a6dcf95e
5
5
  SHA512:
6
- metadata.gz: bb933d5723ee131285824d2faea2570762854a4695da2ecd98716d5b9d981b2b36aece7abbda8acf92fa063b47f52f40ac2931136ef5fecee194f0fda7cdc72c
7
- data.tar.gz: 3593591f4192056035fdc13e3480a26824794be46df0d8aa5b72fdb5c84a4b6b039d658b4279ec64cdf35ec94c2d65533117321c01bdd9b3e06a4267571a83b6
6
+ metadata.gz: cf8d43ffd7d434977f4bcb18acb1d06817854e92973cad5e5e8d7d8efd8e611e82dc1820499f65c50c99f0b12a5b6d3d7d3a18235c1ad693c0efa2325be348a6
7
+ data.tar.gz: c0560e7bd6444fbf325618d8b2eb7172d74b0d63d331058cf9aca5afbcf88da1f0fd5a4d31c1576e808a4b022cf3933247bc61c5ba229f4543b171805e2aa3df
@@ -7,12 +7,6 @@ module ClaudeInbox
7
7
  module Navigating
8
8
  private
9
9
 
10
- # Remembers where the frame put the rows, so a click can find one.
11
- def placed(frame)
12
- @row_items = frame.items
13
- @top = frame.top
14
- end
15
-
16
10
  # The row to land on once a poll lists it, as after a spawn.
17
11
  def select_later(id)
18
12
  @pending_select = id
@@ -100,7 +94,7 @@ module ClaudeInbox
100
94
  # no item of its own; a click there resolves to the row above it.
101
95
  def row_item_at(row)
102
96
  idx = row - 1
103
- @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
97
+ @frame.items[idx] || (@frame.items[idx - 1] if idx > 0 && @frame.items[idx - 1]&.row?)
104
98
  end
105
99
  end
106
100
  end
@@ -71,8 +71,8 @@ module ClaudeInbox
71
71
  reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
72
72
  @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
73
73
  @selected = nil
74
- @row_items = []
75
- @top = 0
74
+ @frame = Renderer::Frame.new([], [], 0)
75
+ @pointer_at = nil
76
76
  @expanded = Hash.new(false)
77
77
  @keymap = Keymap.new
78
78
  @tick = 0
@@ -190,6 +190,9 @@ module ClaudeInbox
190
190
  # the normal action table (see handle_key); mouse input defers to the
191
191
  # same rule rather than reaching past whatever has focus.
192
192
  def handle_mouse(event)
193
+ @pointer_at = [event.row, event.col]
194
+ link = link_under_pointer
195
+ return click_link(link) if event.kind == :click && link
193
196
  return if @modal || filter_editing?
194
197
  case event.kind
195
198
  when :click then click_row(event.row)
@@ -198,6 +201,15 @@ module ClaudeInbox
198
201
  end
199
202
  end
200
203
 
204
+ def click_link(link)
205
+ return open_in_browser(link.url, link.text) unless @modal
206
+ pr_picked if @modal.pick_by_url(link.url)
207
+ end
208
+
209
+ def link_under_pointer
210
+ @frame.link_at(*@pointer_at) if @pointer_at && !filter_editing?
211
+ end
212
+
201
213
  def handle_modal_key(name, key)
202
214
  return handle_form_key(name, key) if @modal.is_a?(NewSessionForm)
203
215
  case @modal.press(name, key)
@@ -274,15 +286,15 @@ module ClaudeInbox
274
286
  ensure_selection(sections)
275
287
  @tick += 1
276
288
  view = Renderer::View.new(
277
- width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
289
+ width: width, height: height, now: now, selected: @selected&.key, top: @frame.top, expanded: @expanded,
278
290
  modal: modal_lines(width), screen: screen_lines(width, body_h),
279
291
  status: status_text(now), usage: @rate_limits.windows(now), filter: filter, filter_editing: filter_editing?,
280
292
  tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
281
293
  )
282
- frame = @renderer.frame(sections, view)
283
- frame = frame.plain unless @color # --no-color: decided here and nowhere else
284
- placed(frame)
285
- @terminal.paint(frame.lines)
294
+ @frame = @renderer.frame(sections, view)
295
+ @frame = @frame.plain unless @color # --no-color: decided here and nowhere else
296
+ @terminal.paint(@frame.lines)
297
+ @terminal.pointer(!link_under_pointer.nil?)
286
298
  dt = monotonic - t0
287
299
  Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
288
300
  end
@@ -33,6 +33,11 @@ module ClaudeInbox
33
33
  # How wide the box wants to be; the frame caps it at the terminal.
34
34
  def fit = self.class::WIDTH
35
35
 
36
+ def bordered(rows, inner)
37
+ top = Text.truncate(title, inner)
38
+ ["┌#{top}#{"─" * (inner - Text.width(top))}┐", *rows.map { |row| "│#{row}│" }, "└#{"─" * inner}┘"]
39
+ end
40
+
36
41
  def key(name) = @theme.cyan_bold(name)
37
42
 
38
43
  # A key and what it does.
@@ -83,6 +88,18 @@ module ClaudeInbox
83
88
 
84
89
  def title = " #{kind.to_s.capitalize} pull request "
85
90
 
91
+ # Drawn by hand: TTY::Box counts a link's hidden characters toward the
92
+ # line's width, so it would clip every line that has a link.
93
+ def frame(width, _caret = nil)
94
+ inner = [width - 4, fit].min - 2
95
+ bordered(lines(inner - 2, nil).map { |l| " #{Text.pad(l, inner - 2)} " }, inner)
96
+ end
97
+
98
+ def pick_by_url(url)
99
+ @choice = @prs.find { |pr| pr.url == url }
100
+ @choice && :pick
101
+ end
102
+
86
103
  def lines(width, _caret)
87
104
  (@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
88
105
  ["", " #{hint(KEYS.fetch(kind), "#{kind} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
@@ -127,13 +144,13 @@ module ClaudeInbox
127
144
 
128
145
  def lines(width, _caret)
129
146
  case kind
130
- when :stop then [ask("Stop", width), "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
147
+ when :stop then [ask("Stop", width), *answers("stop it", "cancel")]
131
148
  when :delete
132
149
  [ask("Delete", width), " Its worktree and conversation", " go with it.",
133
- "", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
150
+ *answers("delete it", "keep it")]
134
151
  when :adopt
135
152
  [" Pull this session into the daemon?", " Its phone session ends and a",
136
- " new one takes its place.", "", " " + hint("y", "adopt it"), " " + hint("esc", "cancel")]
153
+ " new one takes its place.", *answers("adopt it", "cancel")]
137
154
  end
138
155
  end
139
156
 
@@ -147,6 +164,8 @@ module ClaudeInbox
147
164
 
148
165
  # One line, so a long name is cut rather than wrapped mid-word.
149
166
  def ask(verb, width) = " #{verb} #{Text.truncate(@label.to_s, width - Text.width(verb) - 4)}?"
167
+
168
+ def answers(yes, no) = ["", " " + hint("y", yes), " " + hint("esc", no)]
150
169
  end
151
170
 
152
171
  # The first launch without the Claude Code pane asks once whether to add
@@ -170,9 +189,7 @@ module ClaudeInbox
170
189
  # pushes the right border out on every line that has one.
171
190
  def frame(width, _caret = nil)
172
191
  inner = [width - 4, WIDTH].min - 2
173
- ["┌#{title}#{"─" * (inner - Text.width(title))}┐",
174
- *lines(inner, nil).map { |l| "│#{Text.pad(Text.truncate(l, inner), inner)}│" },
175
- "└#{"─" * inner}┘"]
192
+ bordered(lines(inner, nil).map { |l| Text.pad(Text.truncate(l, inner), inner) }, inner)
176
193
  end
177
194
 
178
195
  def lines(_width, _caret)
@@ -2,31 +2,32 @@
2
2
 
3
3
  module ClaudeInbox
4
4
  # Parses SGR mouse reports ("\e[<Cb;Cx;Cy(M|m)", emitted once App turns on
5
- # \e[?1000h\e[?1006h) into clicks and wheel ticks. Pure: no terminal, no
6
- # IO. Row is the terminal's own 1-based row, so callers can index straight
7
- # into what they just painted.
5
+ # \e[?1003h\e[?1006h) into clicks, pointer moves and wheel ticks. Pure: no
6
+ # terminal, no IO. Row and column are the terminal's own 1-based ones, so
7
+ # callers can index straight into what they just painted.
8
8
  module Mouse
9
9
  SEQUENCE = /\e\[<(\d+);(\d+);(\d+)([Mm])/
10
10
 
11
- Event = Struct.new(:kind, :row)
11
+ Event = Struct.new(:kind, :row, :col)
12
12
 
13
13
  # A raw keypress can glue several reports together the same way a fast
14
14
  # "esc gg" glues onto one read (see Input#split_keys), so this scans
15
15
  # rather than matching once.
16
16
  def self.events(raw)
17
- raw.scan(SEQUENCE).filter_map do |cb, _col, row, type|
17
+ raw.scan(SEQUENCE).filter_map do |cb, col, row, type|
18
18
  kind = kind_for(cb.to_i, type)
19
- Event.new(kind, row.to_i) if kind
19
+ Event.new(kind, row.to_i, col.to_i) if kind
20
20
  end
21
21
  end
22
22
 
23
- # Bit 6 (0x40) marks a wheel tick, direction in bit 0. Otherwise this is
24
- # a button press/release: bits 0-1 give the button (0 = left) and bit 5
25
- # (0x20) marks a drag, so 0x23 masks both at once. Only a left click,
26
- # released of any drag, counts; middle/right buttons and drags are left
27
- # for the terminal's own handling.
23
+ # Bit 6 (0x40) marks a wheel tick, direction in bit 0. Otherwise bits
24
+ # 0-1 give the button (0 = left, 3 = none) and bit 5 (0x20) marks motion,
25
+ # so 0x23 masks both at once. Only a left click, released of any drag,
26
+ # counts, and motion with no button held is the pointer moving; middle
27
+ # and right buttons and drags are left for the terminal's own handling.
28
28
  def self.kind_for(cb, type)
29
29
  return (cb.even? ? :scroll_up : :scroll_down) if cb & 0x40 != 0
30
+ return :move if cb & 0x23 == 0x23
30
31
  :click if type == "M" && cb & 0x23 == 0
31
32
  end
32
33
  private_class_method :kind_for
@@ -333,7 +333,7 @@ module ClaudeInbox
333
333
 
334
334
  def prompt_box(f, w, h)
335
335
  on = f.equal?(focused)
336
- edge = on ? ->(s) { @theme.cyan(s) } : ->(s) { @p.dim(s) }
336
+ edge = ->(s) { @theme.tint((:cyan if on), s) }
337
337
  rows, hidden = f.value.view(w - 4, h, cursor: (caret if on), chip: ->(s) { @theme.cyan_bold(s) })
338
338
  rows = [@p.dim("What should this session do?")] if f.value.empty? && !on
339
339
  rows += [""] * (h - rows.size)
@@ -169,7 +169,7 @@ module ClaudeInbox
169
169
  end
170
170
 
171
171
  def seed(url)
172
- number = url[%r{/pull/(\d+)}, 1]&.to_i
172
+ number = url[%r{/(?:pull|-/merge_requests)/(\d+)}, 1]&.to_i
173
173
  cached = resolved[url] || claude_cache[url]
174
174
  PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
175
175
  resolved_at: cached&.dig("resolved_at"), title: cached&.dig("title"))
@@ -12,7 +12,12 @@ module ClaudeInbox
12
12
  class Renderer
13
13
  Frame = Struct.new(:lines, :items, :top) do
14
14
  # The same frame for a terminal drawn without color.
15
- def plain = self.class.new(lines.map { |l| Text.strip_ansi(l) }, items, top)
15
+ def plain = self.class.new(lines.map { |l| l.gsub(Text::CSI, "") }, items, top)
16
+
17
+ def link_at(row, col)
18
+ @links ||= lines.map { |l| Text.links(l) }
19
+ @links.fetch(row - 1, []).find { |link| link.columns.cover?(col - 1) }
20
+ end
16
21
  end
17
22
 
18
23
  CHROME_ROWS = 2 # header + footer
@@ -396,7 +401,7 @@ module ClaudeInbox
396
401
  def pr_badge(s, section)
397
402
  pr = s.pr
398
403
  return nil unless pr
399
- badge = (section == :settled) ? @p.dim("#{pr.short} #{pr.state&.downcase}".strip) : @theme.pr_badge(pr)
404
+ badge = @theme.pr_badge(pr, quiet: section == :settled)
400
405
  more = (s.prs.size > 1) ? @p.dim(" · +#{s.prs.size - 1}") : ""
401
406
  badge + more
402
407
  end
@@ -434,7 +439,8 @@ module ClaudeInbox
434
439
  block_w = block.map { |l| Text.width(l) }.max || 0
435
440
  left = [(width - block_w) / 2, 0].max
436
441
  top = [(lines.size - block.size) / 2, 0].max
437
- out = lines.dup
442
+ # Nothing behind a pop-up is clickable.
443
+ out = lines.map { |l| l.gsub(Text::LINK, "") }
438
444
  block.each_with_index do |bl, i|
439
445
  y = top + i
440
446
  next if y >= out.size
@@ -17,10 +17,14 @@ module ClaudeInbox
17
17
  # know the mode ignore it and keep scrolling their own history.
18
18
  WHEEL_KEYS_ON = "\e[?1007h"
19
19
  WHEEL_KEYS_OFF = "\e[?1007l"
20
- # Button events in the SGR encoding, which Mouse parses. A terminal that
21
- # knows neither mode falls back to WHEEL_KEYS_ON, or its own scrollback.
22
- MOUSE_ON = "\e[?1000h\e[?1006h"
23
- MOUSE_OFF = "\e[?1006l\e[?1000l"
20
+ # Any-motion events in the SGR encoding, which Mouse parses: the pointer
21
+ # moving too, so a link can take a hand pointer. A terminal that knows
22
+ # neither mode falls back to WHEEL_KEYS_ON, or its own scrollback.
23
+ MOUSE_ON = "\e[?1003h\e[?1006h"
24
+ MOUSE_OFF = "\e[?1006l\e[?1003l"
25
+ # OSC 22 sets the pointer's shape. Terminals without it ignore it.
26
+ POINTER = "\e]22;pointer\e\\"
27
+ POINTER_OFF = "\e]22;default\e\\"
24
28
  # Bracketed paste: what is pasted arrives fenced off from what is typed
25
29
  # (Paste), and a pasted image, which has no text, arrives as an empty
26
30
  # fence rather than not at all.
@@ -33,6 +37,7 @@ module ClaudeInbox
33
37
  @painter = Painter.new(out)
34
38
  @restored = true
35
39
  @size = nil
40
+ @over_link = false
36
41
  end
37
42
 
38
43
  # The tty's mode is kept as found, not assumed: `cooked!` sets a stock
@@ -53,8 +58,9 @@ module ClaudeInbox
53
58
  def restore
54
59
  return if @restored
55
60
  @restored = true
61
+ @over_link = false
56
62
  @input.console_mode = @mode if @mode && @input.tty?
57
- @out.print TTY::Cursor.show, PASTE_OFF, MOUSE_OFF, WHEEL_KEYS_OFF, ALT_OFF
63
+ @out.print TTY::Cursor.show, POINTER_OFF, PASTE_OFF, MOUSE_OFF, WHEEL_KEYS_OFF, ALT_OFF
58
64
  @out.flush
59
65
  rescue
60
66
  nil
@@ -93,6 +99,13 @@ module ClaudeInbox
93
99
 
94
100
  def paint(lines) = @painter.paint(lines)
95
101
 
102
+ def pointer(over_link)
103
+ return if over_link == @over_link
104
+ @over_link = over_link
105
+ @out.print(over_link ? POINTER : POINTER_OFF)
106
+ @out.flush
107
+ end
108
+
96
109
  private
97
110
 
98
111
  def measure
@@ -6,15 +6,42 @@ module ClaudeInbox
6
6
  # Width-aware string helpers. Never use String#[] on user-facing text:
7
7
  # session names carry emoji and glyphs that break byte/char slicing.
8
8
  module Text
9
- ANSI = /\e\[[0-9;?]*[A-Za-z]/
9
+ # An OSC 8 hyperlink's opening or, with an empty url, its closing.
10
+ LINK = /\e\]8;[^;\e]*;[^\e]*\e\\/
11
+ CSI = /\e\[[0-9;?]*[A-Za-z]/
12
+ ANSI = /#{CSI}|#{LINK}/
10
13
  # A grapheme, or an escape that takes no column.
11
14
  GLYPH = /#{ANSI}|\X/
12
15
  ELLIPSIS = "…"
16
+ LINK_END = "\e]8;;\e\\"
13
17
 
14
18
  module_function
15
19
 
16
20
  def strip_ansi(s) = s.gsub(ANSI, "")
17
21
 
22
+ # The url comes from a transcript, so a control character in it is dropped.
23
+ def link(s, url) = "\e]8;;#{url.delete("\x00-\x1f\x7f")}\e\\#{s}#{LINK_END}"
24
+
25
+ Link = Data.define(:columns, :url, :text)
26
+
27
+ def links(s)
28
+ found = []
29
+ col = 0
30
+ start = url = text = nil
31
+ s.scan(GLYPH) do |g|
32
+ if g.start_with?("\e]8;")
33
+ found << Link.new(start...col, url, text) if url
34
+ url = g[/\A\e\]8;[^;\e]*;([^\e]+)/, 1]
35
+ start = col
36
+ text = +""
37
+ elsif !g.start_with?("\e")
38
+ text << g if url
39
+ col += Unicode::DisplayWidth.of(g)
40
+ end
41
+ end
42
+ found
43
+ end
44
+
18
45
  # Bytes that aren't UTF-8 become U+FFFD.
19
46
  def utf8(s) = String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
20
47
 
@@ -32,8 +59,8 @@ module ClaudeInbox
32
59
  take(s, w - width(ELLIPSIS)) << ELLIPSIS
33
60
  end
34
61
 
35
- # First `n` columns, no ellipsis. Colors survive the cut, and a reset
36
- # closes whichever one the cut left open.
62
+ # First `n` columns, no ellipsis. Colors and links survive the cut, and
63
+ # the cut closes whichever one it left open.
37
64
  def take(s, n)
38
65
  out = +""
39
66
  used = 0
@@ -43,6 +70,8 @@ module ClaudeInbox
43
70
  out << g
44
71
  used += gw
45
72
  end
73
+ last_link = out.scan(LINK).last
74
+ out << LINK_END if last_link && last_link != LINK_END
46
75
  (out.include?("\e") && !out.end_with?("\e[0m")) ? out << "\e[0m" : out
47
76
  end
48
77
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "text"
4
+
3
5
  module ClaudeInbox
4
6
  # Chrome hues tuned to Atom One Dark rather than the terminal profile's
5
7
  # own ANSI reds and greens. 256-color indices, since those render the
@@ -28,12 +30,16 @@ module ClaudeInbox
28
30
 
29
31
  # "#885 open", or "#885 open · ✗ checks" when something is wrong.
30
32
  # `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
33
+ def pr_badge(pr, name = pr.short, quiet: false)
34
+ hue = PR_HUES[pr.state] unless quiet
35
+ linked = Text.link(underline(tint(hue, name)), pr.url)
36
+ return linked unless PR_HUES.key?(pr.state)
37
+ badge = linked + tint(hue, " #{pr.state.downcase}")
38
+ (pr.trouble && !quiet) ? badge + dim(" · ") + tint(PR_TROUBLE_HUES[pr.health], pr.trouble) : badge
35
39
  end
36
40
 
41
+ def underline(text) = "\e[4m#{text}\e[24m"
42
+
37
43
  # A filled pill: editor-background text on the hue, for a selected row
38
44
  # or choice — the inverse of `color`, which puts the hue on the text.
39
45
  def pill(text, hue) = "\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}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.13.1"
4
+ VERSION = "0.14.1"
5
5
  end
@@ -95,6 +95,11 @@ function itemKey(item: Item): string {
95
95
  return item.kind === 'fold' ? `fold:${item.section}` : `row:${item.row.id ?? item.row.label}`
96
96
  }
97
97
 
98
+ // The Button drawn for a row's pull request, a click on which opens it.
99
+ function prKey(item: Item): string | undefined {
100
+ return item.kind === 'row' && item.row.pr ? `pr:${itemKey(item)}` : undefined
101
+ }
102
+
98
103
  // The visible box drawn for an item, what the pane scrolls to.
99
104
  function viewKey(item: Item): string {
100
105
  return `view:${itemKey(item)}`
@@ -313,14 +318,18 @@ export const register: Register = (on, options) => {
313
318
  // the cursor's item, the arrows move it to the next, and the cursor follows.
314
319
  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
315
320
  const flat = await listed($)
321
+ const was = onList(flat, await read($, cursor))
316
322
  if (e.element === undefined) {
317
- const item = flat[onList(flat, await read($, cursor))]
323
+ const item = flat[was]
318
324
  return item ? next({ ...e, element: itemKey(item) }) : next(e)
319
325
  }
320
- const at = flat.findIndex(item => itemKey(item) === e.element)
326
+ // A PR chip's Button sits in the ring between its row and the next: the ring arrives on it going down
327
+ // from its own row, so it passes on to the next; going up, or by a click, it lands on its own row.
328
+ const chipOf = flat.findIndex(item => prKey(item) === e.element)
329
+ const at = chipOf < 0 ? flat.findIndex(item => itemKey(item) === e.element) : chipOf === was ? Math.min(chipOf + 1, flat.length - 1) : chipOf
321
330
  if (at < 0) return {}
322
331
  await update($, cursor, () => at)
323
- return next(e)
332
+ return next({ ...e, element: itemKey(flat[at]!) })
324
333
  })
325
334
 
326
335
  // An arrow in a focused pane scrolls its body a row while the tree is taller than the
@@ -384,6 +393,12 @@ export const register: Register = (on, options) => {
384
393
  if (!item.row.id || !item.row.actionable) return $.ui.toast('this session cannot be attached')
385
394
  void requestSwitch($, host, item.row.id)
386
395
  }
396
+ // The click's focus may have walked the ring past the row, so the cursor is put back on it.
397
+ const openPr = (item: Item) => () => {
398
+ if (item.kind !== 'row' || !item.row.pr) return
399
+ const url = item.row.pr.url
400
+ void moveTo($, flat, flat.indexOf(item)).then(() => openInBrowser($, url))
401
+ }
387
402
 
388
403
  // Only the keys a chord allows are mounted while it waits, so any other key is a no-op.
389
404
  const keys =
@@ -475,10 +490,13 @@ export const register: Register = (on, options) => {
475
490
  return (
476
491
  <Box key={viewKey(item)} flexDirection="column">
477
492
  <Box>
478
- <Text color={isHere ? 'yellow' : 'blue'}>{isHere ? '› ' : dot ? '● ' : ' '}</Text>
479
- <Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
480
- {GLYPH[one.state] ?? '·'}{' '}
481
- </Text>
493
+ {/* Only the label gives way to a long row: a squeezed glyph wraps under itself. */}
494
+ <Box flexShrink={0}>
495
+ <Text color={isHere ? 'yellow' : 'blue'}>{isHere ? '› ' : dot ? '● ' : ' '}</Text>
496
+ <Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
497
+ {GLYPH[one.state] ?? '·'}{' '}
498
+ </Text>
499
+ </Box>
482
500
  {/* A Button cannot truncate, so its Box clips a long label to the row. A Button takes no
483
501
  color either, so a session's /color is the Box's background rather than the label's ink. */}
484
502
  <Box key={`label:${itemKey(item)}`} height={1} flexShrink={1} overflow="hidden" backgroundColor={tint}>
@@ -494,12 +512,23 @@ export const register: Register = (on, options) => {
494
512
  {one.isUnread && !isQuiet ? <Text bold>{text}</Text> : text}
495
513
  </Button>
496
514
  </Box>
497
- {/* The label gives up its cells to the PR chip, which would otherwise wrap under it. */}
515
+ {/* Takes what a short label leaves, so the PR and age sit at the right edge on every row;
516
+ the label's own box would stretch a /color tint across it. */}
517
+ <Box flexGrow={1} />
518
+ {/* The label gives up its cells to the PR chip, which would otherwise wrap under it. The chip is
519
+ a Button rather than a Link: a terminal without OSC 8 cannot click a Link, and prints its URL. */}
498
520
  {one.pr ? (
499
521
  <Box flexShrink={0}>
522
+ <Box marginLeft={1}>
523
+ <Button key={prKey(item)} plain label={one.pr.short} onPress={openPr(item)}>
524
+ <Text underline color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
525
+ {one.pr.short}
526
+ </Text>
527
+ </Button>
528
+ </Box>
500
529
  <Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
501
530
  {' '}
502
- {one.pr.short} {one.pr.state}
531
+ {one.pr.state}
503
532
  </Text>
504
533
  </Box>
505
534
  ) : null}
@@ -129,7 +129,8 @@ test("rows sit under the gem's sections, Settled folded until za opens it", asyn
129
129
  expect((await ui.findAll({ type: 'Text', text: '● ' })).filter(dot => dot.props.color === 'blue')).toHaveLength(1)
130
130
  expect((await ui.find({ type: 'Text', text: 'finished unseen' }))?.props.bold).toBe(true)
131
131
  expect(await ui.find({ type: 'Text', text: 'still going' })).toBeUndefined()
132
- expect(await ui.find({ type: 'Text', text: /#7 open/ })).toBeDefined()
132
+ expect((await ui.find({ type: 'Button', key: 'pr:row:b2' }))?.props.label).toBe('#7')
133
+ expect((await ui.find({ type: 'Text', text: '#7' }))?.props.underline).toBe(true)
133
134
  expect(await ui.find({ text: /1 folded/ })).toBeDefined()
134
135
  expect(await ui.find({ text: /put away/ })).toBeUndefined()
135
136
  await ui.press({ key: 'g' })
@@ -249,7 +250,7 @@ test('a switch hands the keys back to the prompt before it asks, so a return lan
249
250
  await ui.unmount()
250
251
  })
251
252
 
252
- test('o and w hand the keys back to the prompt before they open the browser', async ($, on) => {
253
+ test('o, w and a click on the PR hand the keys back to the prompt before they open the browser', async ($, on) => {
253
254
  mock.env(on, { HOME: '/Users/me' })
254
255
  mock.clock(on, { now: 1_700_000_000_000 })
255
256
  const remote = { ...ROWS[1], remote: 'https://claude.ai/code/session_b2' }
@@ -270,6 +271,7 @@ test('o and w hand the keys back to the prompt before they open the browser', as
270
271
  const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
271
272
  await ui.press({ key: 'o' })
272
273
  await ui.press({ key: 'w' })
274
+ await ui.press({ key: 'pr:row:b2' })
273
275
  expect(calls).toEqual([
274
276
  'close inbox',
275
277
  'open inbox',
@@ -277,6 +279,9 @@ test('o and w hand the keys back to the prompt before they open the browser', as
277
279
  'close inbox',
278
280
  'open inbox',
279
281
  'open https://claude.ai/code/session_b2',
282
+ 'close inbox',
283
+ 'open inbox',
284
+ 'open https://github.com/o/r/pull/7',
280
285
  ])
281
286
  await ui.unmount()
282
287
  })
@@ -359,3 +364,23 @@ test('with openOnStart off only /inbox opens it', { options: { openOnStart: fals
359
364
  await polled
360
365
  expect(opened).toEqual([])
361
366
  })
367
+
368
+ test("the ring steps over a row's PR chip: down to the next row, up onto its own", async ($, on) => {
369
+ mock.env(on, { HOME: '/Users/me' })
370
+ mock.clock(on, { now: 1_700_000_000_000 })
371
+ on('state.get', ($, e, next) => (e.key === 'rows' ? seeded(ROWS) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
372
+ const landed: (string | undefined)[] = []
373
+ on('ui.focus', ($, e) => {
374
+ landed.push(e.element)
375
+ return {}
376
+ })
377
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
378
+ // The harness has no ring to walk, so the test raises ui.focus as the engine does for an arrow.
379
+ const arrowOnto = (element: string) =>
380
+ $.ui.focus({ component: 'Pane', requestId: 'inbox', plugin: 'inbox-pane', element, origin: { kind: 'person' } } as never)
381
+ await arrowOnto('row:b2')
382
+ await arrowOnto('pr:row:b2')
383
+ await arrowOnto('pr:row:b2')
384
+ expect(landed).toEqual(['row:b2', 'row:uuid-term', 'row:b2'])
385
+ await ui.unmount()
386
+ })
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.13.1
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron