claude-inbox 0.1.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +185 -386
  3. data/exe/claude-inbox +23 -5
  4. data/lib/claude_inbox/agents_client.rb +104 -44
  5. data/lib/claude_inbox/app.rb +114 -62
  6. data/lib/claude_inbox/config.rb +24 -0
  7. data/lib/claude_inbox/dialog.rb +10 -4
  8. data/lib/claude_inbox/images.rb +37 -6
  9. data/lib/claude_inbox/job_state.rb +32 -9
  10. data/lib/claude_inbox/keymap.rb +2 -2
  11. data/lib/claude_inbox/logs.rb +5 -2
  12. data/lib/claude_inbox/new_session_form.rb +64 -52
  13. data/lib/claude_inbox/painter.rb +32 -0
  14. data/lib/claude_inbox/palette.rb +4 -9
  15. data/lib/claude_inbox/peek.rb +4 -4
  16. data/lib/claude_inbox/poller.rb +9 -14
  17. data/lib/claude_inbox/pull_requests.rb +5 -5
  18. data/lib/claude_inbox/rate_limits.rb +19 -7
  19. data/lib/claude_inbox/reaper.rb +13 -16
  20. data/lib/claude_inbox/records.rb +7 -2
  21. data/lib/claude_inbox/remote/http.rb +137 -0
  22. data/lib/claude_inbox/remote/icon.png +0 -0
  23. data/lib/claude_inbox/remote/icon.svg +8 -0
  24. data/lib/claude_inbox/remote/listener.rb +436 -0
  25. data/lib/claude_inbox/remote/page.html +477 -0
  26. data/lib/claude_inbox/remote/pairing.rb +111 -0
  27. data/lib/claude_inbox/remote/pairing_dialog.rb +93 -0
  28. data/lib/claude_inbox/remote/start.rb +202 -0
  29. data/lib/claude_inbox/remote.rb +10 -0
  30. data/lib/claude_inbox/renderer.rb +135 -113
  31. data/lib/claude_inbox/session.rb +19 -19
  32. data/lib/claude_inbox/session_request.rb +151 -0
  33. data/lib/claude_inbox/sessions.rb +2 -3
  34. data/lib/claude_inbox/settings.rb +19 -7
  35. data/lib/claude_inbox/slash_commands.rb +6 -6
  36. data/lib/claude_inbox/store/row.rb +3 -3
  37. data/lib/claude_inbox/store.rb +11 -4
  38. data/lib/claude_inbox/subprocess.rb +12 -3
  39. data/lib/claude_inbox/terminal.rb +9 -3
  40. data/lib/claude_inbox/text.rb +13 -11
  41. data/lib/claude_inbox/text_buffer.rb +3 -2
  42. data/lib/claude_inbox/theme.rb +5 -4
  43. data/lib/claude_inbox/trust.rb +23 -0
  44. data/lib/claude_inbox/vt_screen.rb +1 -3
  45. data/lib/claude_inbox.rb +1 -1
  46. metadata +15 -2
@@ -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
@@ -49,10 +51,11 @@ module ClaudeInbox
49
51
  end
50
52
  end
51
53
 
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
+ # `X` stop, `Ctrl-x` delete and Enter on a remote row all ask first and
55
+ # all take `y`, but the words differ because the outcomes do: read the
56
+ # box before answering.
54
57
  class Confirm < Dialog
55
- TITLES = {stop: " Stop ", delete: " Delete "}.freeze
58
+ TITLES = {stop: " Stop ", delete: " Delete ", adopt: " Adopt "}.freeze
56
59
 
57
60
  def title = TITLES.fetch(kind)
58
61
 
@@ -62,6 +65,9 @@ module ClaudeInbox
62
65
  when :delete
63
66
  [" Delete session #{id}?", " Its worktree and conversation", " go with it.",
64
67
  "", " y delete it", " esc keep it"]
68
+ when :adopt
69
+ [" Pull this session into the daemon?", " Its phone session ends and a",
70
+ " new one takes its place.", "", " y adopt it", " esc cancel"]
65
71
  end
66
72
  end
67
73
 
@@ -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
@@ -13,8 +13,9 @@ module ClaudeInbox
13
13
  # each outstanding piece of work, and `detail` is the session's own status
14
14
  # line, with `needs` naming what it is waiting on while blocked and
