claude-inbox 0.13.0 → 0.14.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: f8b8a6141fcc9efc19386143e9412d4ebab669e73350ee42205e6a07fd05b290
4
- data.tar.gz: 412882713110008db6ff1d96fe8b786e5d61e82ea9b349202c12560f290a86b3
3
+ metadata.gz: c346a215bf94ae3088c6ce5ddff23a2852e0294b77bbaa92a980d6baf4315cb7
4
+ data.tar.gz: 92e53f999cc3731ab9bf96c20e96048cd47a2261cb9640ed26bdafdc5032bbd6
5
5
  SHA512:
6
- metadata.gz: f87a453630132a470dc22b345e8f611ae0e58f04bc877c52ee1c53c4358a306377529a10ae8d807058dc32e1c9d039fc2480dd962a89d382a16e00d35faf6908
7
- data.tar.gz: de74aeac784fdc777336fdaa4db38cfae93e7458873809e2c3e52c0a5a69dc53d2391cec86d73e2d52882ad9634c4567553f3d6151e2c40c571a7eca0abc8db5
6
+ metadata.gz: cb9dc45979798d3e8c73534f06c1e6dc73a6bdfb5019c6df3f87ae7e3e3adb6cf0d67f370e61f29729f3a10a07a5728a58507dd1f961929843c2798e52f5cd18
7
+ data.tar.gz: 59558583519adb57a59ab15c9bad0d78bff87b8db105210c8bdd63370eaa40d3105a68bed312ec0972bfd6fe91bca53866c39decd734db64b09059d8071d4860
data/exe/claude-inbox CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # Claude's files are UTF-8 whatever the locale says; under LANG=C a read of
5
+ # ~/.claude.json would otherwise come back US-ASCII and fail to parse.
6
+ Encoding.default_external = Encoding::UTF_8
7
+
4
8
  require "claude_inbox"
5
9
  require "claude_inbox/cli"
6
10
 
@@ -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
@@ -87,7 +87,7 @@ module ClaudeInbox
87
87
  end
88
88
 
89
89
  def run
90
- @booted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
90
+ @booted_at = monotonic
91
91
  install_traps
92
92
  @terminal.enter
93
93
  @poller.start
@@ -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)
@@ -264,7 +276,7 @@ module ClaudeInbox
264
276
  end
265
277
 
266
278
  def render
267
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
279
+ t0 = monotonic
268
280
  now = Time.now
269
281
  all = @store.sections(now)
270
282
  @snapshot.write(all, now)
@@ -274,16 +286,16 @@ 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)
286
- dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
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?)
298
+ dt = monotonic - t0
287
299
  Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
288
300
  end
289
301
 
@@ -309,11 +321,13 @@ module ClaudeInbox
309
321
  # already says how to retry.
310
322
  def loading_for
311
323
  return nil if @last_poll || @error || !@booted_at
312
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - @booted_at
324
+ monotonic - @booted_at
313
325
  end
314
326
 
315
327
  def notice(msg)
316
328
  @notice = [msg, Time.now + 4]
317
329
  end
330
+
331
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
318
332
  end
319
333
  end
@@ -28,10 +28,15 @@ module ClaudeInbox
28
28
  .split("\n")
29
29
  end
30
30
 
31
+ private
32
+
31
33
  # How wide the box wants to be; the frame caps it at the terminal.
32
34
  def fit = self.class::WIDTH
33
35
 
34
- private
36
+ def bordered(rows, inner)
37
+ top = Text.truncate(title, inner)
38
+ ["┌#{top}#{"─" * (inner - Text.width(top))}┐", *rows.map { |row| "│#{row}│" }, "└#{"─" * inner}┘"]
39
+ end
35
40
 
36
41
  def key(name) = @theme.cyan_bold(name)
37
42
 
@@ -83,9 +88,17 @@ module ClaudeInbox
83
88
 
84
89
  def title = " #{kind.to_s.capitalize} pull request "
85
90
 
86
- # Titles vary, so the box grows to the longest line plus some air,
87
- # and never shrinks below a plain dialog.
88
- def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
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
89
102
 
90
103
  def lines(width, _caret)
91
104
  (@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
@@ -102,6 +115,10 @@ module ClaudeInbox
102
115
 
103
116
  private
104
117
 
118
+ # Titles vary, so the box grows to the longest line plus some air,
119
+ # and never shrinks below a plain dialog.
120
+ def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
121
+
105
122
  # The list's badge, then the title. A bare #190 is ambiguous only
106
123
  # once the list spans repos.
107
124
  def label(pr)
@@ -170,9 +187,7 @@ module ClaudeInbox
170
187
  # pushes the right border out on every line that has one.
171
188
  def frame(width, _caret = nil)
172
189
  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}┘"]
