claude-inbox 0.6.1 → 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.
@@ -1,20 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-reader"
4
3
  require_relative "agents_client"
5
4
  require_relative "debug"
6
5
  require_relative "dialog"
6
+ require_relative "input"
7
7
  require_relative "text_buffer"
8
8
  require_relative "store"
9
9
  require_relative "renderer"
10
10
  require_relative "terminal"
11
- require_relative "logs"
12
- require_relative "peek"
13
11
  require_relative "keymap"
14
12
  require_relative "remote/listener"
15
- require_relative "mouse"
16
13
  require_relative "new_session_form"
17
- require_relative "paste"
18
14
  require_relative "pull_requests"
19
15
  require_relative "reaper"
20
16
  require_relative "poller"
@@ -26,11 +22,26 @@ require_relative "switch"
26
22
  require_relative "trust"
27
23
  require_relative "mod"
28
24
  require_relative "updater"
25
+ require_relative "app/updating"
26
+ require_relative "app/filtering"
27
+ require_relative "app/triaging"
28
+ require_relative "app/navigating"
29
+ require_relative "app/attaching"
30
+ require_relative "app/spawning"
31
+ require_relative "app/browsing"
29
32
 
30
33
  module ClaudeInbox
31
34
  # Owns the terminal and the key loop. The only class allowed to spawn a
32
35
  # child process that takes over the terminal.
33
36
  class App
37
+ include Updating
38
+ include Filtering
39
+ include Triaging
40
+ include Navigating
41
+ include Attaching
42
+ include Spawning
43
+ include Browsing
44
+
34
45
  # The reaper deletes sessions and the listener lets other machines in,
35
46
  # so both are off unless `bin/claude-inbox` switches them on.
36
47
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
@@ -49,24 +60,20 @@ module ClaudeInbox
49
60
  @color = color
50
61
  @trust = trust
51
62
  @renderer = Renderer.new(color: color)
52
- @input = input
53
- @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
63
+ @input = Input.new(input, out)
54
64
  @queue = queue
55
65
  @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
56
66
  reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions)
57
67
  @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
58
- @logs = Logs.new(client)
59
- @peek = Peek.new(@logs)
60
68
  @selected = nil
61
69
  @row_items = []
62
- @list_width = nil
63
70
  @top = 0
64
71
  @expanded = Hash.new(false)
65
72
  @keymap = Keymap.new
66
- @paste = Paste.new
67
73
  @tick = 0
68
74
  @modal = nil
69
75
  @filter = nil
76
+ @filter_editing = false
70
77
  @last_poll = nil
71
78
  @quit = false
72
79
  @resize = false
@@ -80,19 +87,16 @@ module ClaudeInbox
80
87
  @terminal.enter
81
88
  @poller.start
82
89
  @listener.start
83
- @logs.start
84
90
  main_loop
85
91
  ensure
86
92
  @poller.stop
87
- @logs.stop
88
93
  @terminal.restore
89
94
  @listener.stop
90
95
  end
91
96
 
92
97
  def step(input = nil)
93
- check_for_update unless @update_status
98
+ ensure_update_checked
94
99
  drain_queue
95
- @logs.tick
96
100
  if @resize
97
101
  @resize = false
98
102
  @terminal.resized
@@ -100,7 +104,7 @@ module ClaudeInbox
100
104
  handle_input(input) if input
101
105
  # A paste arrives a character per read and shows nothing until it
102
106
  # closes, so a frame per character would only stall a long one.
103
- render unless @paste.pasting?
107
+ render unless @input.pasting?
104
108
  end
105
109
 
106
110
  private
@@ -111,8 +115,6 @@ module ClaudeInbox
111
115
  trap("WINCH") { @resize = true } if Signal.list.key?("WINCH")
112
116
  end
113
117
 
114
- # ----- threads ----------------------------------------------------------
115
-
116
118
  # Runs a block off the main thread; a failure lands in the status line
117
119
  # rather than killing the thread silently.
118
120
  def in_background
@@ -133,39 +135,26 @@ module ClaudeInbox
133
135
  when :error then @error = rest[0]
134
136
  when :notice then notice(rest[0])
135
137
  when :update then update_checked(*rest)
136
- when :select then @pending_select = rest[0]
138
+ when :select then select_later(rest[0])
137
139
  when :attach then attach(rest[0])
