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.
- checksums.yaml +4 -4
- data/README.md +1 -3
- data/lib/claude_inbox/agents_client.rb +1 -11
- data/lib/claude_inbox/app/attaching.rb +77 -0
- data/lib/claude_inbox/app/browsing.rb +29 -0
- data/lib/claude_inbox/app/filtering.rb +37 -0
- data/lib/claude_inbox/app/navigating.rb +91 -0
- data/lib/claude_inbox/app/spawning.rb +62 -0
- data/lib/claude_inbox/app/triaging.rb +76 -0
- data/lib/claude_inbox/app/updating.rb +56 -0
- data/lib/claude_inbox/app.rb +93 -470
- data/lib/claude_inbox/cli.rb +1 -1
- data/lib/claude_inbox/input.rb +65 -0
- data/lib/claude_inbox/keymap.rb +2 -4
- data/lib/claude_inbox/mod.rb +8 -5
- data/lib/claude_inbox/mouse.rb +1 -1
- data/lib/claude_inbox/pull_requests.rb +2 -3
- data/lib/claude_inbox/renderer.rb +12 -39
- data/lib/claude_inbox/slash_commands.rb +6 -7
- data/lib/claude_inbox/store/sections.rb +2 -5
- data/lib/claude_inbox/store.rb +1 -1
- data/lib/claude_inbox/vt_screen.rb +4 -6
- data/lib/claude_inbox.rb +1 -1
- metadata +9 -3
- data/lib/claude_inbox/logs.rb +0 -68
- data/lib/claude_inbox/peek.rb +0 -77
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -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
|
-
|
|
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 @
|
|
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
|
|
138
|
+
when :select then select_later(rest[0])
|
|
137
139
|
when :attach then attach(rest[0])
|
|
138
|
-
when :form_started then
|
|
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(@
|
|
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
|
-
@
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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) :
|
|
179
|
-
text.
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
183
|
+
return if @modal || filter_editing?
|
|
345
184
|
case event.kind
|
|
346
|
-
when :click then click_row(event.row
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
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
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
|
578
|
-
return
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
673
|
-
|
|
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
|