claude-inbox 0.2.0 → 0.3.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 +160 -152
- data/exe/claude-inbox +23 -4
- data/lib/claude_inbox/agents_client.rb +57 -55
- data/lib/claude_inbox/app.rb +42 -26
- data/lib/claude_inbox/config.rb +24 -0
- data/lib/claude_inbox/dialog.rb +3 -1
- data/lib/claude_inbox/images.rb +37 -6
- data/lib/claude_inbox/job_state.rb +23 -8
- data/lib/claude_inbox/keymap.rb +1 -1
- data/lib/claude_inbox/new_session_form.rb +29 -39
- data/lib/claude_inbox/painter.rb +32 -0
- data/lib/claude_inbox/palette.rb +4 -7
- data/lib/claude_inbox/peek.rb +2 -2
- data/lib/claude_inbox/poller.rb +4 -3
- data/lib/claude_inbox/pull_requests.rb +5 -5
- data/lib/claude_inbox/records.rb +7 -2
- data/lib/claude_inbox/remote/http.rb +137 -0
- data/lib/claude_inbox/remote/icon.png +0 -0
- data/lib/claude_inbox/remote/icon.svg +8 -0
- data/lib/claude_inbox/remote/listener.rb +436 -0
- data/lib/claude_inbox/remote/page.html +477 -0
- data/lib/claude_inbox/remote/pairing.rb +111 -0
- data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
- data/lib/claude_inbox/remote/start.rb +202 -0
- data/lib/claude_inbox/remote.rb +10 -0
- data/lib/claude_inbox/renderer.rb +57 -57
- data/lib/claude_inbox/session.rb +7 -21
- data/lib/claude_inbox/session_request.rb +151 -0
- data/lib/claude_inbox/sessions.rb +0 -1
- data/lib/claude_inbox/settings.rb +19 -7
- data/lib/claude_inbox/store.rb +0 -2
- data/lib/claude_inbox/subprocess.rb +9 -1
- data/lib/claude_inbox/terminal.rb +9 -3
- data/lib/claude_inbox/text.rb +7 -1
- data/lib/claude_inbox/trust.rb +23 -0
- data/lib/claude_inbox/vt_screen.rb +1 -1
- data/lib/claude_inbox.rb +1 -1
- metadata +14 -1
data/lib/claude_inbox/app.rb
CHANGED
|
@@ -11,23 +11,25 @@ require_relative "terminal"
|
|
|
11
11
|
require_relative "logs"
|
|
12
12
|
require_relative "peek"
|
|
13
13
|
require_relative "keymap"
|
|
14
|
+
require_relative "remote"
|
|
14
15
|
require_relative "mouse"
|
|
15
16
|
require_relative "new_session_form"
|
|
16
17
|
require_relative "paste"
|
|
17
18
|
require_relative "pull_requests"
|
|
19
|
+
require_relative "reaper"
|
|
18
20
|
require_relative "poller"
|
|
19
21
|
require_relative "rate_limits"
|
|
22
|
+
require_relative "session_request"
|
|
20
23
|
|
|
21
24
|
module ClaudeInbox
|
|
22
25
|
# Owns the terminal and the key loop. The only class allowed to spawn a
|
|
23
26
|
# child process that takes over the terminal.
|
|
24
27
|
class App
|
|
25
|
-
# The reaper
|
|
26
|
-
#
|
|
27
|
-
# reaches it by forgetting an argument.
|
|
28
|
+
# The reaper deletes sessions and the listener lets other machines in,
|
|
29
|
+
# so both are off unless `bin/claude-inbox` switches them on.
|
|
28
30
|
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))
|
|
31
|
+
rate_limits: RateLimits.new, reaper: Reaper.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
|
|
32
|
+
terminal: Terminal.new(out, input), queue: Queue.new)
|
|
31
33
|
@client = client
|
|
32
34
|
@store = store
|
|
33
35
|
@rate_limits = rate_limits
|
|
@@ -35,9 +37,10 @@ module ClaudeInbox
|
|
|
35
37
|
@color = color
|
|
36
38
|
@renderer = Renderer.new(color: color)
|
|
37
39
|
@reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
|
|
38
|
-
@queue =
|
|
40
|
+
@queue = queue
|
|
39
41
|
@poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
|
|
40
42
|
reaper: reaper, queue: @queue)
|
|
43
|
+
@listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, jobs_dir: client.jobs_dir, **listen) : Remote::Listener.disabled
|
|
41
44
|
@logs = Logs.new(client)
|
|
42
45
|
@peek = Peek.new(@logs)
|
|
43
46
|
@selected = nil
|
|
@@ -60,12 +63,14 @@ module ClaudeInbox
|
|
|
60
63
|
install_traps
|
|
61
64
|
@terminal.enter
|
|
62
65
|
@poller.start
|
|
66
|
+
@listener.start
|
|
63
67
|
@logs.start
|
|
64
68
|
main_loop
|
|
65
69
|
ensure
|
|
66
70
|
@poller.stop
|
|
67
71
|
@logs.stop
|
|
68
72
|
@terminal.restore
|
|
73
|
+
@listener.stop
|
|
69
74
|
end
|
|
70
75
|
|
|
71
76
|
def step(input = nil)
|
|
@@ -113,6 +118,7 @@ module ClaudeInbox
|
|
|
113
118
|
when :attach then attach(rest[0])
|
|
114
119
|
when :form_started then @modal = nil if @modal.equal?(rest[0])
|
|
115
120
|
when :form_failed then form_failed(*rest)
|
|
121
|
+
when :remote_started then remote_started(*rest)
|
|
116
122
|
end
|
|
117
123
|
end
|
|
118
124
|
rescue ThreadError
|
|
@@ -135,10 +141,13 @@ module ClaudeInbox
|
|
|
135
141
|
end
|
|
136
142
|
|
|
137
143
|
# The form takes a paste whole, images included; the one-line editors
|
|
138
|
-
# take it as typing, so a pasted PR URL lands where it should.
|
|
144
|
+
# take it as typing, so a pasted PR URL lands where it should. Anywhere
|
|
145
|
+
# else a paste is dropped: typed out, its letters would be keys, and a
|
|
146
|
+
# "y" answers a confirm.
|
|
139
147
|
def handle_paste(text)
|
|
140
148
|
return @modal.paste(text) if @modal.is_a?(NewSessionForm)
|
|
141
|
-
|
|
149
|
+
typing = @modal ? @modal.is_a?(Dialog::Prompt) : @filter_editing
|
|
150
|
+
text.each_char { |c| handle_key(c) } if typing
|
|
142
151
|
end
|
|
143
152
|
|
|
144
153
|
def render
|
|
@@ -146,13 +155,14 @@ module ClaudeInbox
|
|
|
146
155
|
now = Time.now
|
|
147
156
|
sections = filtered(@store.sections(now))
|
|
148
157
|
width, height = @terminal.size
|
|
158
|
+
body_h = Renderer.body_height(height)
|
|
149
159
|
ensure_selection(sections)
|
|
150
160
|
@tick += 1
|
|
151
161
|
view = Renderer::View.new(
|
|
152
162
|
width: width, height: height, now: now, selected: @selected&.key, top: @top, expanded: @expanded,
|
|
153
|
-
peek: @peek.view(sections.row(@selected),
|
|
163
|
+
peek: @peek.view(sections.row(@selected), body_h), modal: modal_lines(width), screen: screen_lines(width, body_h),
|
|
154
164
|
status: status_text(now), usage: @rate_limits.windows(now), filter: @filter, filter_editing: @filter_editing,
|
|
155
|
-
tick: @tick / 2, loading: loading_for
|
|
165
|
+
tick: @tick / 2, loading: loading_for, listening: @listener.snapshot
|
|
156
166
|
)
|
|
157
167
|
frame = @renderer.frame(sections, view)
|
|
158
168
|
@row_items = frame.items
|
|
@@ -202,8 +212,9 @@ module ClaudeInbox
|
|
|
202
212
|
|
|
203
213
|
# Guard for attach/stop: refuse politely on a terminal or remote row.
|
|
204
214
|
def require_actionable
|
|
205
|
-
|
|
206
|
-
if
|
|
215
|
+
s = selected_session
|
|
216
|
+
return true if s&.actionable?
|
|
217
|
+
if s&.interactive?
|
|
207
218
|
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")
|
|
208
219
|
end
|
|
209
220
|
false
|
|
@@ -212,8 +223,9 @@ module ClaudeInbox
|
|
|
212
223
|
# Guard for snooze/wake/alias: anything with a key, since those live in
|
|
213
224
|
# our own store. A terminal you are sitting in is the one exception.
|
|
214
225
|
def require_storable
|
|
215
|
-
|
|
216
|
-
|
|
226
|
+
s = selected_session
|
|
227
|
+
return true if @selected&.row? && !s&.terminal?
|
|
228
|
+
notice("you're in that terminal right now — nothing to snooze") if s&.terminal?
|
|
217
229
|
false
|
|
218
230
|
end
|
|
219
231
|
|
|
@@ -308,12 +320,13 @@ module ClaudeInbox
|
|
|
308
320
|
when :refresh then @poller.soon
|
|
309
321
|
when :toggle_peek then toggle_peek
|
|
310
322
|
when :new_session then open_new_session
|
|
323
|
+
when :remote_pairing then @modal = @listener.pairing_dialog
|
|
311
324
|
when :filter then start_filter
|
|
312
325
|
when :escape then clear_filter
|
|
313
326
|
end
|
|
314
327
|
end
|
|
315
328
|
|
|
316
|
-
def page = [@terminal.size[1]
|
|
329
|
+
def page = [Renderer.body_height(@terminal.size[1]), 1].max
|
|
317
330
|
|
|
318
331
|
def move(delta)
|
|
319
332
|
stops = filtered.selections(@expanded)
|
|
@@ -338,7 +351,8 @@ module ClaudeInbox
|
|
|
338
351
|
name if Store::FOLDABLE_SECTIONS.include?(name)
|
|
339
352
|
end
|
|
340
353
|
|
|
341
|
-
def set_expanded(value
|
|
354
|
+
def set_expanded(value)
|
|
355
|
+
name = current_fold_section
|
|
342
356
|
@expanded[name] = value if name
|
|
343
357
|
end
|
|
344
358
|
|
|
@@ -427,14 +441,7 @@ module ClaudeInbox
|
|
|
427
441
|
|
|
428
442
|
def open_new_session
|
|
429
443
|
cwd = selected_session&.cwd || Dir.pwd
|
|
430
|
-
@modal = NewSessionForm.new(cwd: strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
|
|
431
|
-
end
|
|
432
|
-
|
|
433
|
-
# A session's cwd may sit inside a worktree another agent is using; carrying
|
|
434
|
-
# that into a new prompt would spawn the new agent there too, writing over
|
|
435
|
-
# the same files. Fall back to the repo the worktree was cut from.
|
|
436
|
-
def strip_worktree(cwd)
|
|
437
|
-
cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
|
|
444
|
+
@modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
|
|
438
445
|
end
|
|
439
446
|
|
|
440
447
|
# `attach:` hands the terminal over as soon as the session starts. Without
|
|
@@ -454,6 +461,13 @@ module ClaudeInbox
|
|
|
454
461
|
end
|
|
455
462
|
end
|
|
456
463
|
|
|
464
|
+
# Someone at the desk may be mid-thought, so a start from another
|
|
465
|
+
# device never moves the cursor, attaches or closes what is open.
|
|
466
|
+
def remote_started(id, via)
|
|
467
|
+
notice("started #{id} from #{via}")
|
|
468
|
+
@poller.soon
|
|
469
|
+
end
|
|
470
|
+
|
|
457
471
|
# A spawn failure hands the form back rather than just logging it, so
|
|
458
472
|
# the composed prompt survives (see NewSessionForm#submission_failed).
|
|
459
473
|
def form_failed(form, message)
|
|
@@ -461,9 +475,9 @@ module ClaudeInbox
|
|
|
461
475
|
end
|
|
462
476
|
|
|
463
477
|
# The new-session form takes the whole body; a Dialog is a box over it.
|
|
464
|
-
def screen_lines(width,
|
|
478
|
+
def screen_lines(width, body_h)
|
|
465
479
|
return nil unless @modal.is_a?(NewSessionForm)
|
|
466
|
-
{lines: @modal.screen(width,
|
|
480
|
+
{lines: @modal.screen(width, body_h), footer: @modal.footer}
|
|
467
481
|
end
|
|
468
482
|
|
|
469
483
|
def modal_lines(width)
|
|
@@ -486,6 +500,8 @@ module ClaudeInbox
|
|
|
486
500
|
when :adopt then adopt_session(id)
|
|
487
501
|
end
|
|
488
502
|
when :save then save_prompt
|
|
503
|
+
when :copy then @listener.copy_pairing_url
|
|
504
|
+
when :rotate then @listener.rotate
|
|
489
505
|
end
|
|
490
506
|
end
|
|
491
507
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# Arguments for every launch. They go before the typed ones, so a typed
|
|
7
|
+
# flag wins.
|
|
8
|
+
module Config
|
|
9
|
+
PATH = File.join(Dir.home, ".config", "claude-inbox", "config")
|
|
10
|
+
|
|
11
|
+
def self.argv(typed, path: PATH)
|
|
12
|
+
args(path) + typed
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.args(path)
|
|
16
|
+
File.readlines(path).flat_map { |line| Shellwords.split(line.sub(/(\A|\s)#.*/, "")) }
|
|
17
|
+
rescue Errno::ENOENT
|
|
18
|
+
[]
|
|
19
|
+
rescue ArgumentError => e
|
|
20
|
+
raise ArgumentError, "#{path}: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
private_class_method :args
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/claude_inbox/dialog.rb
CHANGED
|
@@ -10,6 +10,8 @@ module ClaudeInbox
|
|
|
10
10
|
# the details read off the dialog afterwards. `frame(width)` is the box
|
|
11
11
|
# Renderer overlays. Pure: nothing here touches the store or the client.
|
|
12
12
|
class Dialog
|
|
13
|
+
WIDTH = 44
|
|
14
|
+
|
|
13
15
|
attr_reader :kind, :id
|
|
14
16
|
|
|
15
17
|
def initialize(kind, id)
|
|
@@ -18,7 +20,7 @@ module ClaudeInbox
|
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def frame(width, caret = nil)
|
|
21
|
-
box = [width - 4,
|
|
23
|
+
box = [width - 4, self.class::WIDTH].min
|
|
22
24
|
TTY::Box.frame(lines(box - 4, caret).join("\n"), title: {top_left: title}, padding: [0, 1], width: box)
|
|
23
25
|
.split("\n")
|
|
24
26
|
end
|
data/lib/claude_inbox/images.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
require_relative "subprocess"
|
|
5
6
|
|
|
6
7
|
module ClaudeInbox
|
|
7
|
-
# Images a prompt can carry: a dropped file,
|
|
8
|
-
# clipboard.
|
|
8
|
+
# Images a prompt can carry: a dropped file, whatever is on the
|
|
9
|
+
# clipboard, or the bytes a request sent. Each comes back as a path for
|
|
10
|
+
# the prompt to mention.
|
|
9
11
|
module Images
|
|
10
12
|
DEFAULT_DIR = File.join(Dir.home, ".config", "claude-inbox", "images")
|
|
11
13
|
EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .svg].freeze
|
|
@@ -14,9 +16,19 @@ module ClaudeInbox
|
|
|
14
16
|
|
|
15
17
|
Clipboard = Struct.new(:image, :text)
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
19
|
+
class Unsupported < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Bytes from a request carry no file name worth trusting, so the type
|
|
22
|
+
# comes from the magic number at the front.
|
|
23
|
+
MAGIC = {
|
|
24
|
+
".png" => /\A\x89PNG/n,
|
|
25
|
+
".jpg" => /\A\xFF\xD8\xFF/n,
|
|
26
|
+
".gif" => /\AGIF8/n,
|
|
27
|
+
".webp" => /\ARIFF.{4}WEBP/mn
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# macOS only: osascript ships with the OS, and PNG is the flavor a
|
|
31
|
+
# screenshot puts there.
|
|
20
32
|
def self.from_clipboard(dir: DEFAULT_DIR, now: Time.now, run: Subprocess.method(:capture))
|
|
21
33
|
FileUtils.mkdir_p(dir)
|
|
22
34
|
prune(dir, now)
|
|
@@ -26,6 +38,23 @@ module ClaudeInbox
|
|
|
26
38
|
Clipboard.new(nil, (text.success? && !text.out.empty?) ? text.out : nil)
|
|
27
39
|
end
|
|
28
40
|
|
|
41
|
+
# `index` orders the images one request brings; the random part keeps
|
|
42
|
+
# two requests saving in the same millisecond apart. Readable by the
|
|
43
|
+
# owner only: a photo can be anything.
|
|
44
|
+
def self.save(bytes, dir: DEFAULT_DIR, now: Time.now, index: 1)
|
|
45
|
+
data = bytes.b
|
|
46
|
+
ext = extension(data)
|
|
47
|
+
raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
|
|
48
|
+
FileUtils.mkdir_p(dir)
|
|
49
|
+
prune(dir, now)
|
|
50
|
+
path = File.join(dir, "#{now.strftime("%Y%m%d-%H%M%S-%L")}-#{index}-#{SecureRandom.hex(3)}#{ext}")
|
|
51
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(data) }
|
|
52
|
+
path
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ".png", ".jpg", ".gif" or ".webp" by the magic number, or nil.
|
|
56
|
+
def self.extension(bytes) = MAGIC.find { |_, magic| magic.match?(bytes.b) }&.first
|
|
57
|
+
|
|
29
58
|
CLIPBOARD_PNG = <<~APPLESCRIPT
|
|
30
59
|
on run argv
|
|
31
60
|
set png to the clipboard as «class PNGf»
|
|
@@ -46,8 +75,10 @@ module ClaudeInbox
|
|
|
46
75
|
File.file?(path) ? path : nil
|
|
47
76
|
end
|
|
48
77
|
|
|
78
|
+
# Pruned on the way in, because nothing else knows when a session
|
|
79
|
+
# stopped needing its image.
|
|
49
80
|
def self.prune(dir, now)
|
|
50
|
-
Dir.glob(File.join(dir, "
|
|
81
|
+
Dir.glob(EXTENSIONS.map { |ext| File.join(dir, "*#{ext}") }).each do |f|
|
|
51
82
|
File.delete(f) if now - File.mtime(f) > KEEP_FOR
|
|
52
83
|
rescue SystemCallError
|
|
53
84
|
nil
|
|
@@ -50,7 +50,7 @@ module ClaudeInbox
|
|
|
50
50
|
nil
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
attr_reader :detail, :needs, :result, :
|
|
53
|
+
attr_reader :detail, :needs, :result, :pr_urls, :color, :intent, :bridge_id
|
|
54
54
|
|
|
55
55
|
def initialize(hash)
|
|
56
56
|
@detail = hash["detail"]
|
|
@@ -70,16 +70,22 @@ module ClaudeInbox
|
|
|
70
70
|
# a wake, are the only record of Remote Control being on it.
|
|
71
71
|
def remote_control? = flags.include?("--remote-control")
|
|
72
72
|
|
|
73
|
-
# The agent itself is not thinking. On its own this means little — a
|
|
74
|
-
# session whose process died leaves the same reading behind — so it only
|
|
75
|
-
# says something paired with work still in flight.
|
|
76
|
-
def agent_idle? = tempo == "idle"
|
|
77
|
-
|
|
78
|
-
def in_flight? = tasks.positive?
|
|
79
|
-
|
|
80
73
|
# True when the agent has stopped and is only waiting on what it started.
|
|
81
74
|
def waiting_on_work? = agent_idle? && in_flight?
|
|
82
75
|
|
|
76
|
+
# What the session needs while blocked, what it produced once done,
|
|
77
|
+
# otherwise its status line; nil before it has said anything.
|
|
78
|
+
def summary(state)
|
|
79
|
+
line =
|
|
80
|
+
case state
|
|
81
|
+
when "blocked" then needs || detail
|
|
82
|
+
when "done" then result || detail
|
|
83
|
+
else detail
|
|
84
|
+
end
|
|
85
|
+
line = line.to_s.gsub(/\s+/, " ").strip
|
|
86
|
+
line.empty? ? nil : line
|
|
87
|
+
end
|
|
88
|
+
|
|
83
89
|
# "1 shell", "2 agents · 1 shell". Falls back to a bare count for a state
|
|
84
90
|
# file that counts the open tasks without naming them.
|
|
85
91
|
def in_flight_label
|
|
@@ -90,6 +96,15 @@ module ClaudeInbox
|
|
|
90
96
|
|
|
91
97
|
private
|
|
92
98
|
|
|
99
|
+
attr_reader :tempo, :kinds, :tasks, :flags
|
|
100
|
+
|
|
101
|
+
# The agent itself is not thinking. On its own this means little — a
|
|
102
|
+
# session whose process died leaves the same reading behind — so it only
|
|
103
|
+
# says something paired with work still in flight.
|
|
104
|
+
def agent_idle? = tempo == "idle"
|
|
105
|
+
|
|
106
|
+
def in_flight? = tasks.positive?
|
|
107
|
+
|
|
93
108
|
def count_label = "#{tasks} #{plural("task", tasks)}"
|
|
94
109
|
|
|
95
110
|
def plural(word, n) = (n == 1) ? word : "#{word}s"
|
data/lib/claude_inbox/keymap.rb
CHANGED
|
@@ -27,7 +27,7 @@ module ClaudeInbox
|
|
|
27
27
|
"s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
|
|
28
28
|
:ctrl_x => :delete,
|
|
29
29
|
"o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
|
|
30
|
-
"R" => :refresh, "p" => :toggle_peek, "n" => :new_session,
|
|
30
|
+
"R" => :refresh, "p" => :toggle_peek, "n" => :new_session, "N" => :remote_pairing,
|
|
31
31
|
:tab => :next_section, :back_tab => :prev_section,
|
|
32
32
|
"/" => :filter, :escape => :escape,
|
|
33
33
|
"q" => :quit, :ctrl_c => :quit
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "agents_client"
|
|
4
4
|
require_relative "images"
|
|
5
|
+
require_relative "session_request"
|
|
5
6
|
require_relative "settings"
|
|
6
7
|
require_relative "slash_commands"
|
|
7
8
|
require_relative "text"
|
|
@@ -17,15 +18,18 @@ module ClaudeInbox
|
|
|
17
18
|
# string on :choice ones — `kind` says which.
|
|
18
19
|
Field = Struct.new(:key, :label, :kind, :value, :choices)
|
|
19
20
|
|
|
20
|
-
#
|
|
21
|
-
# the screen shows what that resolves to instead.
|
|
21
|
+
# The screen shows what "default" resolves to; `values` makes it nil.
|
|
22
22
|
DEFAULT = "default"
|
|
23
23
|
|
|
24
24
|
MENU_ROWS = 6
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
# Keys a :choice field answers to, as the step through its choices; an
|
|
27
|
+
# editable field spends these on its own text instead.
|
|
28
|
+
CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(cwd:, pastel:, home: Dir.home, clipboard: Images.method(:from_clipboard))
|
|
27
31
|
@p = pastel
|
|
28
|
-
@theme =
|
|
32
|
+
@theme = Theme.new(enabled: pastel.enabled)
|
|
29
33
|
@home = home
|
|
30
34
|
@clipboard = clipboard
|
|
31
35
|
@fields = [
|
|
@@ -36,7 +40,7 @@ module ClaudeInbox
|
|
|
36
40
|
Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
|
|
37
41
|
Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
|
|
38
42
|
Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
|
|
39
|
-
Field.new(:remote, "Remote Control", :choice, "no",
|
|
43
|
+
Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
|
|
40
44
|
]
|
|
41
45
|
@focus = 0
|
|
42
46
|
@error = nil
|
|
@@ -114,19 +118,18 @@ module ClaudeInbox
|
|
|
114
118
|
def picked = menu&.fetch(@pick.clamp(0, menu.size - 1))
|
|
115
119
|
|
|
116
120
|
def values
|
|
117
|
-
@fields.to_h { |f| [f.key, f.value.to_s] }
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
v = @fields.to_h { |f| [f.key, (f.kind == :choice && f.value == DEFAULT) ? nil : f.value.to_s] }
|
|
122
|
+
v[:prompt] = field(:prompt).value.expand { |chip| AgentsClient.mention(chip.path) }.strip
|
|
123
|
+
v[:worktree] = v[:worktree] == "yes"
|
|
124
|
+
v[:name] = nil if v[:name].strip.empty?
|
|
125
|
+
v[:cwd] = directory
|
|
126
|
+
v[:remote] = SessionRequest::FLAGS[v[:remote]]
|
|
127
|
+
SessionRequest.resolve(v, defaults(v[:cwd]))
|
|
124
128
|
end
|
|
125
129
|
|
|
126
130
|
# Settings resolve against the directory the session will run in, so
|
|
127
131
|
# they follow the Directory field.
|
|
128
|
-
def defaults
|
|
129
|
-
cwd = values[:cwd]
|
|
132
|
+
def defaults(cwd = directory)
|
|
130
133
|
return @defaults if @defaults_for == cwd
|
|
131
134
|
@defaults_for = cwd
|
|
132
135
|
@defaults = Settings.defaults(cwd, home: @home)
|
|
@@ -135,7 +138,7 @@ module ClaudeInbox
|
|
|
135
138
|
# Project commands live under the Directory field's path, so they
|
|
136
139
|
# follow it as the defaults do.
|
|
137
140
|
def commands
|
|
138
|
-
cwd =
|
|
141
|
+
cwd = directory
|
|
139
142
|
return @commands if @commands_for == cwd
|
|
140
143
|
@commands_for = cwd
|
|
141
144
|
@commands = SlashCommands.list(cwd: cwd, home: @home)
|
|
@@ -344,38 +347,25 @@ module ClaudeInbox
|
|
|
344
347
|
|
|
345
348
|
def field(key) = @fields.find { |f| f.key == key }
|
|
346
349
|
|
|
350
|
+
def directory
|
|
351
|
+
path = field(:cwd).value.to_s.strip
|
|
352
|
+
File.expand_path(path.empty? ? "." : path)
|
|
353
|
+
end
|
|
354
|
+
|
|
347
355
|
def focus_on(key) = @focus = @fields.index { |f| f.key == key }
|
|
348
356
|
|
|
349
|
-
# Keys a :choice field answers to; an editable field spends these on its
|
|
350
|
-
# own text instead.
|
|
351
357
|
def choose(name, raw)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
when :right then cycle(1)
|
|
355
|
-
else
|
|
356
|
-
case raw
|
|
357
|
-
when "h" then cycle(-1)
|
|
358
|
-
when "l", " " then cycle(1)
|
|
359
|
-
end
|
|
360
|
-
end
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
def cycle(d)
|
|
358
|
+
d = CYCLE[name] || CYCLE[raw]
|
|
359
|
+
return unless d
|
|
364
360
|
f = focused
|
|
365
|
-
return unless f.kind == :choice
|
|
366
361
|
f.value = f.choices[(f.choices.index(f.value) + d) % f.choices.size]
|
|
367
362
|
end
|
|
368
363
|
|
|
369
364
|
def submit(attach:)
|
|
370
|
-
|
|
371
|
-
if
|
|
372
|
-
@error =
|
|
373
|
-
focus_on(
|
|
374
|
-
return :changed
|
|
375
|
-
end
|
|
376
|
-
unless File.directory?(v[:cwd])
|
|
377
|
-
@error = "no such directory: #{v[:cwd]}"
|
|
378
|
-
focus_on(:cwd)
|
|
365
|
+
field, message = SessionRequest.problem(values)
|
|
366
|
+
if field
|
|
367
|
+
@error = message
|
|
368
|
+
focus_on(field)
|
|
379
369
|
return :changed
|
|
380
370
|
end
|
|
381
371
|
@busy = true
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-cursor"
|
|
4
|
+
|
|
5
|
+
module ClaudeInbox
|
|
6
|
+
# Diffs successive frames and writes only changed rows. No erase-to-end-of-
|
|
7
|
+
# line after a row: in the terminal's last column the cursor stays put
|
|
8
|
+
# (pending wrap), so EL would eat the glyph just drawn.
|
|
9
|
+
class Painter
|
|
10
|
+
def initialize(out, cursor: TTY::Cursor)
|
|
11
|
+
@out = out
|
|
12
|
+
@cursor = cursor
|
|
13
|
+
@prev = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def paint(lines)
|
|
17
|
+
buf = +""
|
|
18
|
+
lines.each_with_index do |line, i|
|
|
19
|
+
next if @prev[i] == line
|
|
20
|
+
buf << @cursor.move_to(0, i) << line
|
|
21
|
+
end
|
|
22
|
+
if @prev.size > lines.size
|
|
23
|
+
(lines.size...@prev.size).each { |i| buf << @cursor.move_to(0, i) << @cursor.clear_line }
|
|
24
|
+
end
|
|
25
|
+
@out.print buf unless buf.empty?
|
|
26
|
+
@out.flush
|
|
27
|
+
@prev = lines
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def invalidate = @prev = []
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/claude_inbox/palette.rb
CHANGED
|
@@ -17,13 +17,10 @@ module ClaudeInbox
|
|
|
17
17
|
|
|
18
18
|
INDEXED = {"orange" => 208, "pink" => 205}.freeze
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
SEQUENCES = ANSI.transform_values { |n| "\e[#{n}m" }
|
|
21
|
+
.merge(INDEXED.transform_values { |n| "\e[38;5;#{n}m" }).freeze
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
return "\e[#{ANSI[name]}m" if ANSI.key?(name)
|
|
24
|
-
return "\e[38;5;#{INDEXED[name]}m" if INDEXED.key?(name)
|
|
25
|
-
nil
|
|
26
|
-
end
|
|
23
|
+
RESET = "\e[39m"
|
|
27
24
|
|
|
28
25
|
def initialize(enabled: true)
|
|
29
26
|
@enabled = enabled
|
|
@@ -31,7 +28,7 @@ module ClaudeInbox
|
|
|
31
28
|
|
|
32
29
|
def paint(text, name)
|
|
33
30
|
return text unless @enabled
|
|
34
|
-
seq =
|
|
31
|
+
seq = SEQUENCES[name]
|
|
35
32
|
seq ? seq + text + RESET : text
|
|
36
33
|
end
|
|
37
34
|
end
|
data/lib/claude_inbox/peek.rb
CHANGED
|
@@ -44,9 +44,9 @@ module ClaudeInbox
|
|
|
44
44
|
|
|
45
45
|
# `row` is the selected row as the frame shows it, or nil when nothing is
|
|
46
46
|
# selected. Answers nil when there is no pane to paint.
|
|
47
|
-
def view(row,
|
|
47
|
+
def view(row, body_h)
|
|
48
48
|
return nil unless @open && @selected&.row?
|
|
49
|
-
View.new(scrolled(body(row),
|
|
49
|
+
View.new(scrolled(body(row), body_h), row&.label || @selected.key, subtitle(row))
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
private
|
data/lib/claude_inbox/poller.rb
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "reaper"
|
|
4
3
|
require_relative "sessions"
|
|
5
4
|
|
|
6
5
|
module ClaudeInbox
|
|
@@ -35,8 +34,10 @@ module ClaudeInbox
|
|
|
35
34
|
|
|
36
35
|
def stop = @thread&.kill
|
|
37
36
|
|
|
38
|
-
#
|
|
39
|
-
#
|
|
37
|
+
# Only skips polls while another process holds the terminal; a poll
|
|
38
|
+
# already under way finishes. Forking from another thread meanwhile, as
|
|
39
|
+
# a remote start does, is fine: Subprocess puts every child in its own
|
|
40
|
+
# session, away from the tty.
|
|
40
41
|
def pause = @lock.synchronize { @paused = true }
|
|
41
42
|
|
|
42
43
|
def resume
|
|
@@ -85,11 +85,6 @@ module ClaudeInbox
|
|
|
85
85
|
[fresh, changed]
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
# Best known state for a url without asking gh.
|
|
89
|
-
def known(url)
|
|
90
|
-
@mutex.synchronize { @known[url] ||= seed(url) }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
88
|
# Best known state for a url, refreshed through gh when due.
|
|
94
89
|
def status(url)
|
|
95
90
|
@mutex.synchronize do
|
|
@@ -119,6 +114,11 @@ module ClaudeInbox
|
|
|
119
114
|
|
|
120
115
|
private
|
|
121
116
|
|
|
117
|
+
# Best known state for a url without asking gh.
|
|
118
|
+
def known(url)
|
|
119
|
+
@mutex.synchronize { @known[url] ||= seed(url) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
122
|
def due?(url)
|
|
123
123
|
@gh && @clock.call.to_i - @checked_at.fetch(url, 0) >= REFRESH_AFTER
|
|
124
124
|
end
|
data/lib/claude_inbox/records.rb
CHANGED
|
@@ -18,10 +18,15 @@ module ClaudeInbox
|
|
|
18
18
|
{}
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# `perm` is set on the temp file before anything is written to it, so
|
|
22
|
+
# a secret is never readable at a wider mode, not even briefly.
|
|
23
|
+
def save(path, data, perm: nil)
|
|
22
24
|
FileUtils.mkdir_p(File.dirname(path))
|
|
23
25
|
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
24
|
-
File.
|
|
26
|
+
File.open(tmp, "w", perm || 0o666) do |f|
|
|
27
|
+
f.chmod(perm) if perm
|
|
28
|
+
f.write(JSON.pretty_generate(data))
|
|
29
|
+
end
|
|
25
30
|
File.rename(tmp, path)
|
|
26
31
|
end
|
|
27
32
|
end
|