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 +4 -4
- data/README.md +5 -4
- data/lib/claude_inbox/app/browsing.rb +17 -5
- data/lib/claude_inbox/app.rb +1 -0
- data/lib/claude_inbox/dialog.rb +46 -1
- data/lib/claude_inbox/pull_requests.rb +18 -6
- data/lib/claude_inbox/renderer.rb +11 -9
- data/lib/claude_inbox/session.rb +14 -1
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +4 -5
- data/mod/tests/pane.test.tsx +22 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56e25a548ff2899f765523fbf990e66b96f970915c8efafa39fb8834af99f6db
|
|
4
|
+
data.tar.gz: 590e72d23244e04517692beee562c53c59cecba10028b16ea9a371560392fcec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
13
|
-
return notice("no pull request linked — P sets one") unless pr
|
|
14
|
-
|
|
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
|
-
|
|
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
|
data/lib/claude_inbox/app.rb
CHANGED
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -21,11 +21,14 @@ module ClaudeInbox
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def frame(width, caret = nil)
|
|
24
|
-
box = [width - 4,
|
|
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
|
-
|
|
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
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
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.
|
|
405
|
-
#
|
|
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
|
-
|
|
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)
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -99,6 +99,19 @@ module ClaudeInbox
|
|
|
99
99
|
|
|
100
100
|
def project = cwd ? File.basename(cwd) : ""
|
|
101
101
|
|
|
102
|
-
|
|
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
data/mod/hooks/register.tsx
CHANGED
|
@@ -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('
|
|
416
|
-
if (!row
|
|
417
|
-
|
|
418
|
-
|
|
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'))),
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -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 })
|