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.
- checksums.yaml +4 -4
- data/README.md +149 -358
- data/exe/claude-inbox +0 -1
- data/lib/claude_inbox/agents_client.rb +72 -14
- data/lib/claude_inbox/app.rb +76 -40
- data/lib/claude_inbox/dialog.rb +7 -3
- data/lib/claude_inbox/job_state.rb +11 -3
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/logs.rb +5 -2
- data/lib/claude_inbox/new_session_form.rb +41 -19
- data/lib/claude_inbox/palette.rb +0 -2
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +5 -11
- data/lib/claude_inbox/rate_limits.rb +19 -7
- data/lib/claude_inbox/reaper.rb +13 -16
- data/lib/claude_inbox/renderer.rb +91 -69
- data/lib/claude_inbox/session.rb +17 -3
- data/lib/claude_inbox/sessions.rb +2 -2
- data/lib/claude_inbox/slash_commands.rb +6 -6
- data/lib/claude_inbox/store/row.rb +3 -3
- data/lib/claude_inbox/store.rb +11 -2
- data/lib/claude_inbox/subprocess.rb +3 -2
- data/lib/claude_inbox/text.rb +6 -10
- data/lib/claude_inbox/text_buffer.rb +3 -2
- data/lib/claude_inbox/theme.rb +5 -4
- data/lib/claude_inbox/vt_screen.rb +0 -2
- data/lib/claude_inbox.rb +1 -1
- metadata +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
# parent is the row worth
|
|
108
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -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,
|
|
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 =
|
|
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,
|
|
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
|
|
62
|
-
@peek = Peek.new(@logs)
|
|
63
|
+
@logs.start
|
|
63
64
|
main_loop
|
|
64
65
|
ensure
|
|
65
66
|
@poller.stop
|
|
66
|
-
@logs
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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 —
|
|
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 #{
|
|
413
|
-
in_background { Subprocess.capture(opener,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -49,10 +49,11 @@ module ClaudeInbox
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
# `X` stop
|
|
53
|
-
# words differ because the outcomes do: read the
|
|
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,
|
|
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.
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -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,
|
data/lib/claude_inbox/logs.rb
CHANGED
|
@@ -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
|
|
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] =
|
|
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
|
-
|
|
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(
|
|
146
|
-
out += prompt_box(
|
|
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 -
|
|
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 @
|
|
155
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/claude_inbox/palette.rb
CHANGED
data/lib/claude_inbox/peek.rb
CHANGED
|
@@ -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.
|
|
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)
|