claude-inbox 0.1.0 → 0.2.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "debug"
5
+ require_relative "job_state"
5
6
  require_relative "subprocess"
6
7
 
7
8
  module ClaudeInbox
@@ -10,8 +11,11 @@ module ClaudeInbox
10
11
  class AgentsClient
11
12
  class Error < StandardError; end
12
13
 
13
- def initialize(bin: "claude")
14
+ attr_reader :jobs_dir
15
+
16
+ def initialize(bin: "claude", jobs_dir: JobState::DEFAULT_DIR)
14
17
  @bin = bin
18
+ @jobs_dir = jobs_dir
15
19
  end
16
20
 
17
21
  # => Array<Session>
@@ -72,16 +76,59 @@ module ClaudeInbox
72
76
  def self.mention(path) = "@" + path.gsub(" ", "\\ ")
73
77
 
74
78
  # Pure so it can be tested: "default" means leave the flag off.
75
- def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil)
79
+ # --remote-control takes an optional name and eats whatever follows it,
80
+ # prompt included, so it goes last.
81
+ def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
76
82
  argv = [bin, "--bg", prompt]
77
83
  argv += ["--model", model] if model && model != "default"
78
84
  argv += ["--effort", effort] if effort && effort != "default"
79
85
  argv += ["--permission-mode", permission_mode] if permission_mode && permission_mode != "default"
80
86
  argv += ["--name", name] if name && !name.strip.empty?
81
87
  argv << "--worktree" if worktree
88
+ argv << "--remote-control" if remote
82
89
  argv
83
90
  end
84
91
 
92
+ # Pull a conversation a `claude remote-control` server is serving into
93
+ # the daemon, as a background session under the same id. Returns the
94
+ # short id. The worker goes first: while it is open, a resume only makes
95
+ # a copy. The server marks the session failed and leaves it be; the
96
+ # resumed session gets a bridge of its own.
97
+ def adopt(session_id:, cwd:, pid:)
98
+ raise Error, "nothing to adopt yet — send it a message from your phone first" if transcript_empty?(cwd, session_id)
99
+ Process.kill("TERM", pid)
100
+ wait_gone(pid)
101
+ r = Subprocess.capture(*self.class.adopt_args(@bin, session_id), chdir: cwd)
102
+ raise Error, "claude --bg --resume failed: #{(r.err + r.out).strip}" unless r.success?
103
+ r.out[/\b[0-9a-f]{8}\b/] || r.out.strip
104
+ end
105
+
106
+ def self.adopt_args(bin, session_id) = [bin, "--bg", "--resume", session_id, "--remote-control"]
107
+
108
+ # Where the CLI keeps a directory's transcripts: every slash and dot in
109
+ # the path becomes a dash.
110
+ def self.transcript_path(cwd, session_id, home: Dir.home)
111
+ File.join(home, ".claude", "projects", cwd.gsub(%r{[/.]}, "-"), "#{session_id}.jsonl")
112
+ end
113
+
114
+ # A worker nobody has messaged yet has an empty file, and the daemon
115
+ # reports "source session not found" on resuming it.
116
+ def transcript_empty?(cwd, session_id)
117
+ path = self.class.transcript_path(cwd, session_id)
118
+ File.exist?(path) && File.zero?(path)
119
+ end
120
+
121
+ def wait_gone(pid, timeout: 5)
122
+ deadline = Time.now + timeout
123
+ while Time.now < deadline
124
+ Process.kill(0, pid)
125
+ sleep 0.1
126
+ end
127
+ raise Error, "the worker #{pid} would not exit"
128
+ rescue Errno::ESRCH
129
+ nil
130
+ end
131
+
85
132
  def parse(json)
86
133
  JSON.parse(json).map { |h| Session.from_hash(h) }
87
134
  end
@@ -99,15 +146,17 @@ module ClaudeInbox
99
146
  def classify_origins(sessions)
100
147
  pids = sessions.select { |s| s.interactive? && s.pid }.map(&:pid)
101
148
  return sessions if pids.empty?
102
- assign_origins(sessions, origins_by_pid(pids))
149
+ rows = ps_rows(pids)
150
+ assign_origins(sessions, origins_from(rows), self.class.bridge_ids(rows))
103
151
  end