15
15
  # `output.result` what it produced once done. It also carries the PR links the daemon scanned out of the
16
- # transcript, which PullRequests reads through here, and the color `/color`
17
- # set on the session, which `claude agents --json` drops.
16
+ # transcript, which PullRequests reads through here, the color `/color`
17
+ # set on the session, which `claude agents --json` drops, and `intent`, the
18
+ # prompt the session was started from.
18
19
  #
19
20
  # Never cached. A color changes the moment you type `/color`, so every poll
20
21
  # asks the file again.
@@ -49,7 +50,7 @@ module ClaudeInbox
49
50
  nil
50
51
  end
51
52
 
52
- attr_reader :detail, :needs, :result, :tempo, :kinds, :tasks, :pr_urls, :color
53
+ attr_reader :detail, :needs, :result, :pr_urls, :color, :intent, :bridge_id
53
54
 
54
55
  def initialize(hash)
55
56
  @detail = hash["detail"]
@@ -60,18 +61,31 @@ module ClaudeInbox
60
61
  @tasks = (hash["inFlight"] || {})["tasks"].to_i
61
62
  @pr_urls = (hash["children"] || []).select { |c| c["kind"] == "pr" && c["href"] }.map { |c| c["href"] }
62
63
  @color = hash["color"]
64
+ @intent = hash["intent"]
65
+ @bridge_id = hash["bridgeSessionId"]
66
+ @flags = hash["respawnFlags"] || []
63
67
  end
64
68
 
65
- # The agent itself is not thinking. On its own this means little — a
66
- # session whose process died leaves the same reading behind — so it only
67
- # says something paired with work still in flight.
68
- def agent_idle? = tempo == "idle"
69
-
70
- def in_flight? = tasks.positive?
69
+ # The flags the session was started with, which the daemon puts back on
70
+ # a wake, are the only record of Remote Control being on it.
71
+ def remote_control? = flags.include?("--remote-control")
71
72
 
72
73
  # True when the agent has stopped and is only waiting on what it started.
73
74
  def waiting_on_work? = agent_idle? && in_flight?
74
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
+
75
89
  # "1 shell", "2 agents · 1 shell". Falls back to a bare count for a state
76
90
  # file that counts the open tasks without naming them.
77
91
  def in_flight_label
@@ -82,6 +96,15 @@ module ClaudeInbox
82
96
 
83
97
  private
84
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
+
85
108
  def count_label = "#{tasks} #{plural("task", tasks)}"
86
109
 
87
110
  def plural(word, n) = (n == 1) ? word : "#{word}s"
@@ -26,8 +26,8 @@ module ClaudeInbox
26
26
  "h" => :collapse,
27
27
  "s" => :snooze, "u" => :wake, "a" => :alias, "x" => :settle, "X" => :stop,
28
28
  :ctrl_x => :delete,
29
- "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin,
30
- "R" => :refresh, "p" => :toggle_peek, "n" => :new_session,
29
+ "o" => :open_pr, "P" => :link_pr, "t" => :toggle_pin, "w" => :open_remote,
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
@@ -18,8 +18,11 @@ module ClaudeInbox
18
18
  @pending = nil
19
19
  @pending_at = nil
20
20
  @requests = Queue.new
21
+ end
22
+
23
+ def start
24
+ return if @thread&.alive?
21
25
  @thread = Thread.new { worker }
22
- @thread.abort_on_exception = false
23
26
  end
24
27
 
25
28
  def cached(id)
@@ -42,7 +45,7 @@ module ClaudeInbox
42
45
  @requests << id unless fresh
43
46
  end
44
47
 
45
- def stop = @thread.kill
48
+ def stop = @thread&.kill
46
49
 
47
50
  private
48
51
 
@@ -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 = [
@@ -35,7 +39,8 @@ module ClaudeInbox
35
39
  Field.new(:model, "Model", :choice, DEFAULT, AgentsClient::MODELS),
36
40
  Field.new(:effort, "Effort", :choice, DEFAULT, AgentsClient::EFFORTS),
37
41
  Field.new(:permission_mode, "Permissions", :choice, DEFAULT, AgentsClient::PERMISSION_MODES),
38
- Field.new(:worktree, "Worktree", :choice, "no", %w[no yes])
42
+ Field.new(:worktree, "Worktree", :choice, "no", %w[no yes]),
43
+ Field.new(:remote, "Remote Control", :choice, DEFAULT, [DEFAULT, "no", "yes"])
39
44
  ]
