claude-inbox 0.6.0 → 0.7.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.
@@ -197,14 +197,20 @@ async function requestSwitch($: EngineInterface, host: Host, id: string): Promis
197
197
  })
198
198
  }
199
199
 
200
- // The pane keeps the keys while its session is away, so coming back to it would put the
201
- // person's typing in the pane. The engine has no blur: reopening without `focus` is the
202
- // one way to give the keys to the prompt.
200
+ // Every key that takes the person out of the pane, to another session or the browser, calls
201
+ // this first: the pane would keep the keys while they are away, and their typing on coming
202
+ // back would land in it. The engine has no blur: reopening without `focus` is the one way
203
+ // to give the keys to the prompt.
203
204
  async function handBackKeys($: EngineInterface): Promise<void> {
204
205
  await $.ui.close({ id: PANE })
205
206
  await $.ui.open(OPEN)
206
207
  }
207
208
 
209
+ async function openInBrowser($: EngineInterface, url: string): Promise<void> {
210
+ await handBackKeys($)
211
+ await $.process.run(['open', url])
212
+ }
213
+
208
214
  // Only while no inbox writes the snapshot; the gem's writer lock turns a second one away.
209
215
  function startHeadless($: EngineInterface, host: Host, now: number): void {
210
216
  if (host.isHeadlessRunning || now - host.headlessStartedAt < RESTART_S) return
@@ -410,8 +416,8 @@ export const register: Register = (on, options) => {
410
416
  $.ui.toast(copied.isCopied ? `copied: claude attach ${row.id}` : 'no clipboard here'),
411
417
  )
412
418
  }),
413
- key('o', () => (row?.pr ? void $.process.run(['open', row.pr.url]) : $.ui.toast('no pull request linked'))),
414
- key('w', () => (row?.remote ? void $.process.run(['open', row.remote]) : $.ui.toast('no claude.ai/code page for this session'))),
419
+ key('o', () => (row?.pr ? void openInBrowser($, row.pr.url) : $.ui.toast('no pull request linked'))),
420
+ key('w', () => (row?.remote ? void openInBrowser($, row.remote) : $.ui.toast('no claude.ai/code page for this session'))),
415
421
  key('q', () => void $.ui.close({ id: PANE })),
416
422
  ]
417
423
 
@@ -220,6 +220,38 @@ test('a switch hands the keys back to the prompt before it asks, so a return lan
220
220
  await ui.unmount()
221
221
  })
222
222
 