190
+ bordered(lines(inner, nil).map { |l| Text.pad(Text.truncate(l, inner), inner) }, inner)
176
191
  end
177
192
 
178
193
  def lines(_width, _caret)
@@ -33,8 +33,11 @@ module ClaudeInbox
33
33
  def run
34
34
  return false unless @lock.take(role: "headless")
35
35
  @poller.start
36
- Thread.new { watch_for_updates } if @updater
36
+ updates = Thread.new { watch_for_updates } if @updater
37
37
  loop { @queue.pop }
38
+ ensure
39
+ @poller.stop
40
+ updates&.kill
38
41
  end
39
42
 
40
43
  private
@@ -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
@@ -12,12 +12,11 @@ require_relative "text_buffer"
12
12
  require_relative "theme"
13
13
 
14
14
  module ClaudeInbox
15
- # State and key handling for the "new session" modal. Pure: returns what
16
- # happened so the App can act on it. Rendering returns plain lines; the
17
- # App wraps them in a box.
15
+ # The `n` form a new session is composed in. Pure: it says what happened
16
+ # and the App acts on it.
18
17
  class NewSessionForm
19
18
  # `value` is a TextBuffer on :text and :multiline fields and the picked
20
- # string on :choice ones — `kind` says which.
19
+ # string on :choice ones.
21
20
  Field = Struct.new(:key, :label, :kind, :value, :choices)
22
21
 
23
22
  # A directory the Directory field offers, shown by its shortest
@@ -71,11 +70,9 @@ module ClaudeInbox
71
70
 
72
71
  # => :cancel | :start | :start_and_attach | :changed
73
72
  def press(name, raw)
74
- # `:start`/`:start_and_attach` leave the form up until the spawn is
75
- # known to have worked, so a failure (untrusted directory, `claude`
76
- # missing, ...) can hand the composed prompt back instead of losing
77
- # it; there is no way to cancel a spawn already under way, so every
78
- # key is swallowed until the App reports back.
73
+ # A start leaves the form up until the spawn has worked, so a failure
74
+ # can hand the prompt back. A spawn under way can't be cancelled, so
75
+ # every key is swallowed until the App reports back.
79
76
  return :changed if @busy
80
77
  return confirm_discard_press(name, raw) if @confirm_discard
81
78
  @error = nil
@@ -149,8 +146,7 @@ module ClaudeInbox
149
146
  @defaults = Settings.defaults(cwd, home: @home)
150
147
  end
151
148
 
152
- # Full-screen body: a tall prompt editor, then one row per setting with
153
- # every choice visible. Exactly `height` lines.
149
+ # The whole body, exactly `height` lines.
154
150
  def screen(width, height)
155
151
  inner_w = width - 4
156
152
  fixed = 3 + 2 + 1 + (@fields.size - 1) + 1
@@ -189,11 +185,8 @@ module ClaudeInbox
189
185
  @theme.hints(keys)
190
186
  end
191
187
 
192
- # The App's `claude --bg` call failed after `:start`/`:start_and_attach`
193
- # already handed the form off — an untrusted directory, a stale binary,
194
- # a permissions error. The composed prompt is still here, so the form
195
- # reopens on it with the reason shown instead of the App just logging
196
- # the failure and losing the draft.
188
+ # The App's `claude --bg` call failed after a start. The form shows why,
189
+ # over the prompt it kept.
197
190
  def submission_failed(message)
198
191
  @busy = false
199
192
  @error = message
@@ -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"))
@@ -7,13 +7,17 @@ require_relative "theme"
7
7
  require_relative "store"
8
8
 
9
9
  module ClaudeInbox
10
- # sections -> Array<String> (one entry per terminal row, each exactly
11
- # `width` columns wide) plus a parallel Array of selectable items.
12
- # Pure: no terminal, no IO, no clock beyond the `now` it is handed.
10
+ # Turns the sections and a View into a Frame, every line exactly `width`
11
+ # columns. Pure: no terminal, no IO, no clock beyond the `now` it is handed.
13
12
  class Renderer
14
13
  Frame = Struct.new(:lines, :items, :top) do
15
14
  # The same frame for a terminal drawn without color.
16
- 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
17
21
  end
18
22
 
19
23
  CHROME_ROWS = 2 # header + footer
@@ -141,7 +145,7 @@ module ClaudeInbox
141
145
  bar = "█" * filled + "░" * (10 - filled)