40
45
  @focus = 0
41
46
  @error = nil
@@ -44,12 +49,19 @@ module ClaudeInbox
44
49
  @pick = 0
45
50
  @dismissed = nil
46
51
  @confirm_discard = false
52
+ @busy = false
47
53
  end
48
54
 
49
55
  def focused = @fields[@focus]
50
56
 
51
57
  # => :cancel | :start | :start_and_attach | :changed
52
58
  def press(name, raw)
59
+ # `:start`/`:start_and_attach` leave the form up until the spawn is
60
+ # known to have worked, so a failure (untrusted directory, `claude`
61
+ # missing, ...) can hand the composed prompt back instead of losing
62
+ # it; there is no way to cancel a spawn already under way, so every
63
+ # key is swallowed until the App reports back.
64
+ return :changed if @busy
53
65
  return confirm_discard_press(name, raw) if @confirm_discard
54
66
  @error = nil
55
67
  @candidates = nil
@@ -106,18 +118,18 @@ module ClaudeInbox
106
118
  def picked = menu&.fetch(@pick.clamp(0, menu.size - 1))
107
119
 
108
120
  def values
109
- @fields.to_h { |f| [f.key, f.value.to_s] }.tap do |v|
110
- v[:prompt] = @fields[0].value.expand { |chip| AgentsClient.mention(chip.path) }.strip
111
- v[:worktree] = v[:worktree] == "yes"
112
- v[:name] = nil if v[:name].strip.empty?
113
- v[:cwd] = File.expand_path(v[:cwd].strip.empty? ? "." : v[:cwd].strip)
114
- 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]))
115
128
  end
116
129
 
117
130
  # Settings resolve against the directory the session will run in, so
118
131
  # they follow the Directory field.
119
- def defaults
120
- cwd = values[:cwd]
132
+ def defaults(cwd = directory)
121
133
  return @defaults if @defaults_for == cwd
122
134
  @defaults_for = cwd
123
135
  @defaults = Settings.defaults(cwd, home: @home)
@@ -126,7 +138,7 @@ module ClaudeInbox
126
138
  # Project commands live under the Directory field's path, so they
127
139
  # follow it as the defaults do.
128
140
  def commands
129
- cwd = values[:cwd]
141
+ cwd = directory
130
142
  return @commands if @commands_for == cwd
131
143
  @commands_for = cwd
132
144
  @commands = SlashCommands.list(cwd: cwd, home: @home)
@@ -140,27 +152,23 @@ module ClaudeInbox
140
152
  menu_rows = menu_lines(inner_w, [height - fixed - 3, MENU_ROWS].min)
141
153
  prompt_h = [height - fixed - menu_rows.size, 3].max
142
154
  out = [""]
143
- out << " " + (@confirm_discard ? @theme.red("Discard this session? (y/n)") : @p.bold("New session"))
155
+ title = @busy ? @p.dim("Starting session…") : @p.bold("New session")
156
+ out << " " + (@confirm_discard ? @theme.red("Discard this session? (y/n)") : title)
144
157
  out << ""
145
- out << " " + field_label(@fields[0]) + @p.dim(" ⏎ newline")
146
- out += prompt_box(@fields[0], inner_w, prompt_h)
158
+ out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
159
+ out += prompt_box(field(:prompt), inner_w, prompt_h)
147
160
  out += menu_rows
148
161
  out << ""
149
- @fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 14) }
162
+ @fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 16) }
150
163
  out.first(height) + [""] * [height - out.size, 0].max
151
164
  end
152
165
 
153
166
  def footer
154
- if @confirm_discard
155
- return [["y", "discard"], ["esc", "keep editing"]]
156
- .map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
157
- end
167
+ return @p.dim("starting session…") if @busy
168
+ return hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
158
169
  return @theme.red(@error) if @error
159
170
  return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
