claude-inbox 0.8.0 → 0.10.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: 5f240b8de798a2fea0c318d9bd7882e2d05cf7bdcbdc48fea7698f2ac8e6dc82
4
- data.tar.gz: a94912ebe0d438922d9f5bdc334e0be3465e7997fb9767bc1e2a6ef9f7ed55b4
3
+ metadata.gz: 56e25a548ff2899f765523fbf990e66b96f970915c8efafa39fb8834af99f6db
4
+ data.tar.gz: 590e72d23244e04517692beee562c53c59cecba10028b16ea9a371560392fcec
5
5
  SHA512:
6
- metadata.gz: da9407631ff4ec1f734e9e69baf306cace5042f50e473a9d98487629c59109df9bda5e511d15db5db575cebb9e7b95bf02bcc504b8a617a43602e7421a1f1418
7
- data.tar.gz: 5d79b77a64ffde8d41933bfaa7946ee550b791f55b7131c6a7057a1caab476164591eb7f2baeaf5285e96f9a7a84b9f30aeeac891196b6d7baa8ac3bafe25b54
6
+ metadata.gz: ffb0d572f03f482149ddf17f599d936a99de569340e6dbc61d587a5b736aa1919d78d300ddde35689e6b31b15a020eef350830668b046288df6925e021768ecb
7
+ data.tar.gz: 56805ff4fd994c5a22ef94742a801a6693b04b577f3ebfee63bb99271ff5de68f343e54f33011558852c7dbaeb6851124a944aa2b091190f95479ce51d2736fd
data/README.md CHANGED
@@ -40,7 +40,7 @@ a fullscreen terminal.
40
40
 
41
41
  `/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
42
42
  ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
43
- `P`, `X`, `Ctrl-x`, `/`, `R`, `U` or `y`, and `s` has no `5`. `Enter`, or a click on a row, switches to the
43
+ `P`, `X`, `Ctrl-x`, `/`, `R` or `U`, and `s` has no `5`. `Enter`, or a click on a row, switches to the
44
44
  session when you attached through `claude-inbox`.
45
45
 
46
46
  It opens when a session starts. To stop that, set
@@ -61,7 +61,8 @@ It opens when a session starts. To stop that, set
61
61
  Under each row is what the session is waiting on, what it produced, or its
62
62
  status line. A row with a pull request shows it as `#885 open`, `#885 merged`
63
63
  and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
64
- something is wrong. A steady `◌` with `idle · 1 shell` means the agent stopped
64
+ something is wrong, and `+2` after it when the session has two more.
65
+ A steady `◌` with `idle · 1 shell` means the agent stopped
65
66
  and is waiting on a shell or sub-agent it started. A `●` marks a session you
66
67
  haven't attached to since it got blocked, in red, or finished, in blue; one
67
68
  you have attached to since is dimmed, unless `/color` tints it.
@@ -86,9 +87,9 @@ Vim bindings. Arrows and the mouse work too.
86
87
  | `u` | wake a snoozed session, or bring back a settled one |
87
88
  | `x` | settle by hand |
88
89
  | `a` | set a local alias |
89
- | `o` | open the pull request in the browser |
90
+ | `o` | open the pull request in the browser; with several, `o` again opens the one that matters, `1`… another |
90
91
  | `w` | open the session at claude.ai/code |
91
- | `y` | copy the pull request url, or the session's address |
92
+ | `y` | copy the pull request url, or the session's address; with several, `y` again copies the one that matters, `1`… another |
92
93
  | `P` | link a pull request by hand (empty clears) |
93
94
  | `X` | stop the session; `Enter` resumes it later |
94
95
  | `Ctrl-x` | delete the session, transcript and worktree for good |
@@ -4,14 +4,16 @@ module ClaudeInbox
4
4
  class App
5
5
  # o opens the selected session's pull request and w its claude.ai/code
6
6
  # page, through whatever opens URLs on this platform; y copies the one
7
- # of those the session has, or its id.
7
+ # of those the session has, or its id. With several PRs, o and y ask
8
+ # which first.
8
9
  module Browsing
9
10
  private
10
11
 
11
12
  def open_pr
12
- pr = selected_session&.pr
13
- return notice("no pull request linked — P sets one") unless pr
14
- open_in_browser(pr.url, pr.short)
13
+ session = selected_session
14
+ return notice("no pull request linked — P sets one") unless session&.pr
15
+ return @modal = Dialog::PrPicker.new(:open, session) if session.prs.size > 1
16
+ open_in_browser(session.pr.url, session.pr.short)
15
17
  end
16
18
 