104
152
 
105
153
  # Tags each interactive session with where it is driven from (pid =>
106
- # origin, terminal when unlisted) and drops the unattended ones, whose
107
- # parent is the row worth showing.
108
- def assign_origins(sessions, origins)
154
+ # origin, terminal when unlisted) and the bridge a remote one is served
155
+ # over, and drops the unattended ones, whose parent is the row worth
156
+ # showing.
157
+ def assign_origins(sessions, origins, bridges = {})
109
158
  sessions
110
- .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal)) : s }
159
+ .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
111
160
  .reject(&:unattended?)
112
161
  end
113
162
 
@@ -137,12 +186,16 @@ module ClaudeInbox
137
186
  # terminal often enough to matter; the false positive is accepted.
138
187
  def self.headless?(cmd) = cmd.split.drop(1).any? { |arg| HEADLESS_FLAGS.include?(arg) }
139
188
 
140
- def origins_by_pid(pids)
141
- rows = ps_rows(pids)
189
+ # pid => bridge session id, for the workers a `claude remote-control`
190
+ # server spawned: each is started with `--session-id cse_…`, the same id
191
+ # a background session records as bridgeSessionId. Pure.
192
+ def self.bridge_ids(rows)
193
+ rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
194
+ end
195
+
196
+ def origins_from(rows)
142
197
  return {} if rows.empty?
143
198
  self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
144
- rescue Errno::ENOENT
145
- {}
146
199
  end
147
200
 
148
201
  def ps_rows(pids)
@@ -152,6 +205,8 @@ module ClaudeInbox
152
205
  pid, ppid, *cmd = l.split
153
206
  [pid.to_i, ppid.to_i, cmd.join(" ")]
154
207
  }
208
+ rescue Errno::ENOENT
209
+ []
155
210
  end
156
211
 
157
212
  # pid => command, for the given parent pids.
@@ -192,18 +247,19 @@ module ClaudeInbox
192
247
 
193
248
  # Reads a committed JSON fixture instead of the daemon.
194
249
  class FixtureClient < AgentsClient
195
- def initialize(path, logs: nil, origins: {})
196
- super()
250
+ def initialize(path, logs: nil, origins: {}, bridges: {})
251
+ super(jobs_dir: File.join(File.dirname(path), "jobs"))
197
252
  @path = path
198
253
  @logs = logs
199
254
  @origins = origins
255
+ @bridges = bridges
200
256
  end
201
257
 
202
258
  # Fixture rows carry no process tree; tag each interactive row from the
203
259
  # pid => origin map the test hands in, otherwise as a terminal, then drop
204
260
  # the unattended ones same as the real client does.
205
261
  def list
206
- assign_origins(parse(File.read(@path)), @origins)
262
+ assign_origins(parse(File.read(@path)), @origins, @bridges)
207
263
  end
208
264
 
209
265
  def logs(_id) = @logs
@@ -219,6 +275,8 @@ module ClaudeInbox
219
275
  "deadbeef"
220
276
  end
221
277
 
278
+ def adopt(session_id:, cwd:, pid:) = "adop7ed0"
279
+
222
280
  def rm(_id) = true
223
281
  end
224
282
  end
@@ -25,18 +25,21 @@ module ClaudeInbox
25
25
  # The reaper defaults to off. It is the only thing here that deletes a
26
26
  # session, so switching it on is `bin/claude-inbox`'s job and nothing
27
27
  # reaches it by forgetting an argument.
28
- def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new, jobs_dir: JobState::DEFAULT_DIR,
29
- rate_limits: RateLimits.new, reaper: Reaper.disabled, out: $stdout, input: $stdin, color: true)
28
+ def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
29
+ rate_limits: RateLimits.new, reaper: Reaper.disabled, out: $stdout, input: $stdin, color: true,
30
+ terminal: Terminal.new(out, input))
30
31
  @client = client
31
32
  @store = store
32
33
  @rate_limits = rate_limits
33
- @terminal = Terminal.new(out, input)
34
+ @terminal = terminal
34
35
  @color = color
35
36
  @renderer = Renderer.new(color: color)
