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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -152
  3. data/exe/claude-inbox +23 -4
  4. data/lib/claude_inbox/agents_client.rb +57 -55
  5. data/lib/claude_inbox/app.rb +42 -26
  6. data/lib/claude_inbox/config.rb +24 -0
  7. data/lib/claude_inbox/dialog.rb +3 -1
  8. data/lib/claude_inbox/images.rb +37 -6
  9. data/lib/claude_inbox/job_state.rb +23 -8
  10. data/lib/claude_inbox/keymap.rb +1 -1
  11. data/lib/claude_inbox/new_session_form.rb +29 -39
  12. data/lib/claude_inbox/painter.rb +32 -0
  13. data/lib/claude_inbox/palette.rb +4 -7
  14. data/lib/claude_inbox/peek.rb +2 -2
  15. data/lib/claude_inbox/poller.rb +4 -3
  16. data/lib/claude_inbox/pull_requests.rb +5 -5
  17. data/lib/claude_inbox/records.rb +7 -2
  18. data/lib/claude_inbox/remote/http.rb +137 -0
  19. data/lib/claude_inbox/remote/icon.png +0 -0
  20. data/lib/claude_inbox/remote/icon.svg +8 -0
  21. data/lib/claude_inbox/remote/listener.rb +436 -0
  22. data/lib/claude_inbox/remote/page.html +477 -0
  23. data/lib/claude_inbox/remote/pairing.rb +111 -0
  24. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  25. data/lib/claude_inbox/remote/start.rb +202 -0
  26. data/lib/claude_inbox/remote.rb +10 -0
  27. data/lib/claude_inbox/renderer.rb +57 -57
  28. data/lib/claude_inbox/session.rb +7 -21
  29. data/lib/claude_inbox/session_request.rb +151 -0
  30. data/lib/claude_inbox/sessions.rb +0 -1
  31. data/lib/claude_inbox/settings.rb +19 -7
  32. data/lib/claude_inbox/store.rb +0 -2
  33. data/lib/claude_inbox/subprocess.rb +9 -1
  34. data/lib/claude_inbox/terminal.rb +9 -3
  35. data/lib/claude_inbox/text.rb +7 -1
  36. data/lib/claude_inbox/trust.rb +23 -0
  37. data/lib/claude_inbox/vt_screen.rb +1 -1
  38. data/lib/claude_inbox.rb +1 -1
  39. metadata +14 -1
@@ -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 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
+ # 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 = Queue.new
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
- text.each_char { |c| handle_key(c) }
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), height), modal: modal_lines(width), screen: screen_lines(width, height),
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
- return true if selected_session&.actionable?
206
- if (s = selected_session)&.interactive?
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
- return true if @selected&.row? && !selected_session&.terminal?
216
- notice("you're in that terminal right now — nothing to snooze") if selected_session&.terminal?
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] - 2, 1].max
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, name: current_fold_section)
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, height)
478
+ def screen_lines(width, body_h)
465
479
  return nil unless @modal.is_a?(NewSessionForm)
466
- {lines: @modal.screen(width, height - 2), footer: @modal.footer}
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
@@ -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, 44].min
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
@@ -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, or whatever is on the
8
- # clipboard. Both come back as a path for a TextBuffer chip to hold.
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
- # 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.
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, "*.png")).each do |f|
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, :tempo, :kinds, :tasks, :pr_urls, :color, :intent, :bridge_id, :flags
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"
@@ -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
- # "default" stays the internal value so spawn_args leaves the flag off;
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
- def initialize(cwd:, pastel:, theme: Theme.new(enabled: pastel.enabled), home: Dir.home, clipboard: Images.method(:from_clipboard))
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 = 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", %w[no yes])
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] }.tap do |v|
118
- v[:prompt] = field(:prompt).value.expand { |chip| AgentsClient.mention(chip.path) }.strip
119
- v[:worktree] = v[:worktree] == "yes"
120
- v[:remote] = v[:remote] == "yes"
121
- v[:name] = nil if v[:name].strip.empty?
122
- v[:cwd] = File.expand_path(v[:cwd].strip.empty? ? "." : v[:cwd].strip)
123
- end
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 = values[: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
- case name
353
- when :left then cycle(-1)
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
- v = values
371
- if v[:prompt].empty?
372
- @error = "a prompt is required"
373
- focus_on(:prompt)
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
@@ -17,13 +17,10 @@ module ClaudeInbox
17
17
 
18
18
  INDEXED = {"orange" => 208, "pink" => 205}.freeze
19
19
 
20
- RESET = "\e[39m"
20
+ SEQUENCES = ANSI.transform_values { |n| "\e[#{n}m" }
21
+ .merge(INDEXED.transform_values { |n| "\e[38;5;#{n}m" }).freeze
21
22
 
22
- def self.sequence(name)
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 = self.class.sequence(name)
31
+ seq = SEQUENCES[name]
35
32
  seq ? seq + text + RESET : text
36
33
  end
37
34
  end
@@ -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, height)
47
+ def view(row, body_h)
48
48
  return nil unless @open && @selected&.row?
49
- View.new(scrolled(body(row), height - 2), row&.label || @selected.key, subtitle(row))
49
+ View.new(scrolled(body(row), body_h), row&.label || @selected.key, subtitle(row))
50
50
  end
51
51
 
52
52
  private
@@ -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
- # Skips the poll rather than the timer, so nothing forks `claude` while
39
- # another process holds the terminal. A poll already under way finishes.
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
@@ -18,10 +18,15 @@ module ClaudeInbox
18
18
  {}
19
19
  end
20
20
 
21
- def save(path, data)
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.write(tmp, JSON.pretty_generate(data))
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