142
146
  left = w.resets_at.to_i - now.to_i if resets && w.resets_at
143
147
  figure = left&.positive? ? "#{w.percent}% · #{Text.age(left)} left" : "#{w.percent}%"
144
- "#{@p.dim(w.label)} #{@theme.public_send(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
148
+ "#{@p.dim(w.label)} #{@theme.tint(usage_hue(w.percent), bar)} #{@p.dim(figure)}"
145
149
  end.join(" ")
146
150
  end
147
151
 
@@ -312,7 +316,7 @@ module ClaudeInbox
312
316
  chrome = 1 + 1 + 1 + 1 + 1 + 2 + Text.width(meta) + 2 + 1
313
317
  # Project is only cut once the label has given up all its space too,
314
318
  # so the row can never exceed `width` and fall into Text.pad's blind
315
- # tail-chop (which used to land mid-project-name with no ellipsis).
319
+ # tail-chop.
316
320
  project_text = Text.truncate(s.project, [width - chrome, 0].max)
317
321
  project = quiet ? @p.dim(project_text) : @theme.cyan(project_text)
318
322
 
@@ -397,7 +401,7 @@ module ClaudeInbox
397
401
  def pr_badge(s, section)
398
402
  pr = s.pr
399
403
  return nil unless pr
400
- badge = (section == :settled) ? @p.dim("#{pr.short} #{pr.state&.downcase}".strip) : @theme.pr_badge(pr)
404
+ badge = @theme.pr_badge(pr, quiet: section == :settled)
401
405
  more = (s.prs.size > 1) ? @p.dim(" · +#{s.prs.size - 1}") : ""
402
406
  badge + more
403
407
  end
@@ -416,8 +420,8 @@ module ClaudeInbox
416
420
  end
417
421
 
418
422
  # "working" while the agent is thinking, "idle" once it has stopped and
419
- # only the work it kicked off is still open — with that work named either
420
- # way, since "working · 2 agents" is the answer to "working on what?".
423
+ # only the work it kicked off is still open. Either way the work is
424
+ # named, since "working · 2 agents" answers "working on what?".
421
425
  def working_badge(s)