36
37
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
37
38
  @queue = Queue.new
38
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, jobs_dir: jobs_dir,
39
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
39
40
  reaper: reaper, queue: @queue)
41
+ @logs = Logs.new(client)
42
+ @peek = Peek.new(@logs)
40
43
  @selected = nil
41
44
  @row_items = []
42
45
  @list_width = nil
@@ -47,7 +50,6 @@ module ClaudeInbox
47
50
  @tick = 0
48
51
  @modal = nil
49
52
  @filter = nil
50
- @status = "starting…"
51
53
  @last_poll = nil
52
54
  @quit = false
53
55
  @resize = false
@@ -58,15 +60,25 @@ module ClaudeInbox
58
60
  install_traps
59
61
  @terminal.enter
60
62
  @poller.start
61
- @logs = Logs.new(@client)
62
- @peek = Peek.new(@logs)
63
+ @logs.start
63
64
  main_loop
64
65
  ensure
65
66
  @poller.stop
66
- @logs&.stop
67
+ @logs.stop
67
68
  @terminal.restore
68
69
  end
69
70
 
71
+ def step(input = nil)
72
+ drain_queue
73
+ @logs.tick
74
+ if @resize
75
+ @resize = false
76
+ @terminal.resized
77
+ end
78
+ render
79
+ handle_input(input) if input
80
+ end
81
+
70
82
  private
71
83
 
72
84
  def install_traps
@@ -99,6 +111,8 @@ module ClaudeInbox
99
111
  when :notice then notice(rest[0])
100
112
  when :select then @pending_select = rest[0]
101
113
  when :attach then attach(rest[0])
114
+ when :form_started then @modal = nil if @modal.equal?(rest[0])
115
+ when :form_failed then form_failed(*rest)
102
116
  end
103
117
  end
104
118
  rescue ThreadError
@@ -108,17 +122,7 @@ module ClaudeInbox
108
122
  # ----- main loop --------------------------------------------------------
109
123
 
110
124
  def main_loop
111
- until @quit
112
- drain_queue
113
- @logs.tick
114
- if @resize
115
- @resize = false
116
- @terminal.resized
117
- end
118
- render
119
- key = @reader.read_keypress(echo: false, raw: false, nonblock: true)
120
- handle_input(key) if key
121
- end
125
+ step(@reader.read_keypress(echo: false, raw: false, nonblock: true)) until @quit
122
126
  end
123
127
 
124
128
  def handle_input(raw)
@@ -143,17 +147,14 @@ module ClaudeInbox
143
147
  sections = filtered(@store.sections(now))
144
148
  width, height = @terminal.size
145
149
  ensure_selection(sections)
146
- peek = @peek.view(sections.row(@selected), height)
147
150
  @tick += 1