160
- if menu
161
- return [["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]
162
- .map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
163
- end
171
+ return hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
164
172
  keys =
165
173
  case focused.kind
166
174
  when :multiline then [["⏎", "newline"], ["^V", "image"]]
@@ -169,14 +177,24 @@ module ClaudeInbox
169
177
  end
170
178
  keys += [["^S", "start"], ["^O", "start & open"],
171
179
  ["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
172
- keys.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
180
+ hints(keys)
181
+ end
182
+
183
+ # The App's `claude --bg` call failed after `:start`/`:start_and_attach`
184
+ # already handed the form off — an untrusted directory, a stale binary,
185
+ # a permissions error. The composed prompt is still here, so the form
186
+ # reopens on it with the reason shown instead of the App just logging
187
+ # the failure and losing the draft.
188
+ def submission_failed(message)
189
+ @busy = false
190
+ @error = message
173
191
  end
174
192
 
175
193
  private
176
194
 
177
195
  # Esc with a prompt typed asks first, so a stray keypress can't lose it.
178
196
  def escape_pressed
179
- return :cancel if @fields[0].value.empty?
197
+ return :cancel if field(:prompt).value.empty?
180
198
  @confirm_discard = true
181
199
  :changed
182
200
  end
@@ -242,9 +260,11 @@ module ClaudeInbox
242
260
  rows
243
261
  end
244
262
 
263
+ def hints(pairs) = pairs.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
264
+
245
265
  def field_label(f)
246
266
  on = f.equal?(focused)
247
- (on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 12)) : @p.dim(Text.pad(f.label, 12)))
267
+ (on ? @theme.cyan_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
248
268
  end
249
269
 
250
270
  # The cell the cursor sits on, drawn as a block by inverting it: a bar
@@ -325,38 +345,30 @@ module ClaudeInbox
325
345
 
326
346
  def move(d) = @focus = (@focus + d) % @fields.size
327
347
 
328
- # Keys a :choice field answers to; an editable field spends these on its
329
- # own text instead.
330
- def choose(name, raw)
331
- case name
332
- when :left then cycle(-1)
333
- when :right then cycle(1)
334
- else
335
- case raw
336
- when "h" then cycle(-1)
337
- when "l", " " then cycle(1)
338
- end
339
- end
348
+ def field(key) = @fields.find { |f| f.key == key }
349
+
350
+ def directory
351
+ path = field(:cwd).value.to_s.strip
352
+ File.expand_path(path.empty? ? "." : path)
340
353
  end
341
354
 
342
- def cycle(d)
355
+ def focus_on(key) = @focus = @fields.index { |f| f.key == key }
356
+
357
+ def choose(name, raw)
358
+ d = CYCLE[name] || CYCLE[raw]
359
+ return unless d
343
360
  f = focused
344
- return unless f.kind == :choice
345
361
  f.value = f.choices[(f.choices.index(f.value) + d) % f.choices.size]
346
362
  end
347
363
 
348
364
  def submit(attach:)
349
- v = values
350
- if v[:prompt].empty?
351
- @error = "a prompt is required"
352
- @focus = 0
353
- return :changed
354
- end
355
- unless File.directory?(v[:cwd])
356
- @error = "no such directory: #{v[:cwd]}"
357
- @focus = 2
365
+ field, message = SessionRequest.problem(values)
366
+ if field
367
+ @error = message
368
+ focus_on(field)
358
369
  return :changed
359
370
  end
371
+ @busy = true
360
372
  attach ? :start_and_attach : :start
361
373
  end
362
374
  end
@@ -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,15 +17,10 @@ module ClaudeInbox
17
17
 
18
18
  INDEXED = {"orange" => 208, "pink" => 205}.freeze
19
19
 
20
- RESET = "\e[39m"
21
-
22
- def self.known?(name) = ANSI.key?(name) || INDEXED.key?(name)
20
+ SEQUENCES = ANSI.transform_values { |n| "\e[#{n}m" }
21
+ .merge(INDEXED.transform_values { |n| "\e[38;5;#{n}m" }).freeze
23
22
 
24
- def self.sequence(name)
25
- return "\e[#{ANSI[name]}m" if ANSI.key?(name)
26
- return "\e[38;5;#{INDEXED[name]}m" if INDEXED.key?(name)
27
- nil
28
- end
23
+ RESET = "\e[39m"
29
24
 
30
25
  def initialize(enabled: true)
31
26
  @enabled = enabled
@@ -33,7 +28,7 @@ module ClaudeInbox
33
28
 
34
29
  def paint(text, name)
35
30
  return text unless @enabled
36
- seq = self.class.sequence(name)
31
+ seq = SEQUENCES[name]
37
32
  seq ? seq + text + RESET : text
38
33
  end
39
34
  end
@@ -7,7 +7,7 @@ module ClaudeInbox
7
7
  # lands on a warm cache. Main-thread state only.
8
8
  class Peek
9
9
  TERMINAL_NOTE = "This is a claude you opened in a terminal yourself. The daemon can't attach to it, read its output, or stop it from outside. Switch to that window."
10
- REMOTE_NOTE = "This is a Remote Control session driven from claude.ai/code. The daemon can't attach to it or read its output from here. Open it in the web or mobile app instead."
10
+ REMOTE_NOTE = "This is a Remote Control session driven from claude.ai/code. The daemon can't attach to it or read its output from here. Enter pulls it into the daemon; w opens it in the web or mobile app."
11
11
 
12
12
  # What Renderer paints: the body lines, the title bar, the dim line under it.
13
13
  View = Struct.new(:lines, :title, :subtitle)
@@ -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
@@ -58,7 +58,7 @@ module ClaudeInbox
58
58
  end
59
59
 
60
60
  def interactive_note(s)
61
- [s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}"]
61
+ [s.remote? ? REMOTE_NOTE : TERMINAL_NOTE, "", "pid #{s.pid} · #{s.cwd}", "session #{s.session_id}", s.remote_url].compact
62
62
  end
63
63
 
64
64
  def subtitle(row)
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "reaper"
4
3
  require_relative "sessions"
5
- require_relative "store"
6
4
 
7
5
  module ClaudeInbox
8
6
  # Asks `claude agents` for the list off the main thread, every INTERVAL
@@ -15,11 +13,11 @@ module ClaudeInbox
15
13
  class Poller
16
14
  INTERVAL = 4
17
15
 
18
- def initialize(client:, store:, pull_requests:, jobs_dir:, reaper:, queue:, interval: INTERVAL)
16
+ def initialize(client:, store:, pull_requests:, reaper:, queue:, interval: INTERVAL, clock: -> { Time.now })
19
17
  @client = client
18
+ @clock = clock
20
19
  @store = store
21
20
  @pull_requests = pull_requests
22
- @jobs_dir = jobs_dir
23
21
  @reaper = reaper
24
22
  @queue = queue
25
23
  @interval = interval
@@ -36,8 +34,10 @@ module ClaudeInbox
36
34
 
37
35
  def stop = @thread&.kill
38
36
 
39
- # Skips the poll rather than the timer, so nothing forks `claude` while
40
- # 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.
41
41
  def pause = @lock.synchronize { @paused = true }
42
42
 
43
43
  def resume
@@ -55,8 +55,8 @@ module ClaudeInbox
55
55
  # dropped it. The gh refresh comes last and publishes again only if a PR
56
56
  # state moved.
57
57
  def once
58
- now = Time.now
59
- sessions = Sessions.load(client: @client, jobs_dir: @jobs_dir, pull_requests: @pull_requests, overrides: @store.pr_overrides)
58
+ now = @clock.call
59
+ sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
60
60
  doomed = @reaper.due(sessions, now)
61
61
  @store.hide(doomed)
62
62
  @queue << [:sessions, sessions]
@@ -67,7 +67,7 @@ module ClaudeInbox
67
67
  @store.release(doomed - reaped)
68
68
  end
69
69
  @queue << [:sessions, sessions] if reaped != doomed
70
- notice_reaped(reaped) if reaped.any?
70
+ @queue << [:notice, @reaper.report(reaped)] if reaped.any?
71
71
  fresh, moved = @pull_requests.refresh(sessions)
72
72
  @queue << [:sessions, fresh] if moved
73
73
  rescue => e
@@ -89,10 +89,5 @@ module ClaudeInbox
89
89
  end
90
90
 
91
91
  def paused? = @lock.synchronize { @paused }
92
-
93
- def notice_reaped(keys)
94
- word = (keys.size == 1) ? "session" : "sessions"
95
- @queue << [:notice, "reaped #{keys.size} #{word} idle over #{Store::REAP_AFTER / 86_400}d — see #{@reaper.log_path}"]
96
- end
97
92
  end
98
93
  end
@@ -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