422
426
  return @theme.yellow("waiting#{": #{s.waiting_for}" if s.waiting_for}") if s.status == "waiting"
423
427
  label = s.job_state&.in_flight_label
@@ -431,12 +435,12 @@ module ClaudeInbox
431
435
 
432
436
  # ----- modal --------------------------------------------------------------
433
437
 
434
- # Centre a block of lines over the frame.
435
438
  def overlay(lines, block, width)
436
439
  block_w = block.map { |l| Text.width(l) }.max || 0
437
440
  left = [(width - block_w) / 2, 0].max
438
441
  top = [(lines.size - block.size) / 2, 0].max
439
- out = lines.dup
442
+ # Nothing behind a pop-up is clickable.
443
+ out = lines.map { |l| l.gsub(Text::LINK, "") }
440
444
  block.each_with_index do |bl, i|
441
445
  y = top + i
442
446
  next if y >= out.size
@@ -4,13 +4,9 @@ require_relative "settings"
4
4
 
5
5
  module ClaudeInbox
6
6
  # The slash commands `claude` would offer at its own prompt, read from the
7
- # same places it reads them: `.claude/commands/*.md` and
8
- # `.claude/skills/*/SKILL.md` under the project and the home directory,
9
- # the skills and commands of the plugins enabled there (named
10
- # `plugin:name`), and the skills claude.ai syncs down
11
- # (`anthropic-skills:name`). Built-ins live inside the CLI and are not
12
- # listed; typing one still works, since the prompt goes to `claude --bg`
13
- # verbatim.
7
+ # same places it reads them (docs/cli-quirks.md). Built-ins live inside
8
+ # the CLI and are not listed; typing one still works, since the prompt
9
+ # goes to `claude --bg` verbatim.
14
10
  #
15
11
  # Pure: filesystem in, plain values out. Nothing here runs `claude`.
16
12
  module SlashCommands
@@ -61,7 +57,6 @@ module ClaudeInbox
61
57
  end
62
58
  end
63
59
 
64
- # "name", or "plugin:name" when it comes from a plugin.
65
60
  def qualify(prefix, name) = [prefix, name].compact.join(":")
66
61
 
67
62
  # `installed_plugins.json` maps "name@marketplace" to where the plugin
@@ -77,8 +72,6 @@ module ClaudeInbox
77
72
  end
78
73
  end
79
74
 
80
- # claude.ai's synced skills sit in one bucket per account under
81
- # ~/.claude/skills/synced and answer to the anthropic-skills prefix.
82
75
  def synced(home)
83
76
  Dir.glob(File.join(home, ".claude", "skills", "synced", "*", "")).flat_map do |bucket|
84
77
  skills(bucket, prefix: SYNCED_PLUGIN)
@@ -54,10 +54,6 @@ module ClaudeInbox
54
54
  # Active rather than Settled, until the state changes again.
55
55
  def acknowledged? = unchanged_since?(entry&.acknowledged_at)
56
56
 
57
- # Finished or needing you, so it carries a read or unread mark. Only a
58
- # session you can attach to counts, since nothing else ever clears it.
59
- def markable? = session.actionable? && (session.finished? || session.needs_you?)
60
-
61
57
  # Finished or needing you since you last attached.
62
58
  def unread?
63
59
  return false unless markable?
@@ -122,6 +118,10 @@ module ClaudeInbox
122
118
 
123
119
  private
124
120
 
121
+ # Finished or needing you, so it carries a read or unread mark. Only a
122
+ # session you can attach to counts, since nothing else ever clears it.
123
+ def markable? = session.actionable? && (session.finished? || session.needs_you?)
124
+
125
125
  def unchanged_since?(at) = at && entry.state_since.to_i <= at.to_i
126
126
  end
127
127
  end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- # Runs a helper command with stdout/stderr captured, in its own session so
5
- # it is not associated with our tty. Terminal.app (and others) put the
6
- # "active process" of the tty in the tab title; without this the title
7
- # flips to "claude" or "ps" on every poll.
4
+ # Runs a helper command with its output captured, in a session of its own
5
+ # so it never reaches the tab title (docs/cli-quirks.md).
8
6
  module Subprocess
9
7
  Result = Struct.new(:out, :err, :status) do
10
8
  def success? = status.success?
@@ -6,10 +6,8 @@ require "tty-screen"
6
6
  require_relative "painter"
7
7
 
8
8
  module ClaudeInbox
9
- # The screen the inbox draws on: the alt screen and the mouse and wheel
10
- # modes that come and go with it, raw mode on the input, the cached size,
11
- # and the Painter that diffs frames onto it. `release` hands the whole
12
- # thing to a child that wants the tty and takes it back afterwards.
9
+ # The screen the inbox draws on, lent with `release` to a child that
10
+ # wants the tty.
13
11
  class Terminal
14
12
  ALT_ON = "\e[?1049h"
15
13
  ALT_OFF = "\e[?1049l"
@@ -19,13 +17,14 @@ module ClaudeInbox
19
17
  # know the mode ignore it and keep scrolling their own history.
20
18
  WHEEL_KEYS_ON = "\e[?1007h"
21
19
  WHEEL_KEYS_OFF = "\e[?1007l"
22
- # Real mouse reporting: button events plus the SGR encoding, so clicks
23
- # and wheel ticks arrive as escape sequences we parse ourselves (Mouse)
24
- # instead of the terminal only ever translating the wheel to arrow
25
- # keys. Terminals that don't understand either mode just ignore it and
26
- # fall back to WHEEL_KEYS_ON's translation, or their own scrollback.
27
- MOUSE_ON = "\e[?1000h\e[?1006h"
28
- 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\\"
29
28
  # Bracketed paste: what is pasted arrives fenced off from what is typed
30
29
  # (Paste), and a pasted image, which has no text, arrives as an empty
31
30
  # fence rather than not at all.
@@ -38,6 +37,7 @@ module ClaudeInbox
38
37
  @painter = Painter.new(out)
39
38
  @restored = true
40
39
  @size = nil
40
+ @over_link = false
41
41
  end
42
42
 
43
43
  # The tty's mode is kept as found, not assumed: `cooked!` sets a stock
@@ -54,12 +54,13 @@ module ClaudeInbox
54
54
  resized
55
55
  end
56
56
 
57
- # Safe to call twice, and from at_exit: the second call is a no-op.
57
+ # Safe to call twice, and from at_exit.
58
58
  def restore
59
59
  return if @restored
60
60
  @restored = true
61
+ @over_link = false
61
62
  @input.console_mode = @mode if @mode && @input.tty?
62
- @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
63
64
  @out.flush
64
65
  rescue
65
66
  nil
@@ -98,6 +99,13 @@ module ClaudeInbox
98
99
 
99
100
  def paint(lines) = @painter.paint(lines)
100
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
+
101
109
  private
102
110
 
103
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
 
@@ -35,6 +35,8 @@ module ClaudeInbox
35
35
 
36
36
  def initialize(text = "")
37
37
  @widths = Hash.new { |h, s| h[s] = Text.width(s) }
38
+ @images = 0
39
+ @pastes = 0
38
40
  replace(text)
39
41
  end
40
42
 
@@ -77,11 +79,11 @@ module ClaudeInbox
77
79
 
78
80
  # Chips are numbered per kind and never reused, so a second image is
79
81
  # `[Image #2]` even after the first was deleted, as Claude Code does.
80
- def attach(path) = place(Image.new(@images = (@images || 0) + 1, path))
82
+ def attach(path) = place(Image.new(@images += 1, path))
81
83
 
82
84
  def paste(text)
83
85
  return insert(text) if text.size <= FOLD_CHARS && text.count("\n") < FOLD_LINES
84
- place(Pasted.new(@pastes = (@pastes || 0) + 1, text))
86
+ place(Pasted.new(@pastes += 1, text))
85
87
  end
86
88
 
87
89
  # Handles one keypress: readline's editing keys, plus printable text.
@@ -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"
@@ -133,7 +133,7 @@ module ClaudeInbox
133
133
  # clone follows the fork.
134
134
  def fetch_upstream
135
135
  remote = git("config", "branch.main.remote") or raise Error, "main tracks no remote"
136
- fetched = @run.call("git", "-C", @root, "fetch", "--quiet", remote)
136
+ fetched = run_git("fetch", "--quiet", remote)
137
137
  raise Error, "git fetch #{remote} failed: #{fetched.err.strip}" unless fetched.success?
138
138
  git("rev-parse", "main@{upstream}") or raise Error, "main tracks no remote"
139
139
  end
@@ -142,12 +142,14 @@ module ClaudeInbox
142
142
  branch = git("symbolic-ref", "--short", "HEAD")
143
143
  return Applied.new(success: false, output: "not on main (on #{branch})") unless branch == "main"
144
144
  return Applied.new(success: false, output: "the working tree is dirty") unless git("status", "--porcelain") == ""
145
- merged = @run.call("git", "-C", @root, "merge", "--ff-only", "--quiet", "main@{upstream}")
145
+ merged = run_git("merge", "--ff-only", "--quiet", "main@{upstream}")
146
146
  Applied.from(merged)
147
147
  end
148
148
 
149
+ def run_git(*args) = @run.call("git", "-C", @root, *args)
150
+
149
151
  def git(*args)
150
- result = @run.call("git", "-C", @root, *args)
152
+ result = run_git(*args)
151
153
  result.out.strip if result.success?
152
154
  end
153
155
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "strscan"
4
4
  require "unicode/display_width"
5
+ require_relative "text"
5
6
 
6
7
  module ClaudeInbox
7
8
  # A deliberately small cursor-addressed screen model that turns captured
@@ -22,7 +23,7 @@ module ClaudeInbox
22
23
  end
23
24
 
24
25
  def feed(str)
25
- ss = StringScanner.new(str.dup.force_encoding("UTF-8").scrub)
26
+ ss = StringScanner.new(Text.utf8(str))
26
27
  until ss.eos?
27
28
  if ss.scan(CSI)
28
29
  csi(ss[1], ss[2])
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.0"
4
+ VERSION = "0.14.0"
5
5
  end
@@ -335,7 +335,7 @@ export const register: Register = (on, options) => {
335
335
  })
336
336
 
337
337
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
338
- const { Box, Text, Button } = $.ui.resolve(e)
338
+ const { Box, Text, Button, Link } = $.ui.resolve(e)
339
339
  const list = await read($, rows)
340
340
  const viewing = await read($, current)
341
341
  const folded = await read($, folds)
@@ -497,9 +497,16 @@ export const register: Register = (on, options) => {
497
497
  {/* The label gives up its cells to the PR chip, which would otherwise wrap under it. */}
498
498
  {one.pr ? (
499
499
  <Box flexShrink={0}>
500
+ <Box key={`pr:${itemKey(item)}`} marginLeft={1} hover={{ backgroundColor: 'subtle' }}>
501
+ <Link href={one.pr.url}>
502
+ <Text underline color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
503
+ {one.pr.short}
504
+ </Text>
505
+ </Link>
506
+ </Box>
500
507
  <Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
501
508
  {' '}
502
- {one.pr.short} {one.pr.state}
509
+ {one.pr.state}
503
510
  </Text>
504
511
  </Box>
505
512
  ) : 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: 'Link' }))?.props.href).toBe('https://github.com/o/r/pull/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' })
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.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: optparse
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.5'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: tty-cursor
14
28
  requirement: !ruby/object:Gem::Requirement