148
- frame = @renderer.frame(
149
- sections, width: width, height: height, now: now,
150
- selected: @selected&.key, top: @top, expanded: @expanded,
151
- peek: peek&.lines, peek_title: peek&.title, peek_subtitle: peek&.subtitle,
152
- modal: modal_lines(width), screen: screen_lines(width, height), status: status_text(now), usage: @rate_limits.label(now),
153
- filter: @filter, filter_editing: @filter_editing, tick: @tick / 2,
154
- loading: loading_for
151
+ view = Renderer::View.new(
152
+ width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
153
+ peek: @peek.view(sections.row(@selected), height), modal: modal_lines(width), screen: screen_lines(width, height),
154
+ status: status_text(now), usage: @rate_limits.windows(now), filter: @filter, filter_editing: @filter_editing,
155
+ tick: @tick / 2, loading: loading_for
155
156
  )
156
- @items = frame.items.compact
157
+ frame = @renderer.frame(sections, view)
157
158
  @row_items = frame.items
158
159
  @list_width = frame.list_width
159
160
  @top = frame.top
@@ -203,7 +204,7 @@ module ClaudeInbox
203
204
  def require_actionable
204
205
  return true if selected_session&.actionable?
205
206
  if (s = selected_session)&.interactive?
206
- notice(s.remote? ? "that's a remote session — open it at claude.ai/code" : "that's your own terminal — switch to that window")
207
+ 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")
207
208
  end
208
209
  false
209
210
  end
@@ -301,6 +302,7 @@ module ClaudeInbox
301
302
  when :alias then open_alias_editor
302
303
  when :link_pr then open_pr_editor
303
304
  when :open_pr then open_pr
305
+ when :open_remote then open_remote
304
306
  when :stop then open_confirm(:stop)
305
307
  when :delete then open_confirm(:delete)
306
308
  when :refresh then @poller.soon
@@ -314,8 +316,8 @@ module ClaudeInbox
314
316
  def page = [@terminal.size[1] - 2, 1].max
315
317
 
316
318
  def move(delta)
317
- return if @items.nil? || @items.empty?
318
319
  stops = filtered.selections(@expanded)
320
+ return if stops.empty?
319
321
  idx = stops.index(@selected) || 0
320
322
  select(stops[(idx + delta).clamp(0, stops.size - 1)])
321
323
  end
@@ -350,6 +352,7 @@ module ClaudeInbox
350
352
 
351
353
  def activate
352
354
  return @expanded[@selected.key] = true if @selected&.fold?
355
+ return @modal = Dialog::Confirm.new(:adopt, @selected.key) if selected_session&.remote?
353
356
  attach(@selected.key) if require_actionable
354
357
  end
355
358
 
@@ -404,13 +407,22 @@ module ClaudeInbox
404
407
  @modal = Dialog::Prompt.new(:pr, @selected.key, current)
405
408
  end
406
409
 
407
- # Hands the first PR to the OS browser opener.
408
410
  def open_pr
409
411
  pr = selected_session&.pr
410
412
  return notice("no pull request linked — P sets one") unless pr
413
+ open_in_browser(pr.url, pr.short)
414
+ end
415
+
416
+ def open_remote
417
+ url = selected_session&.remote_url
418
+ return notice("no claude.ai/code page for this session") unless url
419
+ open_in_browser(url, "claude.ai/code")
420
+ end
421
+
422
+ def open_in_browser(url, what)
411
423
  opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
412
- notice("opening #{pr.short}")
413
- in_background { Subprocess.capture(opener, pr.url) }
424
+ notice("opening #{what}")
425
+ in_background { Subprocess.capture(opener, url) }
414
426
  end
415
427
 
416
428
  def open_new_session
@@ -434,11 +446,20 @@ module ClaudeInbox
434
446
  in_background do
435
447
  id = @client.spawn(**v)
436
448
  @queue << [:notice, "started #{id}"]
449
+ @queue << [:form_started, form]
437
450
  @queue << [:select, id]
438
451
  attach ? @queue << [:attach, id] : @poller.soon
452
+ rescue AgentsClient::Error => e
453
+ @queue << [:form_failed, form, e.message]
439
454
  end
440
455
  end
441
456
 
457
+ # A spawn failure hands the form back rather than just logging it, so
458
+ # the composed prompt survives (see NewSessionForm#submission_failed).
459
+ def form_failed(form, message)
460
+ @modal.equal?(form) ? form.submission_failed(message) : (@error = message)
461
+ end
462
+
442
463
  # The new-session form takes the whole body; a Dialog is a box over it.
443
464
  def screen_lines(width, height)
444
465
  return nil unless @modal.is_a?(NewSessionForm)
@@ -459,21 +480,24 @@ module ClaudeInbox
459
480
  when :confirm
460
481
  kind, id = @modal.kind, @modal.id
461
482
  @modal = nil
462
- (kind == :stop) ? stop_session(id) : delete_session(id)
483
+ case kind
484
+ when :stop then stop_session(id)
485
+ when :delete then delete_session(id)
486
+ when :adopt then adopt_session(id)
487
+ end
463
488
  when :save then save_prompt
464
489
  end
465
490
  end
466
491
 
492
+ # `:start`/`:start_and_attach` leave the form as `@modal`, now busy: it
493
+ # closes itself once the spawn is confirmed to have started (`:form_started`)
494
+ # or reopens with the failure and the prompt intact (`:form_failed`).
467
495
  def handle_form_key(name, key)
468
496
  form = @modal
469
497
  case form.press(name, key)
470
498
  when :cancel then @modal = nil
471
- when :start
472
- @modal = nil
473
- start_session(form, attach: false)
474
- when :start_and_attach
475
- @modal = nil
476
- start_session(form, attach: true)
499
+ when :start then start_session(form, attach: false)
500
+ when :start_and_attach then start_session(form, attach: true)
477
501
  end
478
502
  end
479
503
 
@@ -484,6 +508,18 @@ module ClaudeInbox
484
508
  end
485
509
  end
486
510
 
511
+ def adopt_session(key)
512
+ s = session_for(key)
513
+ return unless s&.remote?
514
+ notice("adopting #{s.display_name}…")
515
+ in_background do
516
+ id = @client.adopt(session_id: s.session_id, cwd: s.cwd, pid: s.pid)
517
+ @queue << [:notice, "adopted as #{id}"]
518
+ @queue << [:select, id]
519
+ @queue << [:attach, id]
520
+ end
521
+ end
522
+
487
523
  def delete_session(id)
488
524
  notice("deleting #{id}…")
489
525
  in_background do
@@ -49,10 +49,11 @@ module ClaudeInbox
49
49
  end
50
50
  end
51
51
 
52
- # `X` stop and `Ctrl-x` delete both ask first and both take `y`, but the
53
- # words differ because the outcomes do: read the box before answering.
52
+ # `X` stop, `Ctrl-x` delete and Enter on a remote row all ask first and
53
+ # all take `y`, but the words differ because the outcomes do: read the
54
+ # box before answering.
54
55
  class Confirm < Dialog
55
- TITLES = {stop: " Stop ", delete: " Delete "}.freeze
56
+ TITLES = {stop: " Stop ", delete: " Delete ", adopt: " Adopt "}.freeze
56
57
 
57
58
  def title = TITLES.fetch(kind)
58
59
 
@@ -62,6 +63,9 @@ module ClaudeInbox
62
63
  when :delete
63
64
  [" Delete session #{id}?", " Its worktree and conversation", " go with it.",
64
65
  "", " y delete it", " esc keep it"]
66
+ when :adopt
67
+ [" Pull this session into the daemon?", " Its phone session ends and a",
68
+ " new one takes its place.", "", " y adopt it", " esc cancel"]
65
69
  end
66
70
  end
67
71
 
@@ -13,8 +13,9 @@ module ClaudeInbox
13
13
  # each outstanding piece of work, and `detail` is the session's own status
14
14
  # line, with `needs` naming what it is waiting on while blocked and
15
15
  # `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
16
- # transcript, which PullRequests reads through here, and the color `/color`
17
- # set on the session, which `claude agents --json` drops.
16
+ # transcript, which PullRequests reads through here, the color `/color`
17
+ # set on the session, which `claude agents --json` drops, and `intent`, the
18
+ # prompt the session was started from.
18
19
  #
19
20
  # Never cached. A color changes the moment you type `/color`, so every poll
20
21
  # asks the file again.
@@ -49,7 +50,7 @@ module ClaudeInbox
49
50
  nil
50
51
  end
51
52
 
52
- attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :pr_urls, :color
53
+ attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :pr_urls, :color, :intent, :bridge_id, :flags
53
54
 
54
55
  def initialize(hash)
55
56
  @detail = hash["detail"]
@@ -60,8 +61,15 @@ module ClaudeInbox
60
61
  @tasks = (hash["inFlight"] || {})["tasks"].to_i
61
62
  @pr_urls = (hash["children"] || []).select { |c| c["kind"] == "pr" && c["href"] }.map { |c| c["href"] }
62
63
  @color = hash["color"]
64
+ @intent = hash["intent"]
65
+ @bridge_id = hash["bridgeSessionId"]
66
+ @flags = hash["respawnFlags"] || []
63
67
  end
64
68
 
69
+ # The flags the session was started with, which the daemon puts back on
70
+ # a wake, are the only record of Remote Control being on it.
71
+ def remote_control? = flags.include?("--remote-control")
72
+
65
73
  # The agent itself is not thinking. On its own this means little — a
66
74
  # session whose process died leaves the same reading behind — so it only
67
75
  # says something paired with work still in flight.
@@ -26,7 +26,7 @@ module ClaudeInbox
26
26
  "h" => :collapse,
27
27
  "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
28
28
  :ctrl_x => :delete,
29
- "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin,
29
+ "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
30
30
  "R" => :refresh, "p" => :toggle_peek, "n" => :new_session,
31
31
  :tab => :next_section, :back_tab => :prev_section,
32
32
  "/" => :filter, :escape => :escape,
@@ -18,8 +18,11 @@ module ClaudeInbox
18
18
  @pending = nil
19
19
  @pending_at = nil
20
20
  @requests = Queue.new
21
+ end
22
+
23
+ def start
24
+ return if @thread&.alive?
21
25
  @thread = Thread.new { worker }
22
- @thread.abort_on_exception = false
23
26
  end
24
27
 
25
28
  def cached(id)
@@ -42,7 +45,7 @@ module ClaudeInbox
42
45
  @requests << id unless fresh
43
46
  end
44
47
 
45
- def stop = @thread.kill
48
+ def stop = @thread&.kill
46
49
 
47
50
  private
48
51
 
@@ -35,7 +35,8 @@ module ClaudeInbox
35
35
  Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
36
36
  Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
37
37
  Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
38
- Field.new(:worktree, "Worktree", :choice, "no", %w[no yes])
38
+ Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
39
+ Field.new(:remote, "Remote Control", :choice, "no", %w[no yes])
39
40
  ]