223
+ test('o and w hand the keys back to the prompt before they open the browser', async ($, on) => {
224
+ mock.env(on, { HOME: '/Users/me' })
225
+ mock.clock(on, { now: 1_700_000_000_000 })
226
+ const remote = { ...ROWS[1], remote: 'https://claude.ai/code/session_b2' }
227
+ on('state.get', ($, e, next) => (e.key === 'rows' ? seeded([remote]) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
228
+ const calls: string[] = []
229
+ on('ui.close', ($, e) => {
230
+ calls.push(`close ${e.id}`)
231
+ return { value: undefined }
232
+ })
233
+ on('ui.open', ($, e) => {
234
+ calls.push(`open ${e.id}${e.focus ? ' focused' : ''}`)
235
+ return { value: { isPlaced: true } }
236
+ })
237
+ on('process.run', ($, e) => {
238
+ calls.push(e.argv.join(' '))
239
+ return { value: { code: 0, stdout: '', stderr: '' } }
240
+ })
241
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
242
+ await ui.press({ key: 'o' })
243
+ await ui.press({ key: 'w' })
244
+ expect(calls).toEqual([
245
+ 'close inbox',
246
+ 'open inbox',
247
+ 'open https://github.com/o/r/pull/7',
248
+ 'close inbox',
249
+ 'open inbox',
250
+ 'open https://claude.ai/code/session_b2',
251
+ ])
252
+ await ui.unmount()
253
+ })
254
+
223
255
  test('a click on a row puts the cursor on it before it asks for the switch', async ($, on) => {
224
256
  mock.env(on, { HOME: '/Users/me' })
225
257
  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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron
@@ -108,21 +108,28 @@ files:
108
108
  - lib/claude_inbox/actions.rb
109
109
  - lib/claude_inbox/agents_client.rb
110
110
  - lib/claude_inbox/app.rb
111
+ - lib/claude_inbox/app/attaching.rb
112
+ - lib/claude_inbox/app/browsing.rb
113
+ - lib/claude_inbox/app/filtering.rb
114
+ - lib/claude_inbox/app/navigating.rb
115
+ - lib/claude_inbox/app/spawning.rb
116
+ - lib/claude_inbox/app/triaging.rb
117
+ - lib/claude_inbox/app/updating.rb
118
+ - lib/claude_inbox/cli.rb
111
119
  - lib/claude_inbox/config.rb
112
120
  - lib/claude_inbox/debug.rb
113
121
  - lib/claude_inbox/dialog.rb
114
122
  - lib/claude_inbox/headless.rb
115
123
  - lib/claude_inbox/images.rb
124
+ - lib/claude_inbox/input.rb
116
125
  - lib/claude_inbox/job_state.rb
117
126
  - lib/claude_inbox/keymap.rb
118
- - lib/claude_inbox/logs.rb
119
127
  - lib/claude_inbox/mod.rb
120
128
  - lib/claude_inbox/mouse.rb
121
129
  - lib/claude_inbox/new_session_form.rb
122
130
  - lib/claude_inbox/painter.rb
123
131
  - lib/claude_inbox/palette.rb
124
132
  - lib/claude_inbox/paste.rb
125
- - lib/claude_inbox/peek.rb
126
133
  - lib/claude_inbox/poller.rb
127
134
  - lib/claude_inbox/pull_requests.rb
128
135
  - lib/claude_inbox/rate_limits.rb
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "vt_screen"
4
-
5
- module ClaudeInbox
6
- # The `claude logs <id>` replay of each session as readable lines, fetched
7
- # off the main thread, debounced and cached.
8
- class Logs
9
- DEBOUNCE = 0.25
10
- TTL = 10
11
- MAX_LINES = 400
12
-
13
- def initialize(client, clock: -> { Time.now })
14
- @client = client
15
- @clock = clock
16
- @cache = {}
17
- @mutex = Mutex.new
18
- @pending = nil
19
- @pending_at = nil
20
- @requests = Queue.new
21
- end
22
-
23
- def start
24
- return if @thread&.alive?
25
- @thread = Thread.new { worker }
26
- end
27
-
28
- def cached(id)
29
- @mutex.synchronize { @cache[id]&.first }
30
- end
31
-
32
- def want(id)
33
- @pending = id
34
- @pending_at = @clock.call
35
- end
36
-
37
- # Promotes a request that has sat still for DEBOUNCE to the worker,
38
- # unless the cache already has a fresh answer.
39
- def tick
40
- return unless @pending && @clock.call - @pending_at >= DEBOUNCE
41
- id = @pending
42
- @pending = nil
43
- fresh = @mutex.synchronize { (e = @cache[id]) && @clock.call - e[1] < TTL }
44
- @requests << id unless fresh
45
- end
46
-
47
- def stop = @thread&.kill
48
-
49
- private
50
-
51
- def worker
52
- loop do
53
- id = @requests.pop
54
- id = @requests.pop until @requests.empty? # only the latest matters
55
- lines = fetch(id)
56
- @mutex.synchronize { @cache[id] = [lines, @clock.call] }
57
- end
58
- end
59
-
60
- def fetch(id)
61
- raw = @client.logs(id)
62
- return ["(no output available — the session's process is not running)"] if raw.nil?
63
- VtScreen.new.feed(raw).lines.last(MAX_LINES)
64
- rescue => e
65
- ["(logs failed: #{e.message})"]
66
- end
67
- end
68
- end
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ClaudeInbox
4
- # The peek pane: whether it is open, which row it is on, how far back it
5
- # is scrolled, and what to paint for that. The lines themselves come from
6
- # Logs, which it asks on every selection change so that opening the pane
7
- # lands on a warm cache. Main-thread state only.
8
- class Peek
9
- TERMINAL_NOTE = "This is a claude you opened in a terminal yourself. The daemon can't attach to it, read its output, or stop it from outside. Switch to that window."
10
- REMOTE_NOTE = "This is a Remote Control session driven from claude.ai/code. The daemon can't attach to it or read its output from here. Enter pulls it into the daemon; w opens it in the web or mobile app."
11
-
12
- # What Renderer paints: the body lines, the title bar, the dim line under it.
13
- View = Struct.new(:lines, :title, :subtitle)
14
-
15
- def initialize(logs)
16
- @logs = logs
17
- @open = false
18
- @offset = 0
19
- @session = nil
20
- end
21
-
22
- def open? = @open
23
-
24
- def toggle
25
- @open = !@open
26
- @logs.want(@session.id) if @open && @session&.actionable?
27
- end
28
-
29
- def close = @open = false
30
-
31
- # Called on every selection change; a fold has no session.
32
- def select(session)
33
- @session = session
34
- @offset = 0
35
- @logs.want(session.id) if session&.actionable?
36
- end
37
-
38
- # Positive scrolls back into history, negative towards the tail.
39
- def scroll(delta)
40
- @offset = [@offset + delta, 0].max
41
- end
42
-
43
- # `row` is the selected row as the frame shows it, or nil on a fold or
44
- # with nothing selected. Answers nil when there is no pane to paint.
45
- def view(row, body_h)
46
- return nil unless @open && row
47
- View.new(scrolled(body(row), body_h), row.label, subtitle(row))
48
- end
49
-
50
- private
51
-
52
- def body(row)
53
- return interactive_note(row.session) if row.session.interactive?
54
- @logs.cached(row.session.id) || ["(loading…)"]
55
- end
56
-
57
- def interactive_note(s)
58
- [s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}", s.remote_url].compact
59
- end
60
-
61
- def subtitle(row)
62
- s = row.session
63
- parts = [s.effective_state, s.status, s.waiting_for, s.id, s.started_at&.strftime("started %b %-d %H:%M")].compact
64
- parts += s.prs.map { |pr| "#{pr.short} #{pr.state&.downcase || "?"}" }
65
- parts.join(" · ")
66
- end
67
-
68
- # Lines are shown tail-first; the offset scrolls back into history and is
69
- # clamped to however much history there is.
70
- def scrolled(lines, view_h)
71
- return lines if @offset.zero?
72
- max_off = [lines.size - view_h, 0].max
73
- @offset = [@offset, max_off].min
74
- lines[0, lines.size - @offset]
75
- end
76
- end
77
- end