claude-inbox 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +442 -0
- data/exe/claude-inbox +33 -0
- data/lib/claude_inbox/agents_client.rb +224 -0
- data/lib/claude_inbox/app.rb +533 -0
- data/lib/claude_inbox/debug.rb +16 -0
- data/lib/claude_inbox/dialog.rb +105 -0
- data/lib/claude_inbox/images.rb +58 -0
- data/lib/claude_inbox/job_state.rb +89 -0
- data/lib/claude_inbox/keymap.rb +71 -0
- data/lib/claude_inbox/logs.rb +66 -0
- data/lib/claude_inbox/mouse.rb +34 -0
- data/lib/claude_inbox/new_session_form.rb +363 -0
- data/lib/claude_inbox/palette.rb +40 -0
- data/lib/claude_inbox/paste.rb +42 -0
- data/lib/claude_inbox/peek.rb +81 -0
- data/lib/claude_inbox/poller.rb +98 -0
- data/lib/claude_inbox/pull_requests.rb +155 -0
- data/lib/claude_inbox/rate_limits.rb +48 -0
- data/lib/claude_inbox/reaper.rb +103 -0
- data/lib/claude_inbox/records.rb +28 -0
- data/lib/claude_inbox/renderer.rb +447 -0
- data/lib/claude_inbox/session.rb +100 -0
- data/lib/claude_inbox/sessions.rb +18 -0
- data/lib/claude_inbox/settings.rb +39 -0
- data/lib/claude_inbox/slash_commands.rb +116 -0
- data/lib/claude_inbox/store/entry.rb +144 -0
- data/lib/claude_inbox/store/row.rb +101 -0
- data/lib/claude_inbox/store/sections.rb +47 -0
- data/lib/claude_inbox/store/selection.rb +16 -0
- data/lib/claude_inbox/store.rb +156 -0
- data/lib/claude_inbox/subprocess.rb +53 -0
- data/lib/claude_inbox/terminal.rb +101 -0
- data/lib/claude_inbox/text.rb +98 -0
- data/lib/claude_inbox/text_buffer.rb +216 -0
- data/lib/claude_inbox/theme.rb +35 -0
- data/lib/claude_inbox/vt_screen.rb +138 -0
- data/lib/claude_inbox.rb +14 -0
- metadata +163 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-reader"
|
|
4
|
+
require_relative "agents_client"
|
|
5
|
+
require_relative "debug"
|
|
6
|
+
require_relative "dialog"
|
|
7
|
+
require_relative "text_buffer"
|
|
8
|
+
require_relative "store"
|
|
9
|
+
require_relative "renderer"
|
|
10
|
+
require_relative "terminal"
|
|
11
|
+
require_relative "logs"
|
|
12
|
+
require_relative "peek"
|
|
13
|
+
require_relative "keymap"
|
|
14
|
+
require_relative "mouse"
|
|
15
|
+
require_relative "new_session_form"
|
|
16
|
+
require_relative "paste"
|
|
17
|
+
require_relative "pull_requests"
|
|
18
|
+
require_relative "poller"
|
|
19
|
+
require_relative "rate_limits"
|
|
20
|
+
|
|
21
|
+
module ClaudeInbox
|
|
22
|
+
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
23
|
+
# child process that takes over the terminal.
|
|
24
|
+
class App
|
|
25
|
+
# The reaper defaults to off. It is the only thing here that deletes a
|
|
26
|
+
# session, so switching it on is `bin/claude-inbox`'s job and nothing
|
|
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)
|
|
30
|
+
@client = client
|
|
31
|
+
@store = store
|
|
32
|
+
@rate_limits = rate_limits
|
|
33
|
+
@terminal = Terminal.new(out, input)
|
|
34
|
+
@color = color
|
|
35
|
+
@renderer = Renderer.new(color: color)
|
|
36
|
+
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
37
|
+
@queue = Queue.new
|
|
38
|
+
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests, jobs_dir: jobs_dir,
|
|
39
|
+
reaper: reaper, queue: @queue)
|
|
40
|
+
@selected = nil
|
|
41
|
+
@row_items = []
|
|
42
|
+
@list_width = nil
|
|
43
|
+
@top = 0
|
|
44
|
+
@expanded = Hash.new(false)
|
|
45
|
+
@keymap = Keymap.new
|
|
46
|
+
@paste = Paste.new
|
|
47
|
+
@tick = 0
|
|
48
|
+
@modal = nil
|
|
49
|
+
@filter = nil
|
|
50
|
+
@status = "starting…"
|
|
51
|
+
@last_poll = nil
|
|
52
|
+
@quit = false
|
|
53
|
+
@resize = false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run
|
|
57
|
+
@booted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
58
|
+
install_traps
|
|
59
|
+
@terminal.enter
|
|
60
|
+
@poller.start
|
|
61
|
+
@logs = Logs.new(@client)
|
|
62
|
+
@peek = Peek.new(@logs)
|
|
63
|
+
main_loop
|
|
64
|
+
ensure
|
|
65
|
+
@poller.stop
|
|
66
|
+
@logs&.stop
|
|
67
|
+
@terminal.restore
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def install_traps
|
|
73
|
+
at_exit { @terminal.restore }
|
|
74
|
+
%w[INT TERM].each { |sig| trap(sig) { @quit = true } }
|
|
75
|
+
trap("WINCH") { @resize = true } if Signal.list.key?("WINCH")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ----- threads ----------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
# Runs a block off the main thread; a failure lands in the status line
|
|
81
|
+
# rather than killing the thread silently.
|
|
82
|
+
def in_background
|
|
83
|
+
Thread.new do
|
|
84
|
+
yield
|
|
85
|
+
rescue => e
|
|
86
|
+
@queue << [:error, e.message]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def drain_queue
|
|
91
|
+
until @queue.empty?
|
|
92
|
+
kind, *rest = @queue.pop(true)
|
|
93
|
+
case kind
|
|
94
|
+
when :sessions
|
|
95
|
+
@store.update(rest[0])
|
|
96
|
+
@last_poll = Time.now
|
|
97
|
+
@error = nil
|
|
98
|
+
when :error then @error = rest[0]
|
|
99
|
+
when :notice then notice(rest[0])
|
|
100
|
+
when :select then @pending_select = rest[0]
|
|
101
|
+
when :attach then attach(rest[0])
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
rescue ThreadError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ----- main loop --------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
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
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle_input(raw)
|
|
125
|
+
@paste.feed(raw).each do |kind, text|
|
|
126
|
+
next handle_paste(text) if kind == :paste
|
|
127
|
+
events = Mouse.events(text)
|
|
128
|
+
next events.each { |e| handle_mouse(e) } if events.any?
|
|
129
|
+
split_keys(text).each { |k| handle_key(k) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The form takes a paste whole, images included; the one-line editors
|
|
134
|
+
# take it as typing, so a pasted PR URL lands where it should.
|
|
135
|
+
def handle_paste(text)
|
|
136
|
+
return @modal.paste(text) if @modal.is_a?(NewSessionForm)
|
|
137
|
+
text.each_char { |c| handle_key(c) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def render
|
|
141
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
142
|
+
now = Time.now
|
|
143
|
+
sections = filtered(@store.sections(now))
|
|
144
|
+
width, height = @terminal.size
|
|
145
|
+
ensure_selection(sections)
|
|
146
|
+
peek = @peek.view(sections.row(@selected), height)
|
|
147
|
+
@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
|
|
155
|
+
)
|
|
156
|
+
@items = frame.items.compact
|
|
157
|
+
@row_items = frame.items
|
|
158
|
+
@list_width = frame.list_width
|
|
159
|
+
@top = frame.top
|
|
160
|
+
@terminal.paint(frame.lines)
|
|
161
|
+
dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
162
|
+
Debug.log("render #{(dt * 1000).round}ms") if dt > 0.05
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Seconds spent waiting for the first poll; nil once one has landed, or
|
|
166
|
+
# failed — a failure has its own line in the header and the empty state
|
|
167
|
+
# already says how to retry.
|
|
168
|
+
def loading_for
|
|
169
|
+
return nil if @last_poll || @error || !@booted_at
|
|
170
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - @booted_at
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def status_text(now)
|
|
174
|
+
return @notice[0] if @notice && now < @notice[1]
|
|
175
|
+
return "⚠ #{@error}" if @error
|
|
176
|
+
"polling…" unless @last_poll
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def filtered(sections = @store.sections) = sections.matching(@filter&.to_s)
|
|
180
|
+
|
|
181
|
+
def ensure_selection(sections)
|
|
182
|
+
stops = sections.selections(@expanded)
|
|
183
|
+
pending = @pending_select && Store::Selection.row(@pending_select)
|
|
184
|
+
if pending && stops.include?(pending)
|
|
185
|
+
select(pending)
|
|
186
|
+
@pending_select = nil
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
return if stops.include?(@selected)
|
|
190
|
+
select(stops.first)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def select(selection)
|
|
194
|
+
@selected = selection
|
|
195
|
+
@peek.select(selection, selected_session)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def session_for(key) = @store.sessions.find { |s| s.key == key }
|
|
199
|
+
|
|
200
|
+
def selected_session = session_for(@selected&.key)
|
|
201
|
+
|
|
202
|
+
# Guard for attach/stop: refuse politely on a terminal or remote row.
|
|
203
|
+
def require_actionable
|
|
204
|
+
return true if selected_session&.actionable?
|
|
205
|
+
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
|
+
end
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Guard for snooze/wake/alias: anything with a key, since those live in
|
|
212
|
+
# our own store. A terminal you are sitting in is the one exception.
|
|
213
|
+
def require_storable
|
|
214
|
+
return true if @selected&.row? && !selected_session&.terminal?
|
|
215
|
+
notice("you're in that terminal right now — nothing to snooze") if selected_session&.terminal?
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def notice(msg)
|
|
220
|
+
@notice = [msg, Time.now + 4]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# ----- keys -------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def key_name(key)
|
|
226
|
+
@reader.console.keys[key] || key
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# tty-reader glues ESC to whatever arrives within 100ms, so a fast
|
|
230
|
+
# "esc gg" comes in as one unknown key "\egg". Vim hands make that
|
|
231
|
+
# constantly. Unknown ESC-prefixed strings become ESC + the rest.
|
|
232
|
+
def split_keys(key)
|
|
233
|
+
return [key] if key.size <= 1 || @reader.console.keys.key?(key)
|
|
234
|
+
return [key] unless key.start_with?("\e")
|
|
235
|
+
["\e"] + key[1..].chars
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def handle_key(key)
|
|
239
|
+
name = key_name(key)
|
|
240
|
+
return handle_modal_key(name, key) if @modal
|
|
241
|
+
return handle_line_key(name, key) if @filter_editing
|
|
242
|
+
|
|
243
|
+
action = @keymap.press(name, key)
|
|
244
|
+
perform(action) if action
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A modal or an open filter line already claims every keypress ahead of
|
|
248
|
+
# the normal action table (see handle_key); mouse input defers to the
|
|
249
|
+
# same rule rather than reaching past whatever has focus.
|
|
250
|
+
def handle_mouse(event)
|
|
251
|
+
return if @modal || @filter_editing
|
|
252
|
+
case event.kind
|
|
253
|
+
when :click then click_row(event.row, event.col)
|
|
254
|
+
when :scroll_up then perform(:up)
|
|
255
|
+
when :scroll_down then perform(:down)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Clicking a row selects it and attaches, same as landing on it with
|
|
260
|
+
# j/k and pressing Enter — activate already knows how to expand a fold
|
|
261
|
+
# or refuse a terminal/remote row, so this doesn't repeat that.
|
|
262
|
+
def click_row(row, col)
|
|
263
|
+
return if @list_width && col > @list_width
|
|
264
|
+
item = row_item_at(row)
|
|
265
|
+
return unless item
|
|
266
|
+
select(item.selection)
|
|
267
|
+
activate
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The wrapped detail line under a two-line row ("↳ ~/code/x") carries
|
|
271
|
+
# no item of its own; a click there resolves to the row above it.
|
|
272
|
+
def row_item_at(row)
|
|
273
|
+
idx = row - 1
|
|
274
|
+
@row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.kind == :row)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def perform(action)
|
|
278
|
+
case action
|
|
279
|
+
when :quit then @quit = true
|
|
280
|
+
when :up then move(-1)
|
|
281
|
+
when :down then move(1)
|
|
282
|
+
when :top then move(-1_000_000)
|
|
283
|
+
when :bottom then move(1_000_000)
|
|
284
|
+
when :half_page_down then move(page / 2)
|
|
285
|
+
when :half_page_up then move(-(page / 2))
|
|
286
|
+
when :page_down then move(page)
|
|
287
|
+
when :page_up then move(-page)
|
|
288
|
+
when :next_section then jump_section(1)
|
|
289
|
+
when :prev_section then jump_section(-1)
|
|
290
|
+
when :peek_down then @peek.scroll(-1)
|
|
291
|
+
when :peek_up then @peek.scroll(1)
|
|
292
|
+
when :activate then activate
|
|
293
|
+
when :collapse then collapse
|
|
294
|
+
when :fold_open then set_expanded(true)
|
|
295
|
+
when :fold_close then set_expanded(false)
|
|
296
|
+
when :fold_toggle then set_expanded(!@expanded[current_fold_section])
|
|
297
|
+
when :snooze then open_snooze_menu
|
|
298
|
+
when :wake then wake_selected
|
|
299
|
+
when :toggle_pin then toggle_pin_selected
|
|
300
|
+
when :settle then settle_selected
|
|
301
|
+
when :alias then open_alias_editor
|
|
302
|
+
when :link_pr then open_pr_editor
|
|
303
|
+
when :open_pr then open_pr
|
|
304
|
+
when :stop then open_confirm(:stop)
|
|
305
|
+
when :delete then open_confirm(:delete)
|
|
306
|
+
when :refresh then @poller.soon
|
|
307
|
+
when :toggle_peek then toggle_peek
|
|
308
|
+
when :new_session then open_new_session
|
|
309
|
+
when :filter then start_filter
|
|
310
|
+
when :escape then clear_filter
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def page = [@terminal.size[1] - 2, 1].max
|
|
315
|
+
|
|
316
|
+
def move(delta)
|
|
317
|
+
return if @items.nil? || @items.empty?
|
|
318
|
+
stops = filtered.selections(@expanded)
|
|
319
|
+
idx = stops.index(@selected) || 0
|
|
320
|
+
select(stops[(idx + delta).clamp(0, stops.size - 1)])
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Tab / Shift-Tab: first selectable row of the next / previous section.
|
|
324
|
+
def jump_section(dir)
|
|
325
|
+
sections = filtered
|
|
326
|
+
heads = sections.heads(@expanded)
|
|
327
|
+
return if heads.empty?
|
|
328
|
+
current = sections.section_of(@selected)
|
|
329
|
+
idx = heads.index { |name, _| name == current } || -1
|
|
330
|
+
select(heads[(idx + dir) % heads.size].last)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The foldable section the cursor is currently on or inside, if any.
|
|
334
|
+
def current_fold_section
|
|
335
|
+
name = filtered.section_of(@selected)
|
|
336
|
+
name if Store::FOLDABLE_SECTIONS.include?(name)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def set_expanded(value, name: current_fold_section)
|
|
340
|
+
@expanded[name] = value if name
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# vim-ish "h": close whatever is open, innermost first.
|
|
344
|
+
def collapse
|
|
345
|
+
if @peek.open? then @peek.close
|
|
346
|
+
elsif (name = current_fold_section) && @expanded[name] then @expanded[name] = false
|
|
347
|
+
end
|
|
348
|
+
@terminal.invalidate
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def activate
|
|
352
|
+
return @expanded[@selected.key] = true if @selected&.fold?
|
|
353
|
+
attach(@selected.key) if require_actionable
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def toggle_peek
|
|
357
|
+
@peek.toggle
|
|
358
|
+
@terminal.invalidate
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def wake_selected
|
|
362
|
+
@store.wake(@selected.key) if require_storable
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def toggle_pin_selected
|
|
366
|
+
@store.toggle_pin(@selected.key) if require_storable
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def settle_selected
|
|
370
|
+
return unless require_storable
|
|
371
|
+
@store.settle(@selected.key)
|
|
372
|
+
notice("settled — u brings it back")
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def attach(id)
|
|
376
|
+
@store.acknowledge(id)
|
|
377
|
+
@poller.pause
|
|
378
|
+
@terminal.release { @client.attach(id) }
|
|
379
|
+
ensure
|
|
380
|
+
@poller.resume
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# ----- modals -----------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def open_snooze_menu
|
|
386
|
+
return unless require_storable
|
|
387
|
+
@modal = Dialog::Snooze.new(@selected.key)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def open_confirm(kind)
|
|
391
|
+
return unless require_actionable
|
|
392
|
+
@modal = Dialog::Confirm.new(kind, @selected.key)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def open_alias_editor
|
|
396
|
+
return unless require_storable
|
|
397
|
+
current = @store.alias_for(@selected.key) || ""
|
|
398
|
+
@modal = Dialog::Prompt.new(:alias, @selected.key, current)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def open_pr_editor
|
|
402
|
+
return unless require_storable
|
|
403
|
+
current = @store.pr_for(@selected.key) || selected_session&.pr&.url || ""
|
|
404
|
+
@modal = Dialog::Prompt.new(:pr, @selected.key, current)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Hands the first PR to the OS browser opener.
|
|
408
|
+
def open_pr
|
|
409
|
+
pr = selected_session&.pr
|
|
410
|
+
return notice("no pull request linked — P sets one") unless pr
|
|
411
|
+
opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
|
|
412
|
+
notice("opening #{pr.short}")
|
|
413
|
+
in_background { Subprocess.capture(opener, pr.url) }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def open_new_session
|
|
417
|
+
cwd = selected_session&.cwd || Dir.pwd
|
|
418
|
+
@modal = NewSessionForm.new(cwd: strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# A session's cwd may sit inside a worktree another agent is using; carrying
|
|
422
|
+
# that into a new prompt would spawn the new agent there too, writing over
|
|
423
|
+
# the same files. Fall back to the repo the worktree was cut from.
|
|
424
|
+
def strip_worktree(cwd)
|
|
425
|
+
cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# `attach:` hands the terminal over as soon as the session starts. Without
|
|
429
|
+
# it we stay in the inbox and poll, so the new row shows up right away
|
|
430
|
+
# rather than at the next tick.
|
|
431
|
+
def start_session(form, attach:)
|
|
432
|
+
v = form.values
|
|
433
|
+
notice("starting session…")
|
|
434
|
+
in_background do
|
|
435
|
+
id = @client.spawn(**v)
|
|
436
|
+
@queue << [:notice, "started #{id}"]
|
|
437
|
+
@queue << [:select, id]
|
|
438
|
+
attach ? @queue << [:attach, id] : @poller.soon
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# The new-session form takes the whole body; a Dialog is a box over it.
|
|
443
|
+
def screen_lines(width, height)
|
|
444
|
+
return nil unless @modal.is_a?(NewSessionForm)
|
|
445
|
+
{lines: @modal.screen(width, height - 2), footer: @modal.footer}
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def modal_lines(width)
|
|
449
|
+
@modal.frame(width, @renderer.caret) if @modal.is_a?(Dialog)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def handle_modal_key(name, key)
|
|
453
|
+
return handle_form_key(name, key) if @modal.is_a?(NewSessionForm)
|
|
454
|
+
case @modal.press(name, key)
|
|
455
|
+
when :cancel then @modal = nil
|
|
456
|
+
when :snooze
|
|
457
|
+
@store.snooze(@modal.id, @modal.choice)
|
|
458
|
+
@modal = nil
|
|
459
|
+
when :confirm
|
|
460
|
+
kind, id = @modal.kind, @modal.id
|
|
461
|
+
@modal = nil
|
|
462
|
+
(kind == :stop) ? stop_session(id) : delete_session(id)
|
|
463
|
+
when :save then save_prompt
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def handle_form_key(name, key)
|
|
468
|
+
form = @modal
|
|
469
|
+
case form.press(name, key)
|
|
470
|
+
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)
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def stop_session(id)
|
|
481
|
+
in_background do
|
|
482
|
+
@client.stop(id)
|
|
483
|
+
@poller.soon
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def delete_session(id)
|
|
488
|
+
notice("deleting #{id}…")
|
|
489
|
+
in_background do
|
|
490
|
+
@client.rm(id)
|
|
491
|
+
@store.forget(id)
|
|
492
|
+
@queue << [:notice, "deleted #{id}"]
|
|
493
|
+
@poller.soon
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def save_prompt
|
|
498
|
+
value = @modal.value.strip
|
|
499
|
+
if @modal.kind == :alias
|
|
500
|
+
@store.set_alias(@modal.id, value)
|
|
501
|
+
elsif value.empty? || PullRequests.valid_url?(value)
|
|
502
|
+
@store.set_pr(@modal.id, value)
|
|
503
|
+
@poller.soon
|
|
504
|
+
else
|
|
505
|
+
return notice("that's not a github.com pull request url")
|
|
506
|
+
end
|
|
507
|
+
@modal = nil
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# ----- filter line --------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def start_filter
|
|
513
|
+
@filter ||= TextBuffer.new
|
|
514
|
+
@filter_editing = true
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def clear_filter
|
|
518
|
+
@filter = nil
|
|
519
|
+
@filter_editing = false
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def handle_line_key(name, key)
|
|
523
|
+
case name
|
|
524
|
+
when :escape then clear_filter
|
|
525
|
+
when :return, :enter then @filter_editing = false
|
|
526
|
+
when :backspace, :ctrl_h
|
|
527
|
+
@filter.empty? ? clear_filter : @filter.press(name, key)
|
|
528
|
+
else
|
|
529
|
+
@filter.press(name, key)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeInbox
|
|
4
|
+
# DEBUG=1 appends notes to LOG: slow frames, the attach
|
|
5
|
+
# watchdog. Off, it costs one env lookup.
|
|
6
|
+
module Debug
|
|
7
|
+
LOG = "/tmp/inbox-debug.log"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def log(msg)
|
|
12
|
+
return unless ENV["DEBUG"]
|
|
13
|
+
File.write(LOG, "#{Time.now.strftime("%H:%M:%S.%L")} #{msg}\n", mode: "a")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-box"
|
|
4
|
+
require_relative "text_buffer"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# A small box over the list that claims every key until it answers. Like
|
|
8
|
+
# NewSessionForm, `press(name, raw)` says what happened: nil while the box
|
|
9
|
+
# stays up, :cancel when it is dismissed, or the answer App acts on, with
|
|
10
|
+
# the details read off the dialog afterwards. `frame(width)` is the box
|
|
11
|
+
# Renderer overlays. Pure: nothing here touches the store or the client.
|
|
12
|
+
class Dialog
|
|
13
|
+
attr_reader :kind, :id
|
|
14
|
+
|
|
15
|
+
def initialize(kind, id)
|
|
16
|
+
@kind = kind
|
|
17
|
+
@id = id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def frame(width, caret = nil)
|
|
21
|
+
box = [width - 4, 44].min
|
|
22
|
+
TTY::Box.frame(lines(box - 4, caret).join("\n"), title: {top_left: title}, padding: [0, 1], width: box)
|
|
23
|
+
.split("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `s` snooze: one key per choice, and the choice is read from `choice`.
|
|
27
|
+
class Snooze < Dialog
|
|
28
|
+
MENU = [
|
|
29
|
+
["1", "15 minutes", :m15],
|
|
30
|
+
["2", "1 hour", :h1],
|
|
31
|
+
["3", "tomorrow 9am", :tomorrow_9am],
|
|
32
|
+
["4", "until I wake it", :until_woken]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :choice
|
|
36
|
+
|
|
37
|
+
def initialize(id) = super(:snooze, id)
|
|
38
|
+
|
|
39
|
+
def title = " Snooze "
|
|
40
|
+
|
|
41
|
+
def lines(_width, _caret) = MENU.map { |k, label, _| " #{k} #{label}" } + ["", " esc cancel"]
|
|
42
|
+
|
|
43
|
+
def press(name, key)
|
|
44
|
+
return :cancel if name == :escape || key == "q"
|
|
45
|
+
entry = MENU.find { |k, _, _| k == key }
|
|
46
|
+
return nil unless entry
|
|
47
|
+
@choice = entry[2]
|
|
48
|
+
:snooze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
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.
|
|
54
|
+
class Confirm < Dialog
|
|
55
|
+
TITLES = {stop: " Stop ", delete: " Delete "}.freeze
|
|
56
|
+
|
|
57
|
+
def title = TITLES.fetch(kind)
|
|
58
|
+
|
|
59
|
+
def lines(_width, _caret)
|
|
60
|
+
case kind
|
|
61
|
+
when :stop then [" Stop session #{id}?", "", " y stop it", " esc cancel"]
|
|
62
|
+
when :delete
|
|
63
|
+
[" Delete session #{id}?", " Its worktree and conversation", " go with it.",
|
|
64
|
+
"", " y delete it", " esc keep it"]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def press(name, key)
|
|
69
|
+
return :confirm if key == "y"
|
|
70
|
+
return :cancel if name == :escape || key == "n" || key == "q"
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `a` alias and `P` pull request: one line of text, read from `value`
|
|
76
|
+
# once :save comes back. Empty means clear.
|
|
77
|
+
class Prompt < Dialog
|
|
78
|
+
TITLES = {alias: " Alias ", pr: " Pull request "}.freeze
|
|
79
|
+
QUESTIONS = {alias: " New alias:", pr: " Pull request URL (empty clears):"}.freeze
|
|
80
|
+
|
|
81
|
+
def initialize(kind, id, value)
|
|
82
|
+
super(kind, id)
|
|
83
|
+
@buffer = TextBuffer.new(value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def value = @buffer.to_s
|
|
87
|
+
|
|
88
|
+
def title = TITLES.fetch(kind)
|
|
89
|
+
|
|
90
|
+
def lines(width, caret)
|
|
91
|
+
[QUESTIONS.fetch(kind), "", " > " + @buffer.row(width - 4, cursor: caret), "", " ⏎ save · esc cancel"]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def press(name, key)
|
|
95
|
+
case name
|
|
96
|
+
when :escape then :cancel
|
|
97
|
+
when :return, :enter then :save
|
|
98
|
+
else
|
|
99
|
+
@buffer.press(name, key)
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "subprocess"
|
|
5
|
+
|
|
6
|
+
module ClaudeInbox
|
|
7
|
+
# Images a prompt can carry: a dropped file, or whatever is on the
|
|
8
|
+
# clipboard. Both come back as a path for a TextBuffer chip to hold.
|
|
9
|
+
module Images
|
|
10
|
+
DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "images")
|
|
11
|
+
EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .svg].freeze
|
|
12
|
+
# Matches Store::REAP_AFTER: an image is useless once the session that carried it is reaped.
|
|
13
|
+
KEEP_FOR = 14 * 24 * 3600
|
|
14
|
+
|
|
15
|
+
Clipboard = Struct.new(:image, :text)
|
|
16
|
+
|
|
17
|
+
# Saved images are pruned here, on the way in, because nothing else
|
|
18
|
+
# knows when a session stopped needing its image. macOS only: osascript
|
|
19
|
+
# ships with the OS, and PNG is the flavor a screenshot puts there.
|
|
20
|
+
def self.from_clipboard(dir: DEFAULT_DIR, now: Time.now, run: Subprocess.method(:capture))
|
|
21
|
+
FileUtils.mkdir_p(dir)
|
|
22
|
+
prune(dir, now)
|
|
23
|
+
path = File.join(dir, now.strftime("%Y%m%d-%H%M%S-%L.png"))
|
|
24
|
+
return Clipboard.new(path, nil) if run.call("osascript", "-e", CLIPBOARD_PNG, path).success?
|
|
25
|
+
text = run.call("pbpaste")
|
|
26
|
+
Clipboard.new(nil, (text.success? && !text.out.empty?) ? text.out : nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
CLIPBOARD_PNG = <<~APPLESCRIPT
|
|
30
|
+
on run argv
|
|
31
|
+
set png to the clipboard as «class PNGf»
|
|
32
|
+
set f to open for access POSIX file (item 1 of argv) with write permission
|
|
33
|
+
write png to f
|
|
34
|
+
close access f
|
|
35
|
+
end run
|
|
36
|
+
APPLESCRIPT
|
|
37
|
+
|
|
38
|
+
# The path a dropped file arrives as, if it is an image: the terminal
|
|
39
|
+
# pastes the path escaped the way a shell would want it, with a space
|
|
40
|
+
# after, and only one of the extensions Claude Code itself attaches.
|
|
41
|
+
def self.dropped(text)
|
|
42
|
+
path = text.strip
|
|
43
|
+
path = path[1...-1] if path.match?(/\A(["']).*\1\z/)
|
|
44
|
+
path = path.gsub(/\\(.)/, '\1')
|
|
45
|
+
return nil unless EXTENSIONS.include?(File.extname(path).downcase)
|
|
46
|
+
File.file?(path) ? path : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.prune(dir, now)
|
|
50
|
+
Dir.glob(File.join(dir, "*.png")).each do |f|
|
|
51
|
+
File.delete(f) if now - File.mtime(f) > KEEP_FOR
|
|
52
|
+
rescue SystemCallError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
private_class_method :prune
|
|
57
|
+
end
|
|
58
|
+
end
|