40
41
  @focus = 0
41
42
  @error = nil
@@ -44,12 +45,19 @@ module ClaudeInbox
44
45
  @pick = 0
45
46
  @dismissed = nil
46
47
  @confirm_discard = false
48
+ @busy = false
47
49
  end
48
50
 
49
51
  def focused = @fields[@focus]
50
52
 
51
53
  # => :cancel | :start | :start_and_attach | :changed
52
54
  def press(name, raw)
55
+ # `:start`/`:start_and_attach` leave the form up until the spawn is
56
+ # known to have worked, so a failure (untrusted directory, `claude`
57
+ # missing, ...) can hand the composed prompt back instead of losing
58
+ # it; there is no way to cancel a spawn already under way, so every
59
+ # key is swallowed until the App reports back.
60
+ return :changed if @busy
53
61
  return confirm_discard_press(name, raw) if @confirm_discard
54
62
  @error = nil
55
63
  @candidates = nil
@@ -107,8 +115,9 @@ module ClaudeInbox
107
115
 
108
116
  def values
109
117
  @fields.to_h { |f| [f.key, f.value.to_s] }.tap do |v|
110
- v[:prompt] = @fields[0].value.expand { |chip| AgentsClient.mention(chip.path) }.strip
118
+ v[:prompt] = field(:prompt).value.expand { |chip| AgentsClient.mention(chip.path) }.strip
111
119
  v[:worktree] = v[:worktree] == "yes"