17
19
  def open_remote
@@ -23,7 +25,17 @@ module ClaudeInbox
23
25
  def yank
24
26
  session = selected_session
25
27
  return notice("nothing to copy") unless session
26
- text, what = yank_target(session)
28
+ return @modal = Dialog::PrPicker.new(:copy, session) if session.prs.size > 1
29
+ copy_to_clipboard(*yank_target(session))
30
+ end
31
+
32
+ def pr_picked
33
+ kind, pr = @modal.kind, @modal.choice
34
+ @modal = nil
35
+ (kind == :open) ? open_in_browser(pr.url, pr.short) : copy_to_clipboard(pr.url, pr.short)
36
+ end
37
+
38
+ def copy_to_clipboard(text, what)
27
39
  in_background do
28
40
  @queue << [:notice, @clipboard.call(text) ? "copied #{what}" : "no clipboard tool found"]
29
41
  end
@@ -198,6 +198,7 @@ module ClaudeInbox
198
198
  case @modal.press(name, key)
199
199
  when :cancel then @modal = nil
200
200
  when :snooze then snooze_chosen
201
+ when :pick then pr_picked
201
202
  when :confirm
202
203
  kind, id = @modal.kind, @modal.id
203
204
  @modal = nil
@@ -21,11 +21,14 @@ module ClaudeInbox
21
21
  end
22
22
 
23
23
  def frame(width, caret = nil)
24
- box = [width - 4, self.class::WIDTH].min
24
+ box = [width - 4, fit].min
25
25
  TTY::Box.frame(lines(box - 4, caret).join("\n"), title: {top_left: title}, padding: [0, 1], width: box)
26
26
  .split("\n")
27
27
  end
28
28
 
29
+ # How wide the box wants to be; the frame caps it at the terminal.
30
+ def fit = self.class::WIDTH
31
+
29
32
  # `s` snooze: one key per choice, and the choice is read from `choice`.
30
33
  class Snooze < Dialog
31
34
  MENU = [
@@ -53,6 +56,48 @@ module ClaudeInbox
53
56
  end
54
57
  end
55
58
 
59
+ # `o` open and `y` copy on a session with several PRs: the headline
60
+ # first, and the same key again takes it. The PR is read from `choice`.
61
+ class PrPicker < Dialog
62
+ MARGIN = 6
63
+ KEYS = {open: "o", copy: "y"}.freeze
64
+ VERBS = {open: "open", copy: "copy"}.freeze
65
+
66
+ attr_reader :choice
67
+
68
+ def initialize(kind, session)
69
+ super(kind, session.key)
70
+ @prs = [session.pr, *session.prs.reject { |pr| pr.equal?(session.pr) }].first(9)
71
+ end
72
+
73
+ def title = " #{VERBS.fetch(kind).capitalize} pull request "
74
+
75
+ # Titles vary, so the box grows to the longest line plus some air,
76
+ # and never shrinks below a plain dialog.
77
+ def fit = [WIDTH, lines(Float::INFINITY, nil).map { |l| Text.width(l) }.max + MARGIN].max
78
+
79
+ 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"])
82
+ .map { |line| Text.truncate(line, width) }
83
+ end
84
+
85
+ def press(name, key)
86
+ return :cancel if name == :escape || key == "q"
87
+ @choice = @prs.first if key == KEYS.fetch(kind) || name == :return || name == :enter
88
+ @choice = @prs[key.to_i - 1] if key.to_s.match?(/\A[1-9]\z/)
89
+ @choice && :pick
90
+ end
91
+
92
+ private
93
+
94
+ # A bare #190 is ambiguous only once the list spans repos.
95
+ def label(pr)
96
+ name = (@prs.map(&:repo).uniq.size > 1) ? pr.qualified : pr.short
97
+ ["#{name} #{pr.state&.downcase}".strip, pr.trouble, pr.title].compact.join(" · ")
98
+ end
99
+ end
100
+
56
101
  # `X` stop, `Ctrl-x` delete and Enter on a remote row all ask first and
57
102
  # all take `y`, but the words differ because the outcomes do: read the
58
103
  # box before answering.
@@ -6,16 +6,27 @@ require_relative "records"
6
6
  require_relative "subprocess"
7
7
 
8
8
  module ClaudeInbox
9
+ # What is wrong with an open PR, in the words its badge and the picker
10
+ # use. CLEAN and unknown say nothing.
11
+ PR_TROUBLE = {"FAILING" => "✗ checks", "CHANGES_REQUESTED" => "changes requested", "CONFLICTING" => "conflicts"}.freeze
12
+
9
13
  # A pull request tied to a session. `state` uses GitHub's vocabulary plus
10
14
  # DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
11
15
  # or nil when nothing has told us yet. `resolved_at` is when it was merged
12
16
  # or closed, in epoch seconds, once gh has said. `health` is what gh last
13
17
  # said is wrong with an open PR: FAILING (a check), CHANGES_REQUESTED or
14
18
  # CONFLICTING; CLEAN when nothing is, nil when nothing has said. Claude
15
- # Code's cache knows nothing of it, and a resolved PR has none.
16
- PullRequest = Struct.new(:number, :url, :state, :resolved_at, :health) do
19
+ # Code's cache knows nothing of it, and a resolved PR has none. `title`
20
+ # is whatever gh or the cache last called it, nil until one has.
21
+ PullRequest = Struct.new(:number, :url, :state, :resolved_at, :health, :title) do
17
22
  def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
18
23
 
24
+ def repo = url.to_s[%r{\Ahttps://github\.com/([^/\s]+/[^/\s]+)/pull/\d+}, 1]
25
+
26
+ def qualified = (number && repo) ? "#{repo}##{number}" : short
27
+
28
+ def trouble = PR_TROUBLE[health]
29
+
19
30
  # Failing checks or requested changes want you back; a conflict only
20
31
  # waits on whoever rebases.
21
32
  def needs_you? = %w[FAILING CHANGES_REQUESTED].include?(health)
@@ -123,7 +134,7 @@ module ClaudeInbox
123
134
  state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
124
135
  resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
125
136
  PullRequest.new(number: h["number"], url: h["url"] || url, state: state, resolved_at: resolved_at,
126
- health: (health(h) if h["state"] == "OPEN"))
137
+ health: (health(h) if h["state"] == "OPEN"), title: h["title"])
127
138
  rescue JSON::ParserError, ArgumentError
128
139
  nil
129
140
  end
@@ -163,7 +174,7 @@ module ClaudeInbox
163
174
  number = url[%r{/pull/(\d+)}, 1]&.to_i
164
175
  cached = resolved[url] || claude_cache[url]
165
176
  PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
166
- resolved_at: cached&.dig("resolved_at"))
177
+ resolved_at: cached&.dig("resolved_at"), title: cached&.dig("title"))
167
178
  end
