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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +442 -0
  4. data/exe/claude-inbox +33 -0
  5. data/lib/claude_inbox/agents_client.rb +224 -0
  6. data/lib/claude_inbox/app.rb +533 -0
  7. data/lib/claude_inbox/debug.rb +16 -0
  8. data/lib/claude_inbox/dialog.rb +105 -0
  9. data/lib/claude_inbox/images.rb +58 -0
  10. data/lib/claude_inbox/job_state.rb +89 -0
  11. data/lib/claude_inbox/keymap.rb +71 -0
  12. data/lib/claude_inbox/logs.rb +66 -0
  13. data/lib/claude_inbox/mouse.rb +34 -0
  14. data/lib/claude_inbox/new_session_form.rb +363 -0
  15. data/lib/claude_inbox/palette.rb +40 -0
  16. data/lib/claude_inbox/paste.rb +42 -0
  17. data/lib/claude_inbox/peek.rb +81 -0
  18. data/lib/claude_inbox/poller.rb +98 -0
  19. data/lib/claude_inbox/pull_requests.rb +155 -0
  20. data/lib/claude_inbox/rate_limits.rb +48 -0
  21. data/lib/claude_inbox/reaper.rb +103 -0
  22. data/lib/claude_inbox/records.rb +28 -0
  23. data/lib/claude_inbox/renderer.rb +447 -0
  24. data/lib/claude_inbox/session.rb +100 -0
  25. data/lib/claude_inbox/sessions.rb +18 -0
  26. data/lib/claude_inbox/settings.rb +39 -0
  27. data/lib/claude_inbox/slash_commands.rb +116 -0
  28. data/lib/claude_inbox/store/entry.rb +144 -0
  29. data/lib/claude_inbox/store/row.rb +101 -0
  30. data/lib/claude_inbox/store/sections.rb +47 -0
  31. data/lib/claude_inbox/store/selection.rb +16 -0
  32. data/lib/claude_inbox/store.rb +156 -0
  33. data/lib/claude_inbox/subprocess.rb +53 -0
  34. data/lib/claude_inbox/terminal.rb +101 -0
  35. data/lib/claude_inbox/text.rb +98 -0
  36. data/lib/claude_inbox/text_buffer.rb +216 -0
  37. data/lib/claude_inbox/theme.rb +35 -0
  38. data/lib/claude_inbox/vt_screen.rb +138 -0
  39. data/lib/claude_inbox.rb +14 -0
  40. 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