120
+ v[:remote] = v[:remote] == "yes"
112
121
  v[:name] = nil if v[:name].strip.empty?
113
122
  v[:cwd] = File.expand_path(v[:cwd].strip.empty? ? "." : v[:cwd].strip)
114
123
  end
@@ -140,27 +149,23 @@ module ClaudeInbox
140
149
  menu_rows = menu_lines(inner_w, [height - fixed - 3, MENU_ROWS].min)
141
150
  prompt_h = [height - fixed - menu_rows.size, 3].max
142
151
  out = [""]
143
- out << " " + (@confirm_discard ? @theme.red("Discard this session? (y/n)") : @p.bold("New session"))
152
+ title = @busy ? @p.dim("Starting session…") : @p.bold("New session")
153
+ out << " " + (@confirm_discard ? @theme.red("Discard this session? (y/n)") : title)
144
154
  out << ""
145
- out << " " + field_label(@fields[0]) + @p.dim(" ⏎ newline")
146
- out += prompt_box(@fields[0], inner_w, prompt_h)
155
+ out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
156
+ out += prompt_box(field(:prompt), inner_w, prompt_h)
147
157
  out += menu_rows
148
158
  out << ""
149
- @fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 14) }
159
+ @fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 16) }
150
160
  out.first(height) + [""] * [height - out.size, 0].max
151
161
  end
152
162
 
153
163
  def footer
154
- if @confirm_discard
155
- return [["y", "discard"], ["esc", "keep editing"]]
156
- .map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
157
- end
164
+ return @p.dim("starting session…") if @busy
165
+ return hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
158
166
  return @theme.red(@error) if @error
159
167
  return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
160
- if menu
161
- return [["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]
162
- .map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
163
- end
168
+ return hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
164
169
  keys =
165
170
  case focused.kind
166
171
  when :multiline then [["⏎", "newline"], ["^V", "image"]]
@@ -169,14 +174,24 @@ module ClaudeInbox
169
174
  end
170
175
  keys += [["^S", "start"], ["^O", "start & open"],
171
176
  ["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
172
- keys.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
177
+ hints(keys)
178
+ end
179
+
180
+ # The App's `claude --bg` call failed after `:start`/`:start_and_attach`
181
+ # already handed the form off — an untrusted directory, a stale binary,
182
+ # a permissions error. The composed prompt is still here, so the form
183
+ # reopens on it with the reason shown instead of the App just logging
184
+ # the failure and losing the draft.
185
+ def submission_failed(message)
186
+ @busy = false
187
+ @error = message
173
188
  end
174
189
 
175
190
  private
176
191
 
177
192
  # Esc with a prompt typed asks first, so a stray keypress can't lose it.
178
193
  def escape_pressed
179
- return :cancel if @fields[0].value.empty?
194
+ return :cancel if field(:prompt).value.empty?
180
195
  @confirm_discard = true
181
196
  :changed
182
197
  end
@@ -242,9 +257,11 @@ module ClaudeInbox
242
257
  rows
243
258
  end
244
259
 
260
+ def hints(pairs) = pairs.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
261
+
245
262
  def field_label(f)
246
263
  on = f.equal?(focused)
247
- (on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 12)) : @p.dim(Text.pad(f.label, 12)))
264
+ (on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
248
265
  end
249
266
 
250
267
  # The cell the cursor sits on, drawn as a block by inverting it: a bar
@@ -325,6 +342,10 @@ module ClaudeInbox
325
342
 
326
343
  def move(d) = @focus = (@focus + d) % @fields.size
327
344
 
345
+ def field(key) = @fields.find { |f| f.key == key }
346
+
347
+ def focus_on(key) = @focus = @fields.index { |f| f.key == key }
348
+
328
349
  # Keys a :choice field answers to; an editable field spends these on its
329
350
  # own text instead.
330
351
  def choose(name, raw)
@@ -349,14 +370,15 @@ module ClaudeInbox
349
370
  v = values
350
371
  if v[:prompt].empty?
351
372
  @error = "a prompt is required"
352
- @focus = 0
373
+ focus_on(:prompt)
353
374
  return :changed
354
375
  end
355
376
  unless File.directory?(v[:cwd])
356
377
  @error = "no such directory: #{v[:cwd]}"
357
- @focus = 2
378
+ focus_on(:cwd)
358
379
  return :changed
359
380
  end
381
+ @busy = true
360
382
  attach ? :start_and_attach : :start
361
383
  end
362
384
  end
@@ -19,8 +19,6 @@ module ClaudeInbox
19
19
 
20
20
  RESET = "\e[39m"
21
21
 
22
- def self.known?(name) = ANSI.key?(name) || INDEXED.key?(name)
23
-
24
22
  def self.sequence(name)
25
23
  return "\e[#{ANSI[name]}m" if ANSI.key?(name)
26
24
  return "\e[38;5;#{INDEXED[name]}m" if INDEXED.key?(name)
@@ -7,7 +7,7 @@ module ClaudeInbox
7
7
  # lands on a warm cache. Main-thread state only.
8
8
  class Peek
9
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. Open it in the web or mobile app instead."
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
11
 
12
12
  # What Renderer paints: the body lines, the title bar, the dim line under it.
13
13
  View = Struct.new(:lines, :title, :subtitle)
@@ -58,7 +58,7 @@ module ClaudeInbox
58
58
  end
59
59
 
60
60
  def interactive_note(s)
61
- [s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}"]
61
+ [s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}", s.remote_url].compact
62
62
  end
63
63
 
64
64
  def subtitle(row)