168
179
 
169
180
  # Same shape as Claude Code's cache, so `seed` reads both alike.
@@ -177,14 +188,15 @@ module ClaudeInbox
177
188
 
178
189
  # Under @mutex.
179
190
  def remember(pr)
180
- resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "resolved_at" => pr.resolved_at}
191
+ resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "resolved_at" => pr.resolved_at,
192
+ "title" => pr.title}
181
193
  return unless @resolved_path
182
194
  Records.save(@resolved_path, resolved)
183
195
  end
184
196
 
185
197
  def fetch(url)
186
198
  r = Subprocess.capture(@gh, "pr", "view", url, "--json",
187
- "number,state,isDraft,url,closedAt,statusCheckRollup,reviewDecision,mergeable")
199
+ "number,state,isDraft,url,closedAt,statusCheckRollup,reviewDecision,mergeable,title")
188
200
  r.success? ? self.class.parse(url, r.out) : nil
189
201
  rescue SystemCallError
190
202
  nil
@@ -39,11 +39,9 @@ module ClaudeInbox
39
39
  # the table isn't named on the badge at all.
40
40
  PR_HUES = {"OPEN" => :green, "DRAFT" => nil, "MERGED" => :purple, "CLOSED" => :red}.freeze
41
41
 
42
- # What is wrong with an open PR, after its badge. Red for what puts the
43
- # row in Needs You; a conflict dims, since it waits on a rebase, not on
44
- # you. CLEAN and unknown say nothing.
45
- PR_TROUBLE = {"FAILING" => [:red, "✗ checks"], "CHANGES_REQUESTED" => [:red, "changes requested"],
46
- "CONFLICTING" => [nil, "conflicts"]}.freeze
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
47
45
 
48
46
  SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
49
47
 
@@ -401,16 +399,20 @@ module ClaudeInbox
401
399
  pr ? base + @p.dim(" · ") + pr : base
402
400
  end
403
401
 
404
- # "#885 open", or "#885 open · ✗ checks" when something is wrong. Only
405
- # the first PR is shown.
402
+ # "#885 open", or "#885 open · ✗ checks" when something is wrong. The
403
+ # headline PR is shown, with a count of the rest.
406
404
  def pr_badge(s, section)
407
405
  pr = s.pr
408
406
  return nil unless pr