138
- when :form_started then @modal = nil if @modal.equal?(rest[0])
140
+ when :form_started then form_started(rest[0])
139
141
  when :form_failed then form_failed(*rest)
140
142
  when :remote_started then remote_started(*rest)
141
143
  end
142
144
  end
143
145
  end
144
146
 
145
- # ----- main loop --------------------------------------------------------
146
-
147
147
  def main_loop
148
- step(@paste.pasting? ? read_pasted : @reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
149
- end
150
-
151
- # As much of an open paste as has arrived, read straight off the tty:
152
- # the reader switches echo off and back on around every character, and
153
- # on a long paste that is most of the wait.
154
- def read_pasted
155
- return nil unless @input.wait_readable(0.1)
156
- out = +""
157
- while out.size < 65_536 && @input.wait_readable(0) && (c = @input.getc)
158
- out << c
159
- end
160
- out
148
+ step(@input.read) until @quit
161
149
  end
162
150
 
163
151
  def handle_input(raw)
164
- @paste.feed(raw).each do |kind, text|
165
- next handle_paste(text) if kind == :paste
166
- events = Mouse.events(text)
167
- next events.each { |e| handle_mouse(e) } if events.any?
168
- split_keys(text).each { |k| handle_key(k) }
152
+ @input.events(raw).each do |kind, *event|
153
+ case kind
154
+ when :paste then handle_paste(*event)
155
+ when :mouse then handle_mouse(*event)
156
+ when :key then handle_key(*event)
157
+ end
169
158
  end
170
159
  end
171
160
 
@@ -175,163 +164,13 @@ module ClaudeInbox
175
164
  # "y" answers a confirm.
176
165
  def handle_paste(text)
177
166
  return @modal.paste(text) if @modal.is_a?(NewSessionForm)
178
- typing = @modal ? @modal.is_a?(Dialog::Prompt) : @filter_editing
179
- text.each_char { |c| handle_key(c) } if typing
180
- end
181
-
182
- def render
183
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
- now = Time.now
185
- all = @store.sections(now)
186
- @snapshot.write(all, now)
187
- sections = filtered(all)
188
- width, height = @terminal.size
189
- body_h = Renderer.body_height(height)
190
- ensure_selection(sections)
191
- @tick += 1
192
- view = Renderer::View.new(
193
- width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
194
- peek: @peek.view(sections.row(@selected), body_h), modal: modal_lines(width), screen: screen_lines(width, body_h),
195
- status: status_text(now), usage: @rate_limits.windows(now), filter: @filter, filter_editing: @filter_editing,
196
- tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
197
- )
198
- frame = @renderer.frame(sections, view)
199
- @row_items = frame.items
200
- @list_width = frame.list_width
201
- @top = frame.top
202
- @terminal.paint(frame.lines)
203
- dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
204
- Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
205
- end
206
-
207
- # Seconds spent waiting for the first poll; nil once one has landed, or
208
- # failed — a failure has its own line in the header and the empty state
209
- # already says how to retry.
210
- def loading_for
211
- return nil if @last_poll || @error || !@booted_at
212
- Process.clock_gettime(Process::CLOCK_MONOTONIC) - @booted_at
213
- end
214
-
215
- def status_text(now)
216
- return @notice[0] if @notice && now < @notice[1]
217
- return "⚠ #{@error}" if @error
218
- return "polling…" unless @last_poll
219
- "#{@update.notice} · U updates" if @update
220
- end
221
-
222
- # ----- updates ----------------------------------------------------------
223
-
224
- # Once, on the first step: a stale cache means a network round trip, so
225
- # it runs off the main thread. A laptop that is offline stays quiet
226
- # rather than show an error at every launch; U says what went wrong.
227
- def check_for_update
228
- @update_status = @updater ? "checking for updates…" : "update check is off — claude-inbox update installs the latest"
229
- return unless @updater
230
- in_background do
231
- @queue << [:update, @updater.check]
232
- rescue Updater::Error => e
233
- @queue << [:update, nil, e.message]
234
- end
235
- end
236
-
237
- def update_checked(update, failure = nil)
238
- @update = update
239
- @snapshot.update = update&.notice
240
- @update_status = failure ? "couldn't check for updates: #{failure}" : "up to date"
241
- end
242
-
243
- # The install prints on the real screen, and the relaunch replaces this
244
- # process. A failed install relaunches this version instead, once its
245
- # error has been read, rather than leave the shell showing.
246
- def update_inbox
247
- return notice(@update_status) unless @update
248
- @terminal.release do
249
- @out.puts @update.installing
250
- applied = @updater.apply
251
- @out.puts applied.output unless applied.output.empty?
252
- @terminal.pause unless applied.success?
253
- relaunch
254
- end
255
- end
256
-
257
- def relaunch
258
- @updater.relaunch(@argv)
259
- rescue SystemCallError => e
260
- @error = "relaunch failed: #{e.message}"
261
- end
262
-
263
- def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
264
-
265
- def ensure_selection(sections)
266
- stops = sections.selections(@expanded)
267
- pending = @pending_select && Store::Selection.row(@pending_select)
268
- if pending && stops.include?(pending)
269
- select(pending)
270
- @pending_select = nil
271
- return
272
- end
273
- return if stops.include?(@selected)
274
- select(stops.first)
275
- end
276
-
277
- def select(selection)
278
- @selected = selection
279
- @peek.select(selected_session)
280
- end
281
-
282
- def session_for(key) = @store.sessions.find { |s| s.key == key }
283
-
284
- def selected_session = session_for(@selected&.key)
285
-
286
- # Guard for attach/stop: refuse politely on a terminal or remote row.
287
- def require_actionable
288
- s = selected_session
289
- return true if s&.actionable?
290
- if s&.interactive?
291
- notice(s.remote? ? "that's a remote session — Enter adopts it, w opens it at claude.ai/code" : "that's your own terminal — switch to that window")
292
- end
293
- false
167
+ typing = @modal ? @modal.is_a?(Dialog::Prompt) : filter_editing?
168
+ @input.typed(text).each { |name, c| handle_key(name, c) } if typing
294
169
  end
295
170
 
296
- # Guard for snooze/wake/alias: anything with a key, since those live in
297
- # our own store. A terminal you are sitting in is the one exception.
298
- def require_storable
299
- s = selected_session
300
- return true if @selected&.row? && !s&.terminal?
301
- notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
302
- false
303
- end
304
-
305
- # The edits with no request form, which a second inbox cannot keep.
306
- def require_writable(verb)
307
- return true if @store.writable?
308
- notice("another claude-inbox owns state.json — #{verb} there")
309
- false
310
- end
311
-
312
- def notice(msg)
313
- @notice = [msg, Time.now + 4]
314
- end
315
-
316
- # ----- keys -------------------------------------------------------------
317
-
318
- def key_name(key)
319
- @reader.console.keys[key] || key
320
- end
321
-
322
- # tty-reader glues ESC to whatever arrives within 100ms, so a fast
323
- # "esc gg" comes in as one unknown key "\egg". Vim hands make that
324
- # constantly. Unknown ESC-prefixed strings become ESC + the rest.
325
- def split_keys(key)
326
- return [key] if key.size <= 1 || @reader.console.keys.key?(key)
327
- return [key] unless key.start_with?("\e")
328
- ["\e"] + key[1..].chars
329
- end
330
-
331
- def handle_key(key)
332
- name = key_name(key)
171
+ def handle_key(name, key)
333
172
  return handle_modal_key(name, key) if @modal
334
- return handle_line_key(name, key) if @filter_editing
173
+ return handle_line_key(name, key) if filter_editing?
335
174
 
336
175
  action = @keymap.press(name, key)
337
176
  perform(action) if action
@@ -341,30 +180,32 @@ module ClaudeInbox
341
180
  # the normal action table (see handle_key); mouse input defers to the
342
181
  # same rule rather than reaching past whatever has focus.
343
182
  def handle_mouse(event)
344
- return if @modal || @filter_editing
183
+ return if @modal || filter_editing?
345
184
  case event.kind
346
- when :click then click_row(event.row, event.col)
185
+ when :click then click_row(event.row)
347
186
  when :scroll_up then perform(:up)
348
187
  when :scroll_down then perform(:down)
349
188
  end
350
189
  end
351
190
 
352
- # Clicking a row selects it and attaches, same as landing on it with
353
- # j/k and pressing Enter — activate already knows how to expand a fold
354
- # or refuse a terminal/remote row, so this doesn't repeat that.
355
- def click_row(row, col)
356
- return if @list_width && col > @list_width
357
- item = row_item_at(row)
358
- return unless item
359
- select(item)
360
- activate
361
- end
362
-
363
- # The wrapped detail line under a two-line row ("↳ ~/code/x") carries
364
- # no item of its own; a click there resolves to the row above it.
365
- def row_item_at(row)
366
- idx = row - 1
367
- @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
191
+ def handle_modal_key(name, key)
192
+ return handle_form_key(name, key) if @modal.is_a?(NewSessionForm)
193
+ case @modal.press(name, key)
194
+ when :cancel then @modal = nil
195
+ when :snooze then snooze_chosen
196
+ when :confirm
197
+ kind, id = @modal.kind, @modal.id
198
+ @modal = nil
199
+ case kind
200
+ when :stop then stop_session(id)
201
+ when :delete then delete_session(id)
202
+ when :adopt then adopt_session(id)
203
+ when :install then install_pane
204
+ end
205
+ when :save then save_prompt
206
+ when :copy then @listener.copy_pairing_url
207
+ when :rotate then @listener.rotate
208
+ end
368
209
  end
369
210
 
370
211
  def perform(action)
@@ -380,13 +221,10 @@ module ClaudeInbox
380
221
  when :page_up then move(-page)
381
222
  when :next_section then jump_section(1)
382
223
  when :prev_section then jump_section(-1)
383
- when :peek_down then @peek.scroll(-1)
384
- when :peek_up then @peek.scroll(1)
385
224
  when :activate then activate
386
- when :collapse then collapse
387
225
  when :fold_open then set_expanded(true)
388
226
  when :fold_close then set_expanded(false)
389
- when :fold_toggle then set_expanded(!@expanded[current_fold_section])
227
+ when :fold_toggle then toggle_expanded
390
228
  when :snooze then open_snooze_menu
391
229
  when :wake then wake_selected
392
230
  when :toggle_pin then toggle_pin_selected
@@ -399,7 +237,6 @@ module ClaudeInbox
399
237
  when :delete then open_confirm(:delete)
400
238
  when :refresh then @poller.soon
401
239
  when :update then update_inbox
402
- when :toggle_peek then toggle_peek
403
240
  when :new_session then open_new_session
404
241
  when :remote_pairing then @modal = @listener.pairing_dialog
405
242
  when :filter then start_filter
@@ -407,161 +244,34 @@ module ClaudeInbox
407
244
  end
408
245
  end
409
246
 
410
- def page = [Renderer.body_height(@terminal.size[1]), 1].max
411
-
412
- def move(delta)
413
- stops = filtered.selections(@expanded)
414
- return if stops.empty?
415
- idx = stops.index(@selected) || 0
416
- select(stops[(idx + delta).clamp(0, stops.size - 1)])
417
- end
418
-
419
- # Tab / Shift-Tab: first selectable row of the next / previous section.
420
- def jump_section(dir)
421
- sections = filtered
422
- heads = sections.heads(@expanded)
423
- return if heads.empty?
424
- current = sections.section_of(@selected)
425
- idx = heads.index { |name, _| name == current } || -1
426
- select(heads[(idx + dir) % heads.size].last)
427
- end
428
-
429
- # The foldable section the cursor is currently on or inside, if any.
430
- def current_fold_section
431
- name = filtered.section_of(@selected)
432
- name if Store::FOLDABLE_SECTIONS.include?(name)
433
- end
434
-
435
- def set_expanded(value)
436
- name = current_fold_section
437
- @expanded[name] = value if name
438
- end
439
-
440
- # vim-ish "h": close whatever is open, innermost first.
441
- def collapse
442
- if @peek.open? then @peek.close
443
- elsif (name = current_fold_section) && @expanded[name] then @expanded[name] = false
444
- end
445
- @terminal.invalidate
446
- end
447
-
448
- def activate
449
- return @expanded[@selected.key] = true if @selected&.fold?
450
- return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.remote?
451
- attach(@selected.key) if require_actionable
452
- end
453
-
454
- def toggle_peek
455
- @peek.toggle
456
- @terminal.invalidate
457
- end
458
-
459
- def wake_selected
460
- @store.wake(@selected.key) if require_storable
461
- end
462
-
463
- def toggle_pin_selected
464
- @store.toggle_pin(@selected.key) if require_storable
465
- end
466
-
467
- def settle_selected
468
- return unless require_storable
469
- @store.settle(@selected.key)
470
- notice("settled — u brings it back")
471
- end
472
-
473
- # The pane in the attached session may ask for a different one: the
474
- # attach ends and the next starts without passing through the list. The
475
- # poller keeps running meanwhile, so the pane's snapshot stays fresh. A
476
- # failed attach leaves its reason on screen until a key is pressed.
477
- def attach(id)
478
- while id
479
- @store.acknowledge(id)
480
- @switch.clear
481
- from = session_for(id)&.session_id
482
- @terminal.release do
483
- @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
484
- end
485
- id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
486
- end
487
- end
488
-
489
- # ----- modals -----------------------------------------------------------
490
-
491
- def open_snooze_menu
492
- return unless require_storable
493
- @modal = Dialog::Snooze.new(@selected.key)
494
- end
495
-
496
- def open_confirm(kind)
497
- return unless require_actionable
498
- @modal = Dialog::Confirm.new(kind, @selected.key)
499
- end
500
-
501
- def open_alias_editor
502
- return unless require_storable && require_writable("rename")
503
- current = @store.alias_for(@selected.key) || ""
504
- @modal = Dialog::Prompt.new(:alias, @selected.key, current)
505
- end
506
-
507
- def open_pr_editor
508
- return unless require_storable && require_writable("link a pull request")
509
- current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
510
- @modal = Dialog::Prompt.new(:pr, @selected.key, current)
511
- end
512
-
513
- def open_pr
514
- pr = selected_session&.pr
515
- return notice("no pull request linked — P sets one") unless pr
516
- open_in_browser(pr.url, pr.short)
517
- end
518
-
519
- def open_remote
520
- url = selected_session&.remote_url
521
- return notice("no claude.ai/code page for this session") unless url
522
- open_in_browser(url, "claude.ai/code")
523
- end
524
-
525
- def open_in_browser(url, what)
526
- opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
527
- notice("opening #{what}")
528
- in_background { Subprocess.capture(opener, url) }
529
- end
530
-
531
- def open_new_session
532
- cwd = selected_session&.cwd || Dir.pwd
533
- dirs = SessionRequest.directories(@store.sessions, @trust.call)
534
- @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
535
- end
536
-
537
- # `attach:` hands the terminal over as soon as the session starts. Without
538
- # it we stay in the inbox and poll, so the new row shows up right away
539
- # rather than at the next tick.
540
- def start_session(form, attach:)
541
- v = form.values
542
- notice("starting session…")
543
- in_background do
544
- id = @client.spawn(**v)
545
- @queue << [:notice, "started #{id}"]
546
- @queue << [:form_started, form]
547
- @queue << [:select, id]
548
- attach ? @queue << [:attach, id] : @poller.soon
549
- rescue AgentsClient::Error => e
550
- @queue << [:form_failed, form, e.message]
551
- end
552
- end
553
-
554
- # Someone at the desk may be mid-thought, so a start from another
555
- # device never moves the cursor, attaches or closes what is open.
556
- def remote_started(id, via)
557
- notice("started #{id} from #{via}")
558
- @poller.soon
247
+ def install_pane
248
+ @pane_offer.install
249
+ notice("added the pane — new Claude Code sessions load it; /inbox opens it")
250
+ rescue JSON::ParserError
251
+ notice("~/.claude/settings.json is not valid JSON, so it was left alone")
559
252
  end
560
253
 
561
- # A spawn failure hands the form back rather than just logging it, so
562
- # the composed prompt survives (see NewSessionForm#submission_failed).
563
- def form_failed(form, message)
564
- @modal.equal?(form) ? form.submission_failed(message) : (@error = message)
254
+ def render
255
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
256
+ now = Time.now
257
+ all = @store.sections(now)
258
+ @snapshot.write(all, now)
259
+ sections = filtered(all)
260
+ width, height = @terminal.size
261
+ body_h = Renderer.body_height(height)
262
+ ensure_selection(sections)
263
+ @tick += 1
264
+ view = Renderer::View.new(
265
+ width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
266
+ modal: modal_lines(width), screen: screen_lines(width, body_h),
267
+ status: status_text(now), usage: @rate_limits.windows(now), filter: filter, filter_editing: filter_editing?,
268
+ tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
269
+ )
270
+ frame = @renderer.frame(sections, view)
271
+ placed(frame)
272
+ @terminal.paint(frame.lines)
273
+ dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
274
+ Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
565
275
  end
566
276
 
567
277
  # The new-session form takes the whole body; a Dialog is a box over it.
@@ -574,110 +284,23 @@ module ClaudeInbox
574
284
  @modal.frame(width, @renderer.caret) if @modal.is_a?(Dialog)
575
285
  end
576
286
 
577
- def handle_modal_key(name, key)
578
- return handle_form_key(name, key) if @modal.is_a?(NewSessionForm)
579
- case @modal.press(name, key)
580
- when :cancel then @modal = nil
581
- when :snooze
582
- @store.snooze(@modal.id, @modal.choice)
583
- @modal = nil
584
- when :confirm
585
- kind, id = @modal.kind, @modal.id
586
- @modal = nil
587
- case kind
588
- when :stop then stop_session(id)
589
- when :delete then delete_session(id)
590
- when :adopt then adopt_session(id)
591
- when :install then install_pane
592
- end
593
- when :save then save_prompt
594
- when :copy then @listener.copy_pairing_url
595
- when :rotate then @listener.rotate
596
- end
597
- end
598
-
599
- # `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
600
- # closes itself once the spawn is confirmed to have started (`:form_started`)
601
- # or reopens with the failure and the prompt intact (`:form_failed`).
602
- def handle_form_key(name, key)
603
- form = @modal
604
- case form.press(name, key)
605
- when :cancel then @modal = nil
606
- when :start then start_session(form, attach: false)
607
- when :start_and_attach then start_session(form, attach: true)
608
- end
609
- end
610
-
611
- def stop_session(id)
612
- in_background do
613
- @client.stop(id)
614
- @poller.soon
615
- end
616
- end
617
-
618
- def adopt_session(key)
619
- s = session_for(key)
620
- return unless s&.remote?
621
- notice("adopting #{s.display_name}…")
622
- in_background do
623
- id = @client.adopt(session_id: s.session_id, cwd: s.cwd, pid: s.pid)
624
- @queue << [:notice, "adopted as #{id}"]
625
- @queue << [:select, id]
626
- @queue << [:attach, id]
627
- end
628
- end
629
-
630
- def install_pane
631
- @pane_offer.install
632
- notice("added the pane — new Claude Code sessions load it; /inbox opens it")
633
- rescue JSON::ParserError
634
- notice("~/.claude/settings.json is not valid JSON, so it was left alone")
635
- end
636
-
637
- def delete_session(id)
638
- notice("deleting #{id}…")
639
- in_background do
640
- @client.rm(id)
641
- @store.forget(id)
642
- @queue << [:notice, "deleted #{id}"]
643
- @poller.soon
644
- end
645
- end
646
-
647
- def save_prompt
648
- value = @modal.value.strip
649
- if @modal.kind == :alias
650
- @store.set_alias(@modal.id, value)
651
- elsif value.empty? || PullRequests.valid_url?(value)
652
- @store.set_pr(@modal.id, value)
653
- @poller.soon
654
- else
655
- return notice("that's not a github.com pull request url")
656
- end
657
- @modal = nil
658
- end
659
-
660
- # ----- filter line --------------------------------------------------------
661
-
662
- def start_filter
663
- @filter ||= TextBuffer.new
664
- @filter_editing = true
287
+ def status_text(now)
288
+ return @notice[0] if @notice && now < @notice[1]
289
+ return "⚠ #{@error}" if @error
290
+ return "polling…" unless @last_poll
291
+ update_notice
665
292
  end
666
293
 
667
- def clear_filter
668
- @filter = nil
669
- @filter_editing = false
294
+ # Seconds spent waiting for the first poll; nil once one has landed, or
295
+ # failed — a failure has its own line in the header and the empty state
296
+ # already says how to retry.
297
+ def loading_for
298
+ return nil if @last_poll || @error || !@booted_at
299
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @booted_at
670
300
  end
671
301
 
672
- def handle_line_key(name, key)
673
- case name
674
- when :escape then clear_filter
675
- when :return, :enter then @filter_editing = false
676
- when :backspace, :ctrl_h
677
- @filter.empty? ? clear_filter : @filter.press(name, key)
678
- else
679
- @filter.press(name, key)
680
- end
302
+ def notice(msg)
303
+ @notice = [msg, Time.now + 4]
681
304
  end
682
305
  end
683
306
  end