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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c346a215bf94ae3088c6ce5ddff23a2852e0294b77bbaa92a980d6baf4315cb7
4
- data.tar.gz: 92e53f999cc3731ab9bf96c20e96048cd47a2261cb9640ed26bdafdc5032bbd6
3
+ metadata.gz: d987ed72f80dde3f91bd8ddeb3486aa867fb25e8b4ac5be91305654be021c9b0
4
+ data.tar.gz: 5a4874f4eee5a49c7bf56921db71f966f935397001a20a58c53a71bec69b013b
5
5
  SHA512:
6
- metadata.gz: cb9dc45979798d3e8c73534f06c1e6dc73a6bdfb5019c6df3f87ae7e3e3adb6cf0d67f370e61f29729f3a10a07a5728a58507dd1f961929843c2798e52f5cd18
7
- data.tar.gz: 59558583519adb57a59ab15c9bad0d78bff87b8db105210c8bdd63370eaa40d3105a68bed312ec0972bfd6fe91bca53866c39decd734db64b09059d8071d4860
6
+ metadata.gz: c75cc50ea18dd72da00508eb50066dc6f0a7b9b20a1e8bb6ed042272bac0c45cd963ec31efe570b080dfaf9b4ec191c56143de05a229c2f83212b7baad551728
7
+ data.tar.gz: 5ed8c579ff1405d26327bc6c39acc95cd79a5c0eb3706314aeab91f006bdc940d699e6ba41f35396e3af9b372900329ec8978d122e6026a8f766554a7501c806
@@ -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, and the same key again takes it. The PR is read from `choice`.
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.prs.reject { |pr| pr.equal?(session.pr) }].first(9)
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), "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
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
- "", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
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.", "", " " + hint("y", "adopt it"), " " + hint("esc", "cancel")]
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 = 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)
@@ -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 resolved before the session began was
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 !pr.known? || pr.resolved_before?(created_at) then 4
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.14.0"
4
+ VERSION = "0.14.2"
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
@@ -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, Link } = $.ui.resolve(e)
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
- <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,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
- {/* 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}>
500
- <Box key={`pr:${itemKey(item)}`} marginLeft={1} hover={{ backgroundColor: 'subtle' }}>
501
- <Link href={one.pr.url}>
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
- </Link>
527
+ </Button>
506
528
  </Box>
507
529
  <Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
508
530
  {' '}
@@ -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: 'Link' }))?.props.href).toBe('https://github.com/o/r/pull/7')
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 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) => {
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
+ })
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.14.0
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron