claude-inbox 0.14.0 → 0.14.2
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/lib/claude_inbox/dialog.rb +8 -5
- data/lib/claude_inbox/new_session_form.rb +1 -1
- data/lib/claude_inbox/session.rb +18 -3
- data/lib/claude_inbox.rb +1 -1
- data/mod/hooks/register.tsx +34 -12
- data/mod/tests/pane.test.tsx +26 -2
- 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: d987ed72f80dde3f91bd8ddeb3486aa867fb25e8b4ac5be91305654be021c9b0
|
|
4
|
+
data.tar.gz: 5a4874f4eee5a49c7bf56921db71f966f935397001a20a58c53a71bec69b013b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c75cc50ea18dd72da00508eb50066dc6f0a7b9b20a1e8bb6ed042272bac0c45cd963ec31efe570b080dfaf9b4ec191c56143de05a229c2f83212b7baad551728
|
|
7
|
+
data.tar.gz: 5ed8c579ff1405d26327bc6c39acc95cd79a5c0eb3706314aeab91f006bdc940d699e6ba41f35396e3af9b372900329ec8978d122e6026a8f766554a7501c806
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -74,7 +74,8 @@ module ClaudeInbox
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# `o` open and `y` copy on a session with several PRs: the headline
|
|
77
|
-
# first,
|
|
77
|
+
# first, then the rest in Session#prs_by_status order; the same key
|
|
78
|
+
# again takes the headline. The PR is read from `choice`.
|
|
78
79
|
class PrPicker < Dialog
|
|
79
80
|
MARGIN = 6
|
|
80
81
|
KEYS = {open: "o", copy: "y"}.freeze
|
|
@@ -83,7 +84,7 @@ module ClaudeInbox
|
|
|
83
84
|
|
|
84
85
|
def initialize(kind, session)
|
|
85
86
|
super(kind, session.key)
|
|
86
|
-
@prs = [session.pr, *session.
|
|
87
|
+
@prs = [session.pr, *session.prs_by_status.reject { |pr| pr.equal?(session.pr) }].first(9)
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
def title = " #{kind.to_s.capitalize} pull request "
|
|
@@ -144,13 +145,13 @@ module ClaudeInbox
|
|
|
144
145
|
|
|
145
146
|
def lines(width, _caret)
|
|
146
147
|
case kind
|
|
147
|
-
when :stop then [ask("Stop", width),
|
|
148
|
+
when :stop then [ask("Stop", width), *answers("stop it", "cancel")]
|
|
148
149
|
when :delete
|
|
149
150
|
[ask("Delete", width), " Its worktree and conversation", " go with it.",
|
|
150
|
-
|
|
151
|
+
*answers("delete it", "keep it")]
|
|
151
152
|
when :adopt
|
|
152
153
|
[" Pull this session into the daemon?", " Its phone session ends and a",
|
|
153
|
-
" new one takes its place.",
|
|
154
|
+
" new one takes its place.", *answers("adopt it", "cancel")]
|
|
154
155
|
end
|
|
155
156
|
end
|
|
156
157
|
|
|
@@ -164,6 +165,8 @@ module ClaudeInbox
|
|
|
164
165
|
|
|
165
166
|
# One line, so a long name is cut rather than wrapped mid-word.
|
|
166
167
|
def ask(verb, width) = " #{verb} #{Text.truncate(@label.to_s, width - Text.width(verb) - 4)}?"
|
|
168
|
+
|
|
169
|
+
def answers(yes, no) = ["", " " + hint("y", yes), " " + hint("esc", no)]
|
|
167
170
|
end
|
|
168
171
|
|
|
169
172
|
# The first launch without the Claude Code pane asks once whether to add
|
|
@@ -333,7 +333,7 @@ module ClaudeInbox
|
|
|
333
333
|
|
|
334
334
|
def prompt_box(f, w, h)
|
|
335
335
|
on = f.equal?(focused)
|
|
336
|
-
edge =
|
|
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)
|
data/lib/claude_inbox/session.rb
CHANGED
|
@@ -99,19 +99,34 @@ module ClaudeInbox
|
|
|
99
99
|
|
|
100
100
|
def project = cwd ? File.basename(cwd) : ""
|
|
101
101
|
|
|
102
|
-
# The PR you would act on. One
|
|
103
|
-
# only mentioned (as Row#settled? judges), so it loses to any live one.
|
|
102
|
+
# The PR you would act on. One only mentioned loses to any live one.
|
|
104
103
|
def pr = prs.each_with_index.min_by { |pr, i| [headline_rank(pr), i] }&.first
|
|
105
104
|
|
|
105
|
+
# Oldest first so a stack reads up from its base; trouble is the
|
|
106
|
+
# headline's job, not this order's.
|
|
107
|
+
def prs_by_status = prs.each_with_index.sort_by { |pr, i| [*status_order(pr), i] }.map(&:first)
|
|
108
|
+
|
|
106
109
|
private
|
|
107
110
|
|
|
108
111
|
def headline_rank(pr)
|
|
109
|
-
if
|
|
112
|
+
if mentioned?(pr) then 4
|
|
110
113
|
elsif pr.needs_you? then 0
|
|
111
114
|
elsif pr.health == "CONFLICTING" then 1
|
|
112
115
|
elsif !pr.resolved? then 2
|
|
113
116
|
else 3
|
|
114
117
|
end
|
|
115
118
|
end
|
|
119
|
+
|
|
120
|
+
def status_order(pr)
|
|
121
|
+
age = pr.number.to_i
|
|
122
|
+
if mentioned?(pr) then [3, age]
|
|
123
|
+
elsif pr.state == "OPEN" then [0, age]
|
|
124
|
+
elsif pr.state == "DRAFT" then [1, age]
|
|
125
|
+
else [2, -age]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Unknown, or resolved before the session began (as Row#settled? judges).
|
|
130
|
+
def mentioned?(pr) = !pr.known? || pr.resolved_before?(created_at)
|
|
116
131
|
end
|
|
117
132
|
end
|
data/lib/claude_inbox.rb
CHANGED
data/mod/hooks/register.tsx
CHANGED
|
@@ -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[
|
|
323
|
+
const item = flat[was]
|
|
318
324
|
return item ? next({ ...e, element: itemKey(item) }) : next(e)
|
|
319
325
|
}
|
|
320
|
-
|
|
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
|
|
@@ -335,7 +344,7 @@ export const register: Register = (on, options) => {
|
|
|
335
344
|
})
|
|
336
345
|
|
|
337
346
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
338
|
-
const { Box, Text, Button
|
|
347
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
339
348
|
const list = await read($, rows)
|
|
340
349
|
const viewing = await read($, current)
|
|
341
350
|
const folded = await read($, folds)
|
|
@@ -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
|
-
|
|
479
|
-
<
|
|
480
|
-
{
|
|
481
|
-
|
|
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,15 +512,19 @@ export const register: Register = (on, options) => {
|
|
|
494
512
|
{one.isUnread && !isQuiet ? <Text bold>{text}</Text> : text}
|
|
495
513
|
</Button>
|
|
496
514
|
</Box>
|
|
497
|
-
{/*
|
|
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}>
|
|
500
|
-
<Box
|
|
501
|
-
<
|
|
522
|
+
<Box marginLeft={1}>
|
|
523
|
+
<Button key={prKey(item)} plain label={one.pr.short} onPress={openPr(item)}>
|
|
502
524
|
<Text underline color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
|
|
503
525
|
{one.pr.short}
|
|
504
526
|
</Text>
|
|
505
|
-
</
|
|
527
|
+
</Button>
|
|
506
528
|
</Box>
|
|
507
529
|
<Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
|
|
508
530
|
{' '}
|
data/mod/tests/pane.test.tsx
CHANGED
|
@@ -129,7 +129,7 @@ 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: '
|
|
132
|
+
expect((await ui.find({ type: 'Button', key: 'pr:row:b2' }))?.props.label).toBe('#7')
|
|
133
133
|
expect((await ui.find({ type: 'Text', text: '#7' }))?.props.underline).toBe(true)
|
|
134
134
|
expect(await ui.find({ text: /1 folded/ })).toBeDefined()
|
|
135
135
|
expect(await ui.find({ text: /put away/ })).toBeUndefined()
|
|
@@ -250,7 +250,7 @@ test('a switch hands the keys back to the prompt before it asks, so a return lan
|
|
|
250
250
|
await ui.unmount()
|
|
251
251
|
})
|
|
252
252
|
|
|
253
|
-
test('o and
|
|
253
|
+
test('o, w and a click on the PR hand the keys back to the prompt before they open the browser', async ($, on) => {
|
|
254
254
|
mock.env(on, { HOME: '/Users/me' })
|
|
255
255
|
mock.clock(on, { now: 1_700_000_000_000 })
|
|
256
256
|
const remote = { ...ROWS[1], remote: 'https://claude.ai/code/session_b2' }
|
|
@@ -271,6 +271,7 @@ test('o and w hand the keys back to the prompt before they open the browser', as
|
|
|
271
271
|
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
|
|
272
272
|
await ui.press({ key: 'o' })
|
|
273
273
|
await ui.press({ key: 'w' })
|
|
274
|
+
await ui.press({ key: 'pr:row:b2' })
|
|
274
275
|
expect(calls).toEqual([
|
|
275
276
|
'close inbox',
|
|
276
277
|
'open inbox',
|
|
@@ -278,6 +279,9 @@ test('o and w hand the keys back to the prompt before they open the browser', as
|
|
|
278
279
|
'close inbox',
|
|
279
280
|
'open inbox',
|
|
280
281
|
'open https://claude.ai/code/session_b2',
|
|
282
|
+
'close inbox',
|
|
283
|
+
'open inbox',
|
|
284
|
+
'open https://github.com/o/r/pull/7',
|
|
281
285
|
])
|
|
282
286
|
await ui.unmount()
|
|
283
287
|
})
|
|
@@ -360,3 +364,23 @@ test('with openOnStart off only /inbox opens it', { options: { openOnStart: fals
|
|
|
360
364
|
await polled
|
|
361
365
|
expect(opened).toEqual([])
|
|
362
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
|
+
})
|