407
+ more = (s.prs.size > 1) ? @p.dim(" · +#{s.prs.size - 1}") : ""
408
+ pr_state_badge(pr, section) + more
409
+ end
410
+
411
+ def pr_state_badge(pr, section)
409
412
  return @p.dim("#{pr.short} #{pr.state&.downcase}".strip) if section == :settled
410
413
  return @p.dim(pr.short) unless PR_HUES.key?(pr.state)
411
414
  badge = tint(PR_HUES[pr.state], "#{pr.short} #{pr.state.downcase}")
412
- hue, trouble = PR_TROUBLE[pr.health]
413
- trouble ? badge + @p.dim(" · ") + tint(hue, trouble) : badge
415
+ pr.trouble ? badge + @p.dim(" · ") + tint(PR_TROUBLE_HUES[pr.health], pr.trouble) : badge
414
416
  end
415
417
 
416
418
  def state_badge(s)
@@ -99,6 +99,19 @@ module ClaudeInbox
99
99
 
100
100
  def project = cwd ? File.basename(cwd) : ""
101
101
 
102
- def pr = prs.first
102
+ # The PR you would act on. One resolved before the session began was
103
+ # only mentioned (as Row#settled? judges), so it loses to any live one.
104
+ def pr = prs.each_with_index.min_by { |pr, i| [headline_rank(pr), i] }&.first
105
+
106
+ private
107
+
108
+ def headline_rank(pr)
109
+ if !pr.known? || pr.resolved_before?(created_at) then 4
110
+ elsif pr.needs_you? then 0
111
+ elsif pr.health == "CONFLICTING" then 1
112
+ elsif !pr.resolved? then 2
113
+ else 3
114
+ end
115
+ end
103
116
  end
104
117
  end
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.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -412,11 +412,10 @@ export const register: Register = (on, options) => {
412
412
  key('u', onRow('wake', 'woke')),
413
413
  key('t', onRow('pin', 'pinned')),
414
414
  key('s', () => void startChord($, 's')),
415
- key('c', () => {
416
- if (!row?.id) return
417
- void $.ui.copy({ text: `claude attach ${row.id}`, surface: e.surface }).then(copied =>
418
- $.ui.toast(copied.isCopied ? `copied: claude attach ${row.id}` : 'no clipboard here'),
419
- )
415
+ key('y', () => {
416
+ if (!row) return $.ui.toast('nothing to copy')
417
+ const text = row.pr?.url ?? row.remote ?? row.id ?? row.label
418
+ void $.ui.copy({ text, surface: e.surface }).then(copied => $.ui.toast(copied.isCopied ? `copied ${text}` : 'no clipboard here'))
420
419
  }),
421
420
  key('o', () => (row?.pr ? void openInBrowser($, row.pr.url) : $.ui.toast('no pull request linked'))),
422
421
  key('w', () => (row?.remote ? void openInBrowser($, row.remote) : $.ui.toast('no claude.ai/code page for this session'))),
@@ -256,6 +256,28 @@ test('o and w hand the keys back to the prompt before they open the browser', as
256
256
  await ui.unmount()
257
257
  })
258
258
 
259
+ test("y copies the row's pull request, else its claude.ai/code page, else its id", async ($, on) => {
260
+ mock.env(on, { HOME: '/Users/me' })
261
+ mock.clock(on, { now: 1_700_000_000_000 })
262
+ const remote = { section: 'active', id: 'e5', label: 'on the web', state: 'working', actionable: true, remote: 'https://claude.ai/code/session_e5' }
263
+ on('state.get', ($, e, next) =>
264
+ e.key === 'rows' ? seeded([ROWS[0], ROWS[1], remote]) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e),
265
+ )
266
+ const copied: string[] = []
267
+ on('ui.copy', ($, e) => {
268
+ copied.push(e.text)
269
+ return { value: { isCopied: true } }
270
+ })
271
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
272
+ await ui.press({ key: 'y' })
273
+ await ui.press({ key: 'j' })
274
+ await ui.press({ key: 'y' })
275
+ await ui.press({ key: 'j' })
276
+ await ui.press({ key: 'y' })
277
+ expect(copied).toEqual(['a1', 'https://github.com/o/r/pull/7', 'https://claude.ai/code/session_e5'])
278
+ await ui.unmount()
279
+ })
280
+
259
281
  test('a click on a row puts the cursor on it before it asks for the switch', async ($, on) => {
260
282
  mock.env(on, { HOME: '/Users/me' })
261
283
  mock.clock(on, { now: 1_700_000_000_000 })
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.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron