claude-inbox 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c29f6bef2ba7ecc7f4d63161cfc064517e21f95a3a187313a1c5b882ae34786
4
- data.tar.gz: 5195d163ef619f7f82a7224ca0a9b4dfe508fd23d8c1b3d87ab0a7b3b419cb28
3
+ metadata.gz: 150bc4c5ae639139680ad48d476e0628faa82e89e77e783fb1ac9b2ac34ab25f
4
+ data.tar.gz: c40bbe7c0a955ba01edc66014b3b2ff9d8b80eb70d3b6ea4a9345495a69af011
5
5
  SHA512:
6
- metadata.gz: 9b051d9eeba2b19da5c9ced63f9dcd00030619cc4c7ac667f873da8db411ecf3c777352d2b373dfb7def00c9737c6d1ed0bc8edb0663b0a73d850c01e429d20d
7
- data.tar.gz: cfe120a05f387965ba538ea48dbf726ad1bf379684b1515d213bead19d02803be8030b14abd938457671c6d80fe0d65ce1de1715632db8c304b768a5e6d46ae8
6
+ metadata.gz: 1168c17b14d49071580d1c8565eff838a8c5229e339de5cbbd3f90ed78274973cbe1b1b0f3d21b89c4734142029630a1cb9ea8b03932655d22d151a3447faaf8
7
+ data.tar.gz: 7a6fb8224d67ae2ff94136d9615bb1a5caf12b7430031c5d221fde12a46357aef6f7ffd223f2ab725fb15d3797458ddb7f6cd824d890a01b3b525c0dbc8e3f1b
data/README.md CHANGED
@@ -3,6 +3,10 @@
3
3
  An inbox for Claude Code's background sessions. It lists what `claude agents`
4
4
  lists and adds snooze, settle, pinning and each session's pull request.
5
5
 
6
+ Inspired by [T3 Code](https://github.com/pingdotgg/t3code)'s functionality and
7
+ inbox-style UX, I wanted a better way to manage my Claude agents, so I built
8
+ this.
9
+
6
10
  ![claude-inbox with one working session, one snoozed and the settled section folded](docs/screenshot.png)
7
11
 
8
12
  ## Install
@@ -33,8 +37,8 @@ a fullscreen terminal.
33
37
 
34
38
  `/inbox` opens it, `ctrl+x tab` focuses it and `esc` leaves. The keys are the
35
39
  ones below, with `ge` for `G`, `n` `p` for `Tab` `Shift+Tab`, and no `a`,
36
- `P`, `X`, `Ctrl-x`, `/` or `R`. `Enter` switches to the session when you
37
- attached through `claude-inbox`.
40
+ `P`, `X`, `Ctrl-x`, `/` or `R`. `Enter`, or a click on a row, switches to the
41
+ session when you attached through `claude-inbox`.
38
42
 
39
43
  It opens when a session starts. To stop that, set
40
44
  `pluginConfigs.inbox-pane.options.openOnStart` to `false` in
@@ -97,6 +101,9 @@ session and `Ctrl-O` starts it and attaches.
97
101
  Paste an image on macOS, or drop a file into the prompt, and refer to it as
98
102
  `[Image #1]`. Type `/` for a menu of your skills.
99
103
 
104
+ Type in Directory to pick from recent and trusted directories; `Ctrl-U`
105
+ lists them all.
106
+
100
107
  ## How sessions move
101
108
 
102
109
  A snoozed session wakes when its timer runs out, when you press `u`, or when
@@ -108,11 +115,10 @@ again.
108
115
  A session settles when you press `x` or when all its pull requests are merged
109
116
  or closed. A session without a pull request only settles by hand.
110
117
 
111
- The inbox deletes background sessions that have been idle for 14 days, with
112
- `claude rm`. Working, pinned and snoozed sessions are kept, and `claude rm`
113
- refuses a worktree with unpushed commits. Each deletion is logged to
114
- `~/.config/claude-inbox/reaped.log`. Set `CLAUDE_INBOX_NO_REAP=1` to turn it
115
- off.
118
+ With `--reap`, the inbox deletes background sessions that have been idle for
119
+ 14 days, with `claude rm`. Working, pinned and snoozed sessions are kept, and
120
+ `claude rm` refuses a worktree with unpushed commits. Each deletion is logged
121
+ to `~/.config/claude-inbox/reaped.log`.
116
122
 
117
123
  ## Remote Control
118
124
 
data/exe/claude-inbox CHANGED
@@ -60,12 +60,13 @@ if fixture
60
60
  opts[:listen] = listen&.merge(fixture: true, pairing: ClaudeInbox::Remote::Pairing.new(path: File.join(dir, "listen.json")),
61
61
  lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))
62
62
  else
63
- # The only place the reaper is armed. Everything else — tests, --fixture,
64
- # anything embedding App — gets one that deletes nothing. The listener,
65
- # likewise, comes only from this file, and only when asked for.
63
+ # The only place the reaper is armed, and only with --reap: deleting
64
+ # sessions is opt-in. Everything else — tests, --fixture, anything
65
+ # embedding App — gets one that deletes nothing. The listener, likewise,
66
+ # comes only from this file, and only when asked for.
66
67
  opts[:client] = ClaudeInbox::AgentsClient.new
67
68
  opts[:store] = ClaudeInbox::Store.new
68
- opts[:reaper] = ClaudeInbox::Reaper.new(opts[:client], opts[:store])
69
+ opts[:reaper] = argv.include?("--reap") ? ClaudeInbox::Reaper.new(opts[:client], opts[:store]) : ClaudeInbox::Reaper.disabled
69
70
  opts[:snapshot] = ClaudeInbox::Snapshot.new
70
71
  opts[:switch] = ClaudeInbox::Switch.new
71
72
  opts[:actions] = ClaudeInbox::Actions.new
@@ -25,16 +25,16 @@ module ClaudeInbox
25
25
  @sent = 0
26
26
  end
27
27
 
28
- # Leaves one request for the writer, as the pane does; `from` names the
29
- # asker, and a count keeps two requests in one millisecond apart.
30
- def request(action, id, choice: nil, from: "inbox-#{Process.pid}")
28
+ # Leaves one request for the writer, as the pane does, named for this
29
+ # inbox; a count keeps two requests in one millisecond apart.
30
+ def request(action, id, choice: nil)
31
31
  return unless @dir
32
32
  FileUtils.mkdir_p(@dir)
33
33
  at = (@clock.call.to_f * 1000).to_i
34
34
  request = {"action" => action, "id" => id, "at" => at}
35
35
  request["choice"] = choice if choice
36
36
  @sent += 1
37
- File.write(File.join(@dir, "#{from}-#{at}-#{@sent}.json"), JSON.generate(request))
37
+ File.write(File.join(@dir, "inbox-#{Process.pid}-#{at}-#{@sent}.json"), JSON.generate(request))
38
38
  end
39
39
 
40
40
  # Applies every request on disk to `store` and returns how many it took.
@@ -39,6 +39,8 @@ module ClaudeInbox
39
39
 
40
40
  # Hands the terminal to the child; caller must have restored cooked mode.
41
41
  # Returns when the user detaches. Detaching never stops the session.
42
+ # False when the attach failed on its own, as a session that cannot be
43
+ # woken does: the CLI prints why and exits 1.
42
44
  #
43
45
  # Pressing ← inside an attached session detaches it and then `claude
44
46
  # attach` execs itself in place as `claude agents` (same pid). There is
@@ -52,7 +54,7 @@ module ClaudeInbox
52
54
  pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
53
55
  watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
54
56
  _, status = Process.wait2(pid)
55
- status
57
+ status.success? || !status.exited?
56
58
  ensure
57
59
  watchdog&.kill
58
60
  end
@@ -188,14 +190,13 @@ module ClaudeInbox
188
190
  # origin, terminal when unlisted) and the bridge a remote one is served
189
191
  # over, and drops the unattended ones, whose parent is the row worth
190
192
  # showing.
191
- def assign_origins(sessions, origins, bridges = {})
193
+ def assign_origins(sessions, origins, bridges)
192
194
  sessions
193
195
  .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
194
196
  .reject(&:unattended?)
195
197
  end
196
198
 
197
199
  def origins_from(rows)
198
- return {} if rows.empty?
199
200
  self.class.origins(rows, ps_commands(rows.map { |_, ppid, _| ppid }.uniq))
200
201
  end
201
202
 
@@ -11,7 +11,7 @@ require_relative "terminal"
11
11
  require_relative "logs"
12
12
  require_relative "peek"
13
13
  require_relative "keymap"
14
- require_relative "remote"
14
+ require_relative "remote/listener"
15
15
  require_relative "mouse"
16
16
  require_relative "new_session_form"
17
17
  require_relative "paste"
@@ -23,6 +23,7 @@ require_relative "session_request"
23
23
  require_relative "actions"
24
24
  require_relative "snapshot"
25
25
  require_relative "switch"
26
+ require_relative "trust"
26
27
 
27
28
  module ClaudeInbox
28
29
  # Owns the terminal and the key loop. The only class allowed to spawn a
@@ -32,7 +33,7 @@ module ClaudeInbox
32
33
  # so both are off unless `bin/claude-inbox` switches them on.
33
34
  def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
34
35
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
35
- actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true,
36
+ actions: Actions.disabled, listen: nil, out: $stdout, input: $stdin, color: true, trust: Trust.method(:projects),
36
37
  terminal: Terminal.new(out, input), queue: Queue.new)
37
38
  @client = client
38
39
  @store = store
@@ -41,6 +42,7 @@ module ClaudeInbox
41
42
  @rate_limits = rate_limits
42
43
  @terminal = terminal
43
44
  @color = color
45
+ @trust = trust
44
46
  @renderer = Renderer.new(color: color)
45
47
  @input = input
46
48
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
@@ -305,7 +307,7 @@ module ClaudeInbox
305
307
  return if @list_width && col > @list_width
306
308
  item = row_item_at(row)
307
309
  return unless item
308
- select(item.selection)
310
+ select(item)
309
311
  activate
310
312
  end
311
313
 
@@ -313,7 +315,7 @@ module ClaudeInbox
313
315
  # no item of its own; a click there resolves to the row above it.
314
316
  def row_item_at(row)
315
317
  idx = row - 1
316
- @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.kind == :row)
318
+ @row_items[idx] || (@row_items[idx - 1] if idx > 0 && @row_items[idx - 1]&.row?)
317
319
  end
318
320
 
319
321
  def perform(action)
@@ -420,13 +422,16 @@ module ClaudeInbox
420
422
 
421
423
  # The pane in the attached session may ask for a different one: the
422
424
  # attach ends and the next starts without passing through the list. The
423
- # poller keeps running meanwhile, so the pane's snapshot stays fresh.
425
+ # poller keeps running meanwhile, so the pane's snapshot stays fresh. A
426
+ # failed attach leaves its reason on screen until a key is pressed.
424
427
  def attach(id)
425
428
  while id
426
429
  @store.acknowledge(id)
427
430
  @switch.clear
428
- from = @store.sessions.find { |s| s.key == id }&.session_id
429
- @terminal.release { @client.attach(id) { @switch.requested_for?(from) } }
431
+ from = session_for(id)&.session_id
432
+ @terminal.release do
433
+ @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
434
+ end
430
435
  id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
431
436
  end
432
437
  end
@@ -475,7 +480,8 @@ module ClaudeInbox
475
480
 
476
481
  def open_new_session
477
482
  cwd = selected_session&.cwd || Dir.pwd
478
- @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), pastel: Pastel.new(enabled: @color))
483
+ dirs = SessionRequest.directories(@store.sessions, @trust.call)
484
+ @modal = NewSessionForm.new(cwd: SessionRequest.strip_worktree(cwd), color: @color, dirs: dirs)
479
485
  end
480
486
 
481
487
  # `attach:` hands the terminal over as soon as the session starts. Without
@@ -13,12 +13,11 @@ module ClaudeInbox
13
13
  # reaps, since deleting sessions stays with the inbox you can see, and it
14
14
  # exits at once while an inbox holds the writer lock.
15
15
  class Headless
16
- def initialize(client:, store:, pull_requests: PullRequests.new, snapshot: Snapshot.new, actions: Actions.new,
17
- lock: WriterLock.new, queue: Queue.new)
16
+ def initialize(client:, store:, lock: WriterLock.new)
18
17
  @lock = lock
19
- @queue = queue
20
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
21
- queue: queue, snapshot: snapshot, actions: actions)
18
+ @queue = Queue.new
19
+ @poller = Poller.new(client: client, store: store, pull_requests: PullRequests.new, reaper: Reaper.disabled,
20
+ queue: @queue, snapshot: Snapshot.new, actions: Actions.new)
22
21
  end
23
22
 
24
23
  # False when the lock is held, by an inbox or another headless process.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pastel"
3
4
  require_relative "agents_client"
4
5
  require_relative "images"
5
6
  require_relative "session_request"
@@ -18,6 +19,12 @@ module ClaudeInbox
18
19
  # string on :choice ones — `kind` says which.
19
20
  Field = Struct.new(:key, :label, :kind, :value, :choices)
20
21
 
22
+ # A directory the Directory field offers, shown by its shortest
23
+ # distinct label as the phone's form shows it.
24
+ Place = Struct.new(:path, :label, :description) do
25
+ def to_s = label
26
+ end
27
+
21
28
  # The screen shows what "default" resolves to; `values` makes it nil.
22
29
  DEFAULT = "default"
23
30
 
@@ -27,11 +34,13 @@ module ClaudeInbox
27
34
  # editable field spends these on its own text instead.
28
35
  CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
29
36
 
30
- def initialize(cwd:, pastel:, home: Dir.home, clipboard: Images.method(:from_clipboard))
31
- @p = pastel
32
- @theme = Theme.new(enabled: pastel.enabled)
37
+ def initialize(cwd:, color:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
38
+ @p = Pastel.new(enabled: color)
39
+ @theme = Theme.new(enabled: color)
33
40
  @home = home
34
41
  @clipboard = clipboard
42
+ @places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
43
+ @dir_settled = cwd.to_s
35
44
  @fields = [
36
45
  Field.new(:prompt, "Prompt", :multiline, TextBuffer.new),
37
46
  Field.new(:name, "Name", :text, TextBuffer.new),
@@ -66,7 +75,7 @@ module ClaudeInbox
66
75
  @error = nil
67
76
  @candidates = nil
68
77
  return :changed if menu && menu_press(name)
69
- before = command_query
78
+ before = menu_query
70
79
  case name
71
80
  when :escape then return escape_pressed
72
81
  when :ctrl_s then return submit(attach: false)
@@ -80,7 +89,7 @@ module ClaudeInbox
80
89
  else
81
90
  editable? ? focused.value.press(name, raw) : choose(name, raw)
82
91
  end
83
- edited = before && command_query && command_query != before
92
+ edited = before && menu_query && menu_query != before
84
93
  @pick = 0 if edited
85
94
  @dismissed = nil if edited
86
95
  :changed
@@ -103,10 +112,12 @@ module ClaudeInbox
103
112
  :changed
104
113
  end
105
114
 
115
+ # Slash commands while one is being typed in the prompt; the recent and
116
+ # trusted directories once the Directory field is edited.
106
117
  def menu
107
- q = command_query
118
+ q = menu_query
108
119
  return nil if q.nil? || @dismissed == q
109
- found = SlashCommands.match(commands, q)
120
+ found = (focused.key == :cwd) ? places(q) : SlashCommands.match(commands, q)
110
121
  found.empty? ? nil : found
111
122
  end
112
123
 
@@ -144,9 +155,12 @@ module ClaudeInbox
144
155
  out << ""
145
156
  out << " " + field_label(field(:prompt)) + @p.dim(" ⏎ newline")
146
157
  out += prompt_box(field(:prompt), inner_w, prompt_h)
147
- out += menu_rows
158
+ out += menu_rows if focused.key == :prompt
148
159
  out << ""
149
- @fields[1..].each { |f| out << " " + field_label(f) + field_value(f, inner_w - 16) }
160
+ @fields[1..].each do |f|
161
+ out << " " + field_label(f) + field_value(f, inner_w - 16)
162
+ out += menu_rows if f.key == :cwd && focused.key == :cwd
163
+ end
150
164
  out.first(height) + [""] * [height - out.size, 0].max
151
165
  end
152
166
 
@@ -162,6 +176,7 @@ module ClaudeInbox
162
176
  when :choice then [["← → h l", "change"], ["⏎", "next"]]
163
177
  else [["⏎", "next"]]
164
178
  end
179
+ keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
165
180
  keys += [["^S", "start"], ["^O", "start & open"],
166
181
  ["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
167
182
  hints(keys)
@@ -179,9 +194,25 @@ module ClaudeInbox
179
194
 
180
195
  private
181
196
 
182
- def command_query
183
- return nil unless focused.key == :prompt
184
- focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
197
+ def menu_query
198
+ case focused.key
199
+ when :prompt then focused.value.head[/(?:\A|\s)\/(\S*)\z/, 1]
200
+ when :cwd then focused.value.to_s unless focused.value.to_s == @dir_settled
201
+ end
202
+ end
203
+
204
+ def places(query)
205
+ q = query.downcase
206
+ found = @places.select { |d| d.path.downcase.include?(q) || d.description.downcase.include?(q) }
207
+ starts, rest = found.partition { |d| d.label.downcase.start_with?(q) }
208
+ starts + rest
209
+ end
210
+
211
+ # A path put in the field by a pick or a completion rather than typed,
212
+ # so the menu stays shut until the next edit.
213
+ def settle_dir(path)
214
+ field(:cwd).value.replace(path)
215
+ @dir_settled = path
185
216
  end
186
217
 
187
218
  # Project commands live under the Directory field's path, so they
@@ -228,14 +259,18 @@ module ClaudeInbox
228
259
  when :up, :ctrl_p then @pick = (@pick - 1) % menu.size
229
260
  when :down, :ctrl_n then @pick = (@pick + 1) % menu.size
230
261
  when :tab, :return, :enter then accept(picked)
231
- when :escape then @dismissed = command_query
262
+ when :escape then @dismissed = menu_query
232
263
  else return false
233
264
  end
234
265
  true
235
266
  end
236
267
 
237
- def accept(cmd)
238
- focused.value.replace_before(command_query.grapheme_clusters.size + 1, "#{cmd} ")
268
+ def accept(item)
269
+ if focused.key == :cwd
270
+ settle_dir(item.path)
271
+ else
272
+ focused.value.replace_before(menu_query.grapheme_clusters.size + 1, "#{item} ")
273
+ end
239
274
  @pick = 0
240
275
  end
241
276
 
@@ -327,10 +362,10 @@ module ClaudeInbox
327
362
  matches = Dir.glob(listing ? "#{base}/*" : "#{base}*").select { |d| File.directory?(d) }.sort
328
363
  return nil if matches.empty?
329
364
  if matches.size == 1
330
- focused.value.replace("#{matches.first}/")
365
+ settle_dir("#{matches.first}/")
331
366
  else
332
367
  @candidates = matches.map { |d| File.basename(d) }
333
- focused.value.replace(common_prefix(matches))
368
+ settle_dir(common_prefix(matches))
334
369
  end
335
370
  :changed
336
371
  end
@@ -18,9 +18,7 @@ module ClaudeInbox
18
18
  next if @prev[i] == line
19
19
  buf << TTY::Cursor.move_to(0, i) << line
20
20
  end
21
- if @prev.size > lines.size
22
- (lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
23
- end
21
+ (lines.size...@prev.size).each { |i| buf << TTY::Cursor.move_to(0, i) << TTY::Cursor.clear_line }
24
22
  @out.print buf unless buf.empty?
25
23
  @out.flush
26
24
  @prev = lines
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "actions"
4
3
  require_relative "sessions"
5
- require_relative "snapshot"
6
4
 
7
5
  module ClaudeInbox
8
6
  # Asks `claude agents` for the list off the main thread, every INTERVAL
@@ -17,7 +15,7 @@ module ClaudeInbox
17
15
  INTERVAL = 4
18
16
  ACTIONS_INTERVAL = 0.5
19
17
 
20
- def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot: Snapshot.disabled, actions: Actions.disabled,
18
+ def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
21
19
  interval: INTERVAL, clock: -> { Time.now })
22
20
  @client = client
23
21
  @clock = clock
@@ -76,7 +76,7 @@ module ClaudeInbox
76
76
  # one (interactive, or forgotten) has none; Sessions.load sees to the
77
77
  # order. `overrides` maps session key => url for links set by hand; an
78
78
  # override replaces the scanned list.
79
- def enrich(sessions, overrides = {})
79
+ def enrich(sessions, overrides)
80
80
  sessions.map do |s|
81
81
  urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
82
82
  s.with(prs: urls.map { |u| known(u) })
@@ -30,7 +30,7 @@ module ClaudeInbox
30
30
  # time, or nil.
31
31
  # Parses only when the file has changed, since render asks several times
32
32
  # a second.
33
- def windows(now = Time.now)
33
+ def windows(now)
34
34
  mtime = File.mtime(@path)
35
35
  return nil if now - mtime > STALE_AFTER
36
36
  @data = parse if mtime != @mtime
@@ -20,15 +20,13 @@ module ClaudeInbox
20
20
  RETRY_AFTER = 24 * 3600
21
21
  DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
22
22
 
23
- def initialize(client, store, log_path: DEFAULT_LOG, enabled: self.class.enabled?)
23
+ def initialize(client, store, log_path: DEFAULT_LOG, enabled: true)
24
24
  @client = client
25
25
  @store = store
26
26
  @log_path = log_path
27
27
  @enabled = enabled
28
28
  end
29
29
 
30
- def self.enabled? = ENV["CLAUDE_INBOX_NO_REAP"].to_s.empty?
31
-
32
30
  # For --fixture runs and tests: selects nothing, deletes nothing, and
33
31
  # needs neither a client nor a store to do it.
34
32
  def self.disabled = new(nil, nil, enabled: false)
@@ -107,7 +107,7 @@ module ClaudeInbox
107
107
  end
108
108
  end
109
109
 
110
- def self.write(io, status, headers = {}, body = "")
110
+ def self.write(io, status, headers, body)
111
111
  body = body.b
112
112
  head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
113
113
  HEADERS.merge(headers, "Content-Length" => body.bytesize.to_s).each { |name, value| head << "#{name}: #{value}" }
@@ -17,7 +17,7 @@ module ClaudeInbox
17
17
  # connection limits, the static files and the Pairing token, in front of
18
18
  # Start. Like the Poller it reaches App only through the queue, and it
19
19
  # never writes to the terminal: every failure becomes a response or a
20
- # notice.
20
+ # notice. It is App's only way into Remote.
21
21
  class Listener
22
22
  DEFAULT_PORT = 7433
23
23
  DEFAULT_MODES = %w[default auto plan].freeze
@@ -58,17 +58,13 @@ module ClaudeInbox
58
58
  # lock, as `held_by`) or :failed (for good, as `error` says). `urls` and
59
59
  # `firewall` are nil until `refresh`.
60
60
  Snapshot = Data.define(:state, :port, :lan, :urls, :firewall, :allowed_modes, :recent, :held_by, :fixture, :error) do
61
- def initialize(error: nil, **) = super
62
-
63
61
  # Over a VPN only an address reaches the Mac: multicast DNS, which
64
62
  # the .local name needs, stays on the LAN.
65
63
  def pairing_url = (lan && urls&.[](1)) || urls&.first
66
64
  end
67
65
 
68
66
  # `count` is how many times `via` got `result`, the last at `at`.
69
- Outcome = Data.define(:at, :via, :result, :count) do
70
- def initialize(at:, via:, result:, count: 1) = super
71
- end
67
+ Outcome = Data.define(:at, :via, :result, :count)
72
68
 
73
69
  # App's `listen:` from the flags, else the environment; nil when neither
74
70
  # asks for it. --listen-lan wins over --listen, whichever came last.
@@ -26,7 +26,7 @@ module ClaudeInbox
26
26
  BODY_TIMEOUT = 120
27
27
  KEYS_KEPT = 16
28
28
 
29
- def initialize(client:, store:, queue:, allowed_modes:, fixture: false, images_dir: Images::DEFAULT_DIR,
29
+ def initialize(client:, store:, queue:, allowed_modes:, fixture:, images_dir: Images::DEFAULT_DIR,
30
30
  trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
31
31
  @client = client
32
32
  @store = store
@@ -62,13 +62,7 @@ module ClaudeInbox
62
62
  }
63
63
  end
64
64
 
65
- # Where sessions ran lately, newest first, then every directory whose
66
- # trust dialog was accepted: the likely choices first. Only an order;
67
- # the CLI still decides where a session may start.
68
- def dir_paths
69
- recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
70
- (recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
71
- end
65
+ def dir_paths = SessionRequest.directories(@store.sessions, @trust.call)
72
66
 
73
67
  # Checked cheapest first, and nothing is written to disk until every
74
68
  # check has passed. A retry with the same Idempotency-Key gets the
@@ -11,12 +11,6 @@ module ClaudeInbox
11
11
  # `width` columns wide) plus a parallel Array of selectable items.
12
12
  # Pure: no terminal, no IO, no clock beyond the `now` it is handed.
13
13
  class Renderer
14
- Item = Struct.new(:kind, :row, :section) do
15
- def selection = (kind == :fold_toggle) ? Store::Selection.fold(section) : Store::Selection.row(row.key)
16
-
17
- def key = selection.key
18
- end
19
-
20
14
  Frame = Struct.new(:lines, :items, :top, :list_width)
21
15
 
22
16
  CHROME_ROWS = 2 # header + footer
@@ -175,7 +169,7 @@ module ClaudeInbox
175
169
  end
176
170
  end
177
171
 
178
- def header_chips(sections, compact:, listening: nil)
172
+ def header_chips(sections, compact:, listening:)
179
173
  pinned = sections.pinned.size
180
174
  needing = sections.needs_you.size
181
175
  working = sections.active.count { |r| r.session.effective_state == "working" && !r.session.waiting_on_work? }
@@ -214,7 +208,7 @@ module ClaudeInbox
214
208
 
215
209
  def footer(width, view)
216
210
  text =
217
- if view.filter_editing then " " + @theme.cyan_bold("/") + line(view.filter, width - 2)
211
+ if view.filter_editing then " " + @theme.cyan_bold("/") + view.filter.row(width - 2, cursor: caret)
218
212
  elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
219
213
  else " " + footer_keys(view).map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
220
214
  end
@@ -226,8 +220,6 @@ module ClaudeInbox
226
220
  KEYS.flat_map { |key| (key[0] == "n") ? [key, ["N", "pair"]] : [key] }
227
221
  end
228
222
 
229
- def line(buffer, width) = buffer.row(width, cursor: caret)
230
-
231
223
  def section_title(name, count, width)
232
224
  title = " #{SECTION_TITLES[name]} "
233
225
  count_s = " #{count} "
@@ -256,13 +248,13 @@ module ClaudeInbox
256
248
  items << nil << nil
257
249
  if Store.folded?(name, view.expanded)
258
250
  lines << fold_toggle_line(name, rows.size, selected, width)
259
- items << Item.new(:fold_toggle, nil, name)
251
+ items << Store::Selection.fold(name)
260
252
  next
261
253
  end
262
254
  rows.each do |row|
263
255
  row_lines(row, name, selected, width, view.now, view.tick).each_with_index do |l, i|
264
256
  lines << l
265
- items << ((i.zero? && row.selectable?) ? Item.new(:row, row, name) : nil)
257
+ items << ((i.zero? && row.selectable?) ? Store::Selection.row(row.key) : nil)
266
258
  end
267
259
  end
268
260
  end
@@ -80,6 +80,16 @@ module ClaudeInbox
80
80
  # writing over the same files. Fall back to the repo it was cut from.
81
81
  def self.strip_worktree(cwd) = cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
82
82
 
83
+ # Where sessions ran lately, newest first, then every directory whose
84
+ # trust dialog was accepted: the likely choices first. Only an order;
85
+ # the CLI still decides where a session may start. Every worktree gets
86
+ # trusted on its own, so each folds into its repo rather than crowding
87
+ # the list.
88
+ def self.directories(sessions, trusted)
89
+ recent = sessions.sort_by { |s| -s.started_at.to_i }.filter_map(&:cwd)
90
+ (recent + trusted).map { |dir| strip_worktree(dir) }.uniq.select { |dir| File.directory?(dir) }
91
+ end
92
+
83
93
  # The daemon's summary line can echo the prompt to the terminal, so no
84
94
  # control character but tab and newline gets that far. A CRLF, the
85
95
  # newline an HTTP client may send, is taken as a newline.
@@ -13,7 +13,7 @@ module ClaudeInbox
13
13
  #
14
14
  # Pure: filesystem in, plain values out. Nothing here runs `claude`.
15
15
  module SlashCommands
16
- Command = Struct.new(:name, :description, :source) do
16
+ Command = Struct.new(:name, :description) do
17
17
  def to_s = "/#{name}"
18
18
  end
19
19
 
@@ -26,8 +26,8 @@ module ClaudeInbox
26
26
  def list(cwd:, home: Dir.home)
27
27
  found = {}
28
28
  add = ->(cmd) { found[cmd.name] ||= cmd }
29
- from_dir(File.join(cwd, ".claude"), "project").each(&add)
30
- from_dir(File.join(home, ".claude"), "user").each(&add)
29
+ from_dir(File.join(cwd, ".claude")).each(&add)
30
+ from_dir(File.join(home, ".claude")).each(&add)
31
31
  plugins(home).each(&add)
32
32
  synced(home).each(&add)
33
33
  found.values.sort_by(&:name)
@@ -39,25 +39,25 @@ module ClaudeInbox
39
39
  starts + rest.select { |c| c.name.downcase.include?(q) }
40
40
  end
41
41
 
42
- def from_dir(dir, source, prefix: nil)
43
- skills(File.join(dir, "skills"), source, prefix:) +
44
- commands(File.join(dir, "commands"), source, prefix:)
42
+ def from_dir(dir, prefix: nil)
43
+ skills(File.join(dir, "skills"), prefix:) +
44
+ commands(File.join(dir, "commands"), prefix:)
45
45
  end
46
46
 
47
- def skills(dir, source, prefix: nil)
47
+ def skills(dir, prefix: nil)
48
48
  Dir.glob(File.join(dir, "*", "SKILL.md")).sort.filter_map do |path|
49
49
  meta = frontmatter(path)
50
50
  next if meta["user-invocable"] == "false"
51
51
  name = File.basename(File.dirname(path))
52
- Command.new(qualify(prefix, name), meta["description"].to_s, source)
52
+ Command.new(qualify(prefix, name), meta["description"].to_s)
53
53
  end
54
54
  end
55
55
 
56
56
  # A command file's name is its own; a subdirectory only groups them.
57
- def commands(dir, source, prefix: nil)
57
+ def commands(dir, prefix: nil)
58
58
  Dir.glob(File.join(dir, "**", "*.md")).sort.map do |path|
59
59
  name = File.basename(path, ".md")
60
- Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s, source)
60
+ Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s)
61
61
  end
62
62
  end
63
63
 
@@ -71,7 +71,7 @@ module ClaudeInbox
71
71
  (installed["plugins"] || {}).flat_map do |key, entries|
72
72
  plugin = key.split("@").first
73
73
  Array(entries).filter_map { |e| e["installPath"] if e.is_a?(Hash) }.uniq.flat_map do |root|
74
- from_dir(root, "plugin", prefix: plugin)
74
+ from_dir(root, prefix: plugin)
75
75
  end
76
76
  end
77
77
  end
@@ -80,7 +80,7 @@ module ClaudeInbox
80
80
  # ~/.claude/skills/synced and answer to the anthropic-skills prefix.
81
81
  def synced(home)
82
82
  Dir.glob(File.join(home, ".claude", "skills", "synced", "*", "")).flat_map do |bucket|
83
- skills(bucket, "synced", prefix: SYNCED_PLUGIN)
83
+ skills(bucket, prefix: SYNCED_PLUGIN)
84
84
  end
85
85
  end
86
86
 
@@ -23,7 +23,7 @@ module ClaudeInbox
23
23
  # Called from the poller's threads and the screen's alike.
24
24
  def write(sections, now)
25
25
  return unless @path
26
- body = sections.to_h.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }
26
+ body = sections.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }
27
27
  @mutex.synchronize do
28
28
  return if body == @body && now.to_i - @written_at < HEARTBEAT
29
29
  @body = body
@@ -39,8 +39,7 @@ module ClaudeInbox
39
39
  pr = s.pr
40
40
  {
41
41
  "id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
42
- "waiting" => s.waiting_on_work? || nil, "terminal" => s.terminal? || nil,
43
- "cwd" => s.cwd, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
42
+ "waiting" => s.waiting_on_work? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
44
43
  "pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
45
44
  }.compact
46
45
  end
@@ -5,8 +5,6 @@ module ClaudeInbox
5
5
  # A session paired with its Entry (nil until a poll has recorded one), with
6
6
  # the triage rules as its methods; each takes `now`, so a Row is pure.
7
7
  Row = Struct.new(:session, :entry) do
8
- def id = session.id
9
-
10
8
  def key = session.key
11
9
 
12
10
  def selectable? = !key.nil?
@@ -16,11 +16,9 @@ module ClaudeInbox
16
16
  @path = path
17
17
  end
18
18
 
19
- def request(id, from, now) = @path && Records.save(@path, {"id" => id, "from" => from, "at" => now.to_i})
20
-
21
19
  # Whether the session `from` has asked to go elsewhere.
22
20
  def requested_for?(from)
23
- return false if @path.nil? || from.nil? || !File.exist?(@path)
21
+ return false if from.nil?
24
22
  Records.read(@path)["from"] == from
25
23
  end
26
24
 
@@ -76,6 +76,14 @@ module ClaudeInbox
76
76
  enter
77
77
  end
78
78
 
79
+ # Inside `release`: keeps what a failed child printed on the screen
80
+ # until a key is pressed, since `enter` would cover it at once.
81
+ def pause
82
+ @out.print "\r\npress any key to return to claude-inbox"
83
+ @out.flush
84
+ @input.tty? ? @input.getch : @input.getc
85
+ end
86
+
79
87
  # [cols, rows]. Cached: querying the terminal can fall back to spawning
80
88
  # `tput`, which is far too slow to do on every frame. Refreshed by
81
89
  # `resized`, which WINCH and re-entry both call.
@@ -37,13 +37,6 @@ module ClaudeInbox
37
37
  file.flock(File::LOCK_EX | File::LOCK_NB) ? hold(file, role) : refuse(file)
38
38
  end
39
39
 
40
- def held? = !@file.nil?
41
-
42
- def release
43
- @file&.close
44
- @file = nil
45
- end
46
-
47
40
  private
48
41
 
49
42
  # After reading the holder the position sits at the end, and truncate does
@@ -53,7 +46,7 @@ module ClaudeInbox
53
46
  file.truncate(0)
54
47
  file.write("#{role} #{Process.pid}")
55
48
  file.flush
56
- @file = file
49
+ @file = file # only kept so GC never closes it and drops the flock
57
50
  true
58
51
  end
59
52
 
data/lib/claude_inbox.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeInbox
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -33,8 +33,6 @@ export type SnapshotRow = {
33
33
  state?: string
34
34
  actionable?: boolean
35
35
  waiting?: boolean
36
- terminal?: boolean
37
- cwd?: string
38
36
  line?: string
39
37
  wake_at?: number | 'until_woken'
40
38
  remote?: string
@@ -43,7 +41,7 @@ export type SnapshotRow = {
43
41
  type Snapshot = { written_at?: number; sections?: Partial<Record<Section, SnapshotRow[]>> }
44
42
  // This session, as the gem's files name it, and the headless inbox this module may start.
45
43
  type Host = { session: string; command: string; headlessStartedAt: number; isHeadlessRunning: boolean }
46
- type Fold = 'snoozed' | 'settled'
44
+ type Fold = keyof Folds
47
45
  type Item = { kind: 'row'; row: InboxRow } | { kind: 'fold'; section: Fold; count: number }
48
46
  type Snooze = 'm15' | 'h1' | 'tomorrow_9am' | 'until_woken'
49
47
  type Action = { action: 'settle' | 'wake' | 'pin'; id: string } | { action: 'snooze'; id: string; choice: Snooze }
@@ -60,7 +58,7 @@ const SNOOZE: Record<string, { choice: Snooze; label: string }> = {
60
58
  '4': { choice: 'until_woken', label: 'until woken' },
61
59
  }
62
60
  // What each chord's second key does, drawn under the footer while it waits.
63
- const MENU: Record<'g' | 'z' | 's', [string, string][]> = {
61
+ const MENU: Record<NonNullable<Pending>, [string, string][]> = {
64
62
  g: [
65
63
  ['g', 'first'],
66
64
  ['e', 'last'],
@@ -82,7 +80,7 @@ function isFold(section: Section): section is Fold {
82
80
  return section === 'snoozed' || section === 'settled'
83
81
  }
84
82
 
85
- // The hidden Button standing for an item: the ring walks these with the arrows.
83
+ // The Button drawn for an item, its label: the ring walks these with the arrows, a click presses one.
86
84
  function itemKey(item: Item): string {
87
85
  return item.kind === 'fold' ? `fold:${item.section}` : `row:${item.row.id ?? item.row.label}`
88
86
  }
@@ -105,6 +103,15 @@ function groupsOf(list: InboxRow[], folded: Folds, current: InboxRow | null): {
105
103
  )
106
104
  }
107
105
 
106
+ async function listed($: EngineInterface): Promise<Item[]> {
107
+ return groupsOf(await read($, rows), await read($, folds), await read($, current)).flatMap(group => group.items)
108
+ }
109
+
110
+ // The stored cursor may point past a list that has shrunk since it was set.
111
+ function onList(flat: Item[], at: number): number {
112
+ return Math.min(at, Math.max(0, flat.length - 1))
113
+ }
114
+
108
115
  async function readJson<T>($: EngineInterface, path: string, fallback: T): Promise<T> {
109
116
  try {
110
117
  return JSON.parse(await $.fs.read(path)) as T
@@ -123,8 +130,6 @@ export function rowOf(section: Section, one: SnapshotRow): InboxRow {
123
130
  state: one.state ?? 'unknown',
124
131
  actionable: one.actionable === true,
125
132
  isWaiting: one.waiting === true,
126
- isTerminal: one.terminal === true,
127
- cwd: one.cwd,
128
133
  line: one.line,
129
134
  wakeAt: one.wake_at,
130
135
  remote: one.remote,
@@ -142,26 +147,34 @@ function ago(seconds: number): string {
142
147
  return seconds < 60 ? 'just now' : `${Math.floor(seconds / 60)}m ago`
143
148
  }
144
149
 
150
+ async function inboxFile($: EngineInterface, path: string): Promise<string> {
151
+ return `${(await $.env.get('HOME')) ?? ''}/${path}`
152
+ }
153
+
145
154
  async function nowS($: EngineInterface): Promise<number> {
146
155
  return Math.floor((await $.clock.now()) / 1000)
147
156
  }
148
157
 
158
+ // Read each time: it is the transcript's name, which the daemon reports as sessionId,
159
+ // and a /clear may mint a new one mid-session.
160
+ async function sessionId($: EngineInterface, host: Host): Promise<string> {
161
+ return $.session.id().catch(() => host.session)
162
+ }
163
+
149
164
  // Leaves one request for the inbox to apply; its poller takes it within half a second
150
165
  // and the next snapshot shows the row where the gem's own rules put it.
151
166
  async function ask($: EngineInterface, host: Host, action: Action): Promise<void> {
152
- const home = (await $.env.get('HOME')) ?? ''
167
+ const actions = await inboxFile($, ACTIONS)
153
168
  const at = Math.floor(await $.clock.now())
154
- await $.fs.write(`${home}/${ACTIONS}/${host.session}-${at}.json`, JSON.stringify({ ...action, at }))
169
+ await $.fs.write(`${actions}/${host.session}-${at}.json`, JSON.stringify({ ...action, at }))
155
170
  }
156
171
 
157
172
  // Asks the inbox attached to this session for another one. It takes the file when it
158
173
  // does; a file still there after SWITCH_MS, and still ours, means no inbox is attached to
159
- // this session. The session id is read each time: it is the transcript's name, which the
160
- // daemon reports as sessionId, and a /clear may mint a new one mid-session.
174
+ // this session.
161
175
  async function requestSwitch($: EngineInterface, host: Host, id: string): Promise<void> {
162
- const home = (await $.env.get('HOME')) ?? ''
163
- const path = `${home}/${SWITCH}`
164
- const from = await $.session.id().catch(() => host.session)
176
+ const path = await inboxFile($, SWITCH)
177
+ const from = await sessionId($, host)
165
178
  const at = await nowS($)
166
179
  await $.fs.write(path, JSON.stringify({ id, from, at }))
167
180
  $.ui.toast(`switching to ${id}…`)
@@ -194,14 +207,13 @@ function startHeadless($: EngineInterface, host: Host, now: number): void {
194
207
  }
195
208
 
196
209
  async function poll($: EngineInterface, host: Host): Promise<void> {
197
- const home = (await $.env.get('HOME')) ?? ''
210
+ const file = await inboxFile($, SNAPSHOT)
198
211
  const now = await nowS($)
199
- const snapshot = await readJson<Snapshot | null>($, `${home}/${SNAPSHOT}`, null)
212
+ const snapshot = await readJson<Snapshot | null>($, file, null)
200
213
  const written = snapshot?.written_at ?? 0
201
214
  if (snapshot !== null) {
202
215
  const all = ORDER.flatMap(section => (snapshot.sections?.[section] ?? []).map(one => rowOf(section, one)))
203
- // Read on every poll: a /clear mints a new session id mid-session.
204
- const id = await $.session.id().catch(() => host.session)
216
+ const id = await sessionId($, host)
205
217
  await update($, current, () => all.find(one => one.session === id) ?? null)
206
218
  await update($, rows, () => all.filter(one => one.session !== id))
207
219
  }
@@ -209,7 +221,7 @@ async function poll($: EngineInterface, host: Host): Promise<void> {
209
221
  if (now - written > STALE_S) startHeadless($, host, now)
210
222
  }
211
223
 
212
- // Puts the ring on the item's hidden Button, so Enter acts on it, and scrolls it into view.
224
+ // Puts the ring on the item's Button, so Enter acts on it, and scrolls it into view.
213
225
  async function ringOn($: EngineInterface, item: Item): Promise<void> {
214
226
  await $.ui.focus({ requestId: PANE, key: itemKey(item) }).catch(() => undefined)
215
227
  await $.ui.scroll({ in: PANE, to: { key: viewKey(item) }, block: 'nearest' }).catch(() => undefined)
@@ -227,7 +239,7 @@ async function moveTo($: EngineInterface, flat: Item[], to: number): Promise<voi
227
239
  async function setFold($: EngineInterface, list: InboxRow[], current: InboxRow | null, name: Fold | undefined, open: boolean): Promise<void> {
228
240
  await update($, pending, () => null)
229
241
  if (!name) return
230
- const folded = await update($, folds, f => ({ ...(f ?? { snoozed: true, settled: true }), [name]: !open }))
242
+ const folded = await update($, folds, f => ({ ...f, [name]: !open }))
231
243
  // The item under the cursor may have just become a fold, or the fold its rows.
232
244
  const groups = groupsOf(list, folded, current)
233
245
  const at = groups.findIndex(group => group.section === name)
@@ -265,12 +277,12 @@ export const register: Register = (on, options) => {
265
277
  return { text: `Inbox pane open on ${surfaces}. ctrl+x tab focuses it; the keys are claude-inbox's.` }
266
278
  })
267
279
 
268
- // The ring walks the items' hidden Buttons and nothing else: entering the pane it lands on
280
+ // The ring walks the items' Buttons and nothing else: entering the pane it lands on
269
281
  // the cursor's item, the arrows move it to the next, and the cursor follows.
270
282
  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
271
- const flat = groupsOf(await read($, rows), await read($, folds), await read($, current)).flatMap(group => group.items)
283
+ const flat = await listed($)
272
284
  if (e.element === undefined) {
273
- const item = flat[Math.min(await read($, cursor), Math.max(0, flat.length - 1))]
285
+ const item = flat[onList(flat, await read($, cursor))]
274
286
  return item ? next({ ...e, element: itemKey(item) }) : next(e)
275
287
  }
276
288
  const at = flat.findIndex(item => itemKey(item) === e.element)
@@ -284,8 +296,8 @@ export const register: Register = (on, options) => {
284
296
  // moves the cursor instead, and the wheel, the page keys and the pane's own scrolls pass.
285
297
  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
286
298
  if (e.origin.kind !== 'person' || e.pointer !== undefined || Math.abs(e.by) !== 1) return next(e)
287
- const flat = groupsOf(await read($, rows), await read($, folds), await read($, current)).flatMap(group => group.items)
288
- const at = Math.min(await read($, cursor), Math.max(0, flat.length - 1))
299
+ const flat = await listed($)
300
+ const at = onList(flat, await read($, cursor))
289
301
  await moveTo($, flat, at + e.by)
290
302
  return {}
291
303
  })
@@ -300,7 +312,7 @@ export const register: Register = (on, options) => {
300
312
  const ageS = (await nowS($)) - written
301
313
  const groups = groupsOf(list, folded, viewing)
302
314
  const flat = groups.flatMap(group => group.items)
303
- const at = Math.min(await read($, cursor), Math.max(0, flat.length - 1))
315
+ const at = onList(flat, await read($, cursor))
304
316
  const selected = flat[at]
305
317
  const row = selected?.kind === 'row' ? selected.row : undefined
306
318
  const section = selected?.kind === 'fold' ? selected.section : row === viewing ? undefined : row?.section
@@ -329,8 +341,10 @@ export const register: Register = (on, options) => {
329
341
  void ask($, host, { action, id: row.id }).then(() => $.ui.toast(`${verb} ${row.label}`))
330
342
  }
331
343
  const key = (hotkey: string, onPress: () => void) => <Button key={hotkey} hotkey={hotkey} plain label="" onPress={onPress} />
332
- // Enter on an item: a fold opens, a row the inbox can attach to asks for the switch.
344
+ // Enter or a click on an item: the cursor lands on it, a fold opens, a row the inbox can
345
+ // attach to asks for the switch.
333
346
  const enter = (item: Item) => () => {
347
+ void moveTo($, flat, flat.indexOf(item))
334
348
  if (item.kind === 'fold') return void setFold($, list, viewing, item.section, true)
335
349
  if (item.row === viewing) return $.ui.toast('you are in this session')
336
350
  if (!item.row.id || !item.row.actionable) return $.ui.toast('this session cannot be attached')
@@ -383,8 +397,8 @@ export const register: Register = (on, options) => {
383
397
 
384
398
  return (
385
399
  <Box flexDirection="column">
386
- {/* The header shares the pane's top row with the engine's close mark, which takes the last cells. */}
387
- <Box columnGap={2} paddingRight={3}>
400
+ {/* The engine draws its close mark on a row of its own above the body, so the header can't share it. */}
401
+ <Box columnGap={2}>
388
402
  <Text color="cyan" bold>
389
403
  claude-inbox
390
404
  </Text>
@@ -408,7 +422,7 @@ export const register: Register = (on, options) => {
408
422
  return (
409
423
  <Box key={viewKey(item)}>
410
424
  <Text color={isHere ? 'yellow' : undefined}>{isHere ? '› ' : ' '}</Text>
411
- <Text dimColor>▸ {item.count} folded · za opens</Text>
425
+ <Button key={itemKey(item)} plain dimColor label={`▸ ${item.count} folded · za opens`} onPress={enter(item)} autoFocus={isHere || undefined} />
412
426
  </Box>
413
427
  )
414
428
  }
@@ -421,15 +435,25 @@ export const register: Register = (on, options) => {
421
435
  <Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
422
436
  {GLYPH[one.state] ?? '·'}{' '}
423
437
  </Text>
424
- <Text bold={isHere} dimColor={isQuiet && !isHere} wrap="truncate-end">
425
- {one.label}
426
- {one.wakeAt !== undefined ? ` · ${wakeLabel(one.wakeAt)}` : ''}
427
- </Text>
438
+ {/* A Button cannot truncate, so its Box clips a long label to the row. */}
439
+ <Box height={1} flexShrink={1} overflow="hidden">
440
+ <Button
441
+ key={itemKey(item)}
442
+ plain
443
+ dimColor={isQuiet && !isHere}
444
+ label={one.wakeAt !== undefined ? `${one.label} · ${wakeLabel(one.wakeAt)}` : one.label}
445
+ onPress={enter(item)}
446
+ autoFocus={isHere || undefined}
447
+ />
448
+ </Box>
449
+ {/* The label gives up its cells to the PR chip, which would otherwise wrap under it. */}
428
450
  {one.pr ? (
429
- <Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
430
- {' '}
431
- {one.pr.short} {one.pr.state}
432
- </Text>
451
+ <Box flexShrink={0}>
452
+ <Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
453
+ {' '}
454
+ {one.pr.short} {one.pr.state}
455
+ </Text>
456
+ </Box>
433
457
  ) : null}
434
458
  </Box>
435
459
  {!isQuiet && one.line !== undefined ? (
@@ -457,11 +481,8 @@ export const register: Register = (on, options) => {
457
481
  </Box>
458
482
  )}
459
483
  </Box>
460
- {/* Drawn nowhere: one Button per item for the ring and Enter, then the hotkeys. */}
484
+ {/* Drawn nowhere: the hotkeys. */}
461
485
  <Box height={0} overflow="hidden">
462
- {flat.map(item => (
463
- <Button key={itemKey(item)} plain label="" onPress={enter(item)} autoFocus={item === selected ? true : undefined} />
464
- ))}
465
486
  {keys}
466
487
  </Box>
467
488
  </Box>
@@ -17,7 +17,5 @@ test("the rows the pane reads are the ones the gem's Snapshot writes", () => {
17
17
  ['settled', 'c3', 'put away', 'done', true],
18
18
  ])
19
19
  expect(rows[1]?.pr).toEqual({ short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' })
20
- expect(rows[0]?.cwd).toBe('/tmp/proj')
21
20
  expect(rows[0]?.isWaiting).toBe(false)
22
- expect(rows[0]?.isTerminal).toBe(false)
23
21
  })
@@ -12,7 +12,7 @@ const PANE = {
12
12
  const ROWS = [
13
13
  { section: 'needs_you', id: 'a1', label: 'needs an answer', state: 'blocked', actionable: true, line: 'confirm: drop it?' },
14
14
  { section: 'active', id: 'b2', label: 'still going', state: 'working', actionable: true, line: 'running bin/ci', pr: { short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' } },
15
- { section: 'active', id: 'uuid-term', label: 'a terminal', state: 'working', actionable: false, isTerminal: true },
15
+ { section: 'active', id: 'uuid-term', label: 'a terminal', state: 'working', actionable: false },
16
16
  { section: 'settled', id: 'c3', label: 'put away', state: 'done', actionable: true },
17
17
  ]
18
18
 
@@ -78,18 +78,18 @@ test("rows sit under the gem's sections, Settled folded until za opens it", asyn
78
78
  expect(await ui.find({ type: 'Text', text: /Settled · 1/ })).toBeDefined()
79
79
  expect(await ui.find({ type: 'Text', text: /confirm: drop it\?/ })).toBeDefined()
80
80
  expect(await ui.find({ type: 'Text', text: /#7 open/ })).toBeDefined()
81
- expect(await ui.find({ type: 'Text', text: /1 folded/ })).toBeDefined()
82
- expect(await ui.find({ type: 'Text', text: /put away/ })).toBeUndefined()
81
+ expect(await ui.find({ text: /1 folded/ })).toBeDefined()
82
+ expect(await ui.find({ text: /put away/ })).toBeUndefined()
83
83
  await ui.press({ key: 'g' })
84
84
  await ui.press({ key: 'e' })
85
85
  await ui.press({ key: 'z' })
86
86
  expect(await ui.find({ type: 'Button', key: 'x' })).toBeUndefined()
87
87
  await ui.press({ key: 'a' })
88
- expect(await ui.find({ type: 'Text', text: /put away/ })).toBeDefined()
88
+ expect(await ui.find({ text: /put away/ })).toBeDefined()
89
89
  expect(await ui.find({ type: 'Button', key: 'x' })).toBeDefined()
90
90
  await ui.press({ key: 'z' })
91
91
  await ui.press({ key: 'c' })
92
- expect(await ui.find({ type: 'Text', text: /put away/ })).toBeUndefined()
92
+ expect(await ui.find({ text: /put away/ })).toBeUndefined()
93
93
  // Cursor and folds live in session state, so put them back for the next surface.
94
94
  await ui.press({ key: 'g' })
95
95
  await ui.press({ key: 'g' })
@@ -114,7 +114,7 @@ test('the session the pane sits in leads the list under This session, with its o
114
114
  })
115
115
  const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
116
116
  expect(await ui.find({ type: 'Text', text: /^This session$/ })).toBeDefined()
117
- expect(await ui.find({ type: 'Text', text: /needs an answer/ })).toBeDefined()
117
+ expect(await ui.find({ text: /needs an answer/ })).toBeDefined()
118
118
  expect(await ui.find({ type: 'Text', text: /Needs you/ })).toBeUndefined()
119
119
  await ui.press({ key: 'row:a1' })
120
120
  expect(written).toEqual([])
@@ -172,6 +172,24 @@ test('Enter asks the attached inbox for the row, naming this session, and refuse
172
172
  await ui.unmount()
173
173
  })
174
174
 
175
+ test('a click on a row puts the cursor on it before it asks for the switch', async ($, on) => {
176
+ mock.env(on, { HOME: '/Users/me' })
177
+ mock.clock(on, { now: 1_700_000_000_000 })
178
+ on('state.get', ($, e, next) => (e.key === 'rows' ? seeded(ROWS) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
179
+ const written: { path: string; text: string }[] = []
180
+ on('fs.write', ($, e) => {
181
+ written.push({ path: e.path, text: e.text })
182
+ return { value: undefined }
183
+ })
184
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
185
+ expect((await ui.find({ key: 'row:b2' }))?.props.label).toBe('still going')
186
+ await ui.press({ key: 'row:b2' })
187
+ await ui.press({ key: 'x' })
188
+ expect(written.map(one => one.path)).toEqual(['/Users/me/.config/claude-inbox/switch.json', '/Users/me/.config/claude-inbox/actions/pane-1700000000000.json'])
189
+ expect(JSON.parse(written[1]?.text ?? '{}')).toMatchObject({ action: 'settle', id: 'b2' })
190
+ await ui.unmount()
191
+ })
192
+
175
193
  test('an unfocused pane says how to focus it', async ($, on) => {
176
194
  mock.env(on, { HOME: '/Users/me' })
177
195
  mock.clock(on, { now: 1_700_000_000_000 })
@@ -13,8 +13,7 @@ export const SNAPSHOT = `{
13
13
  "session": "u-a1",
14
14
  "label": "needs an answer",
15
15
  "state": "blocked",
16
- "actionable": true,
17
- "cwd": "/tmp/proj"
16
+ "actionable": true
18
17
  }
19
18
  ],
20
19
  "active": [
@@ -24,7 +23,6 @@ export const SNAPSHOT = `{
24
23
  "label": "still going",
25
24
  "state": "working",
26
25
  "actionable": true,
27
- "cwd": "/tmp/proj",
28
26
  "pr": {
29
27
  "short": "#7",
30
28
  "state": "open",
@@ -39,8 +37,7 @@ export const SNAPSHOT = `{
39
37
  "session": "u-c3",
40
38
  "label": "put away",
41
39
  "state": "done",
42
- "actionable": true,
43
- "cwd": "/tmp/proj"
40
+ "actionable": true
44
41
  }
45
42
  ]
46
43
  }
data/mod/types/index.d.ts CHANGED
@@ -10,8 +10,6 @@ export type InboxRow = {
10
10
  state: string
11
11
  actionable: boolean
12
12
  isWaiting: boolean
13
- isTerminal: boolean
14
- cwd?: string
15
13
  line?: string
16
14
  wakeAt?: number | 'until_woken'
17
15
  remote?: string
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-inbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron
@@ -128,7 +128,6 @@ files:
128
128
  - lib/claude_inbox/rate_limits.rb
129
129
  - lib/claude_inbox/reaper.rb
130
130
  - lib/claude_inbox/records.rb
131
- - lib/claude_inbox/remote.rb
132
131
  - lib/claude_inbox/remote/http.rb
133
132
  - lib/claude_inbox/remote/icon.png
134
133
  - lib/claude_inbox/remote/icon.svg
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ClaudeInbox
4
- # Starting sessions from another device. App reaches it only through
5
- # Remote::Listener.
6
- module Remote
7
- end
8
- end
9
-
10
- require_relative "remote/listener"