claude-inbox 0.3.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +93 -165
  3. data/exe/claude-inbox +49 -4
  4. data/lib/claude_inbox/actions.rb +91 -0
  5. data/lib/claude_inbox/agents_client.rb +15 -10
  6. data/lib/claude_inbox/app.rb +58 -18
  7. data/lib/claude_inbox/headless.rb +30 -0
  8. data/lib/claude_inbox/job_state.rb +4 -4
  9. data/lib/claude_inbox/keymap.rb +0 -2
  10. data/lib/claude_inbox/mod.rb +71 -0
  11. data/lib/claude_inbox/new_session_form.rb +67 -31
  12. data/lib/claude_inbox/painter.rb +3 -6
  13. data/lib/claude_inbox/paste.rb +2 -0
  14. data/lib/claude_inbox/poller.rb +46 -25
  15. data/lib/claude_inbox/pull_requests.rb +32 -18
  16. data/lib/claude_inbox/rate_limits.rb +2 -4
  17. data/lib/claude_inbox/reaper.rb +1 -3
  18. data/lib/claude_inbox/remote/http.rb +11 -8
  19. data/lib/claude_inbox/remote/listener.rb +4 -9
  20. data/lib/claude_inbox/remote/start.rb +4 -11
  21. data/lib/claude_inbox/renderer.rb +8 -16
  22. data/lib/claude_inbox/session_request.rb +10 -0
  23. data/lib/claude_inbox/settings.rb +1 -0
  24. data/lib/claude_inbox/slash_commands.rb +13 -12
  25. data/lib/claude_inbox/snapshot.rb +47 -0
  26. data/lib/claude_inbox/store/entry.rb +0 -2
  27. data/lib/claude_inbox/store/row.rb +7 -5
  28. data/lib/claude_inbox/store.rb +24 -9
  29. data/lib/claude_inbox/switch.rb +38 -0
  30. data/lib/claude_inbox/terminal.rb +8 -0
  31. data/lib/claude_inbox/text_buffer.rb +84 -33
  32. data/lib/claude_inbox/writer_lock.rb +58 -0
  33. data/lib/claude_inbox.rb +1 -10
  34. data/mod/.claude-plugin/plugin.json +20 -0
  35. data/mod/hooks/hooks.json +1 -0
  36. data/mod/hooks/register.tsx +491 -0
  37. data/mod/tests/contract.test.tsx +21 -0
  38. data/mod/tests/pane.test.tsx +217 -0
  39. data/mod/tests/snapshot.fixture.ts +44 -0
  40. data/mod/tests/snooze.test.tsx +31 -0
  41. data/mod/tsconfig.json +3 -0
  42. data/mod/types/index.d.ts +28 -0
  43. metadata +16 -2
  44. data/lib/claude_inbox/remote.rb +0 -10
@@ -4,45 +4,41 @@ require_relative "sessions"
4
4
 
5
5
  module ClaudeInbox
6
6
  # Asks `claude agents` for the list off the main thread, every INTERVAL
7
- # seconds and on demand, and hands what it finds to App over the shared
8
- # queue: [:sessions, list] for each hand-over, [:error, msg] when a poll
9
- # fails, [:notice, text] when the reaper took something. Nothing here
10
- # touches App's state directly; the queue is the whole of the interface.
11
- # One worker runs every poll, so two can never overlap and publish the
12
- # list out of order.
7
+ # seconds and on demand, puts what it finds in the store and the snapshot,
8
+ # and tells App over the shared queue: [:sessions, list] for each
9
+ # hand-over, [:error, msg] when a poll fails, [:notice, text] when the
10
+ # reaper took something. Between polls it applies what another front end
11
+ # asked for. Nothing here touches App's state directly; the queue is the
12
+ # whole of the interface. One worker runs every poll, so two can never
13
+ # overlap and publish the list out of order.
13
14
  class Poller
14
15
  INTERVAL = 4
16
+ ACTIONS_INTERVAL = 0.5
15
17
 
16
- def initialize(client:, store:, pull_requests:, reaper:, queue:, interval: INTERVAL, clock: -> { Time.now })
18
+ def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
19
+ interval: INTERVAL, clock: -> { Time.now })
17
20
  @client = client
18
21
  @clock = clock
19
22
  @store = store
20
23
  @pull_requests = pull_requests
21
24
  @reaper = reaper
22
25
  @queue = queue
26
+ @snapshot = snapshot
27
+ @actions = actions
23
28
  @interval = interval
24
29
  @wake = Queue.new
25
- @paused = false
26
- @lock = Mutex.new
27
30
  end
28
31
 
29
32
  def start
30
33
  return if @thread&.alive?
31
34
  soon
32
35
  @thread = Thread.new { worker }
36
+ @applier = Thread.new { applier }
33
37
  end
34
38
 
35
- def stop = @thread&.kill
36
-
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
- def pause = @lock.synchronize { @paused = true }
42
-
43
- def resume
44
- @lock.synchronize { @paused = false }
45
- soon
39
+ def stop
40
+ @thread&.kill
41
+ @applier&.kill
46
42
  end
47
43
 
48
44
  def soon = @wake << true
@@ -59,35 +55,60 @@ module ClaudeInbox
59
55
  sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
60
56
  doomed = @reaper.due(sessions, now)
61
57
  @store.hide(doomed)
62
- @queue << [:sessions, sessions]
58
+ publish(sessions)
63
59
  reaped = []
64
60
  begin
65
61
  reaped = @reaper.sweep(sessions.select { |s| doomed.include?(s.key) }, now)
66
62
  ensure
67
63
  @store.release(doomed - reaped)
68
64
  end
69
- @queue << [:sessions, sessions] if reaped != doomed
65
+ publish(sessions) if reaped != doomed
70
66
  @queue << [:notice, @reaper.report(reaped)] if reaped.any?
71
67
  fresh, moved = @pull_requests.refresh(sessions)
72
- @queue << [:sessions, fresh] if moved
68
+ publish(fresh) if moved
73
69
  rescue => e
74
70
  @queue << [:error, e.message]
75
71
  end
76
72
 
73
+ # Applies the requests another front end left, and says whether any landed.
74
+ def apply
75
+ return false unless @actions.drain(@store) > 0
76
+ write_snapshot
77
+ true
78
+ end
79
+
77
80
  private
78
81
 
82
+ def publish(sessions)
83
+ @store.update(sessions)
84
+ write_snapshot
85
+ @queue << [:sessions, sessions]
86
+ end
87
+
88
+ def write_snapshot
89
+ now = @clock.call
90
+ @snapshot.write(@store.sections(now), now)
91
+ end
92
+
79
93
  # Past StandardError `once` does not catch, and a dead worker would end
80
94
  # polling with nothing on screen to say so.
81
95
  def worker
82
96
  loop do
83
97
  @wake.pop(timeout: @interval)
84
98
  @wake.clear
85
- once unless paused?
99
+ once
86
100
  rescue SystemStackError, ScriptError, SecurityError => e
87
101
  @queue << [:error, e.message]
88
102
  end
89
103
  end
90
104
 
91
- def paused? = @lock.synchronize { @paused }
105
+ def applier
106
+ loop do
107
+ sleep ACTIONS_INTERVAL
108
+ apply
109
+ rescue => e
110
+ @queue << [:error, e.message]
111
+ end
112
+ end
92
113
  end
93
114
  end
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
  require_relative "records"
5
6
  require_relative "subprocess"
6
7
 
7
8
  module ClaudeInbox
8
9
  # A pull request tied to a session. `state` uses GitHub's vocabulary plus
9
10
  # DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
10
- # or nil when nothing has told us yet.
11
- PullRequest = Struct.new(:number, :url, :state, :title) do
11
+ # or nil when nothing has told us yet. `resolved_at` is when it was merged
12
+ # or closed, in epoch seconds, once gh has said.
13
+ PullRequest = Struct.new(:number, :url, :state, :title, :resolved_at) do
12
14
  def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
13
15
 
14
16
  def merged? = state == "MERGED"
@@ -19,6 +21,16 @@ module ClaudeInbox
19
21
  def resolved? = merged? || closed?
20
22
 
21
23
  def known? = !state.nil?
24
+
25
+ # Nothing more to learn: resolved, and gh has said when.
26
+ def final? = resolved? && !resolved_at.nil?
27
+
28
+ # Resolved before `time`, so a session started then can only have mentioned it.
29
+ def resolved_before?(time) = final? && resolved_at < time.to_i
30
+
31
+ # Resolved at or after `time`, which is the only kind of resolution that
32
+ # can answer a session started then.
33
+ def resolved_since?(time) = final? && resolved_at >= time.to_i
22
34
  end
23
35
 
24
36
  # Finds the PRs a session is tied to and keeps their state fresh.
@@ -34,11 +46,10 @@ module ClaudeInbox
34
46
  # State comes first from our own record of resolved PRs, then from
35
47
  # ~/.claude/gh-pr-status-cache.json (whatever Claude Code last saw), then
36
48
  # from `gh pr view` for PRs that are still open, at most once per
37
- # REFRESH_AFTER. A merged or closed PR never changes again, so it is never
38
- # asked about twice — and once gh has said so, it is written to
39
- # RESOLVED_PATH so the next launch does not ask either. Claude Code's cache
40
- # only covers PRs its own sessions opened, and a link scan picks up plenty
41
- # of others.
49
+ # REFRESH_AFTER. A merged or closed PR never changes again, so once gh has
50
+ # said so, and when, it is written to RESOLVED_PATH and never asked about
51
+ # again. Claude Code's cache never says when, and says nothing at all about
52
+ # most of a link scan's PRs: it only covers PRs its own sessions opened.
42
53
  #
43
54
  # `enrich` never touches gh; it is what stands between `claude agents` and
44
55
  # the first frame. `refresh` is the slow half, one network round trip per
@@ -65,7 +76,7 @@ module ClaudeInbox
65
76
  # one (interactive, or forgotten) has none; Sessions.load sees to the
66
77
  # order. `overrides` maps session key => url for links set by hand; an
67
78
  # override replaces the scanned list.
68
- def enrich(sessions, overrides = {})
79
+ def enrich(sessions, overrides)
69
80
  sessions.map do |s|
70
81
  urls = overrides[s.key] ? [overrides[s.key]] : (s.job_state&.pr_urls || [])
71
82
  s.with(prs: urls.map { |u| known(u) })
@@ -73,13 +84,14 @@ module ClaudeInbox
73
84
  end
74
85
 
75
86
  # Asks gh about every PR on these sessions that is due. Returns the
76
- # sessions with the answers on their `prs` and whether any state changed,
77
- # so the caller knows whether the list is worth publishing again.
87
+ # sessions with the answers on their `prs` and whether any PR changed, so
88
+ # the caller knows whether the list is worth publishing again. A merge
89
+ # date alone counts: it is what lets a PR the cache called merged settle.
78
90
  def refresh(sessions)
79
91
  changed = false
80
92
  fresh = sessions.map do |s|
81
93
  s.with(prs: s.prs.map { |pr|
82
- status(pr.url).tap { |now| changed = true if now.state != pr.state }
94
+ status(pr.url).tap { |now| changed = true if now != pr }
83
95
  })
84
96
  end
85
97
  [fresh, changed]
@@ -89,11 +101,11 @@ module ClaudeInbox
89
101
  def status(url)
90
102
  @mutex.synchronize do
91
103
  pr = @known[url] ||= seed(url)
92
- return pr if pr.resolved? || !due?(url)
104
+ return pr if pr.final? || !due?(url)
93
105
  @checked_at[url] = @clock.call.to_i
94
106
  fresh = fetch(url)
95
107
  return pr unless fresh
96
- remember(fresh) if fresh.resolved?
108
+ remember(fresh) if fresh.final?
97
109
  @known[url] = fresh
98
110
  end
99
111
  end
@@ -102,8 +114,9 @@ module ClaudeInbox
102
114
  def self.parse(url, json)
103
115
  h = JSON.parse(json)
104
116
  state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
105
- PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"])
106
- rescue JSON::ParserError
117
+ resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
118
+ PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"], resolved_at: resolved_at)
119
+ rescue JSON::ParserError, ArgumentError
107
120
  nil
108
121
  end
109
122
 
@@ -126,7 +139,8 @@ module ClaudeInbox
126
139
  def seed(url)
127
140
  number = url[%r{/pull/(\d+)}, 1]&.to_i
128
141
  cached = resolved[url] || claude_cache[url]
129
- PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"), title: cached&.dig("title"))
142
+ PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
143
+ title: cached&.dig("title"), resolved_at: cached&.dig("resolved_at"))
130
144
  end
131
145
 
132
146
  # Same shape as Claude Code's cache, so `seed` reads both alike.
@@ -140,13 +154,13 @@ module ClaudeInbox
140
154
 
141
155
  # Under @mutex.
142
156
  def remember(pr)
143
- resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title}
157
+ resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title, "resolved_at" => pr.resolved_at}
144
158
  return unless @resolved_path
145
159
  Records.save(@resolved_path, resolved)
146
160
  end
147
161
 
148
162
  def fetch(url)
149
- r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url")
163
+ r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url,closedAt")
150
164
  r.success? ? self.class.parse(url, r.out) : nil
151
165
  rescue SystemCallError
152
166
  nil
@@ -18,9 +18,7 @@ module ClaudeInbox
18
18
  LABELS = {"five_hour" => "session", "seven_day" => "week"}.freeze
19
19
 
20
20
  # `resets_at` is when the window rolls over, or nil if the file omits it.
21
- Window = Data.define(:label, :percent, :resets_at) do
22
- def initialize(label:, percent:, resets_at: nil) = super
23
- end
21
+ Window = Data.define(:label, :percent, :resets_at)
24
22
 
25
23
  def initialize(path: DEFAULT_PATH)
26
24
  @path = path
@@ -32,7 +30,7 @@ module ClaudeInbox
32
30
  # time, or nil.
33
31
  # Parses only when the file has changed, since render asks several times
34
32
  # a second.
35
- def windows(now = Time.now)
33
+ def windows(now)
36
34
  mtime = File.mtime(@path)
37
35
  return nil if now - mtime > STALE_AFTER
38
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)
@@ -32,7 +32,7 @@ module ClaudeInbox
32
32
  # the JSON body, and the headers that status calls for. `note`, when
33
33
  # set, is what the refusal is logged as in place of its message.
34
34
  class Error < StandardError
35
- attr_reader :status, :headers, :details, :note
35
+ attr_reader :status, :headers, :note
36
36
 
37
37
  def initialize(status, message, headers: {}, note: nil, **details)
38
38
  super(message)
@@ -43,6 +43,10 @@ module ClaudeInbox
43
43
  end
44
44
 
45
45
  def body = {error: Http.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Http.utf8(v) }}
46
+
47
+ private
48
+
49
+ attr_reader :details
46
50
  end
47
51
 
48
52
  # A request head. `headers` are keyed in lower case, and a repeated
@@ -50,12 +54,11 @@ module ClaudeInbox
50
54
  # pass for the first. The bytes read past the head stay here for the
51
55
  # body.
52
56
  class Request
53
- attr_reader :verb, :path, :query, :version, :headers
57
+ attr_reader :verb, :path, :headers
54
58
 
55
- def initialize(verb, target, version, headers, rest)
59
+ def initialize(verb, target, headers, rest)
56
60
  @verb = verb
57
- @path, @query = target.split("?", 2)
58
- @version = version
61
+ @path = target.split("?", 2).first
59
62
  @headers = headers
60
63
  @rest = rest
61
64
  end
@@ -88,13 +91,13 @@ module ClaudeInbox
88
91
  def self.read_head(io, deadline:)
89
92
  rest = +"".b
90
93
  line = read_line(io, rest, deadline)
91
- request = line.match(%r{\A(\S+) (/\S*) HTTP/(1\.[01])\z})
94
+ request = line.match(%r{\A(\S+) (/\S*) HTTP/1\.[01]\z})
92
95
  raise Error.new(400, "not an HTTP/1.x request line") unless request && request[1].match?(TOKEN)
93
96
  headers = {}
94
97
  count = 0
95
98
  loop do
96
99
  line = read_line(io, rest, deadline)
97
- return Request.new(request[1], request[2], request[3], headers, rest) if line.empty?
100
+ return Request.new(request[1], request[2], headers, rest) if line.empty?
98
101
  raise Error.new(400, "more than #{MAX_HEADERS} headers") if (count += 1) > MAX_HEADERS
99
102
  name, value = line.split(":", 2)
100
103
  raise Error.new(400, "malformed header") unless value && name.match?(TOKEN)
@@ -104,7 +107,7 @@ module ClaudeInbox
104
107
  end
105
108
  end
106
109
 
107
- def self.write(io, status, headers = {}, body = "")
110
+ def self.write(io, status, headers, body)
108
111
  body = body.b
109
112
  head = ["HTTP/1.1 #{status} #{REASONS.fetch(status)}"]
110
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.
@@ -109,7 +105,7 @@ module ClaudeInbox
109
105
  def self.disabled = new(client: nil, store: nil, queue: nil, pairing: nil, port: nil)
110
106
 
111
107
  # `start_options` are Start's own.
112
- def initialize(client:, store:, queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
108
+ def initialize(queue:, port:, pairing: Pairing.new, lan: false, allowed_modes: DEFAULT_MODES,
113
109
  lock_path: LOCK_PATH, fixture: false, retry_every: RETRY_EVERY, **start_options)
114
110
  @queue = queue
115
111
  @pairing = pairing
@@ -119,8 +115,7 @@ module ClaudeInbox
119
115
  @lock_path = lock_path
120
116
  @fixture = fixture
121
117
  @retry_every = retry_every
122
- @start = Start.new(client: client, store: store, queue: queue, allowed_modes: allowed_modes,
123
- fixture: fixture, **start_options)
118
+ @start = Start.new(queue: queue, allowed_modes: allowed_modes, fixture: fixture, **start_options)
124
119
  @mutex = Mutex.new
125
120
  @state = :off
126
121
  @recent = []
@@ -26,15 +26,14 @@ 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,
30
- jobs_dir: JobState::DEFAULT_DIR, trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
29
+ def initialize(client:, store:, queue:, allowed_modes:, fixture:, images_dir: Images::DEFAULT_DIR,
30
+ trust: Trust.method(:projects), settings: Settings.method(:defaults), bridge_wait: 3)
31
31
  @client = client
32
32
  @store = store
33
33
  @queue = queue
34
34
  @allowed_modes = allowed_modes
35
35
  @fixture = fixture
36
36
  @images_dir = images_dir
37
- @jobs_dir = jobs_dir
38
37
  @trust = trust
39
38
  @settings = settings
40
39
  @bridge_wait = bridge_wait
@@ -63,13 +62,7 @@ module ClaudeInbox
63
62
  }
64
63
  end
65
64
 
66
- # Where sessions ran lately, newest first, then every directory whose
67
- # trust dialog was accepted: the likely choices first. Only an order;
68
- # the CLI still decides where a session may start.
69
- def dir_paths
70
- recent = @store.sessions.sort_by { |s| -s.started_at.to_i }.filter_map { |s| s.cwd && SessionRequest.strip_worktree(s.cwd) }
71
- (recent + @trust.call).uniq.select { |dir| File.directory?(dir) }
72
- end
65
+ def dir_paths = SessionRequest.directories(@store.sessions, @trust.call)
73
66
 
74
67
  # Checked cheapest first, and nothing is written to disk until every
75
68
  # check has passed. A retry with the same Idempotency-Key gets the
@@ -192,7 +185,7 @@ module ClaudeInbox
192
185
  def remote_url(id, wait:)
193
186
  deadline = Http.monotonic + wait
194
187
  loop do
195
- url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @jobs_dir)).remote_url
188
+ url = Session.new(id: id, job_state: JobState.read(id, jobs_dir: @client.jobs_dir)).remote_url
196
189
  return url if url || Http.monotonic >= deadline
197
190
  sleep 0.2
198
191
  end
@@ -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
@@ -81,14 +75,14 @@ module ClaudeInbox
81
75
 
82
76
  def frame(sections, view)
83
77
  return full_screen(sections, view) if view.screen
84
- width, height, now, selected = view.width, view.height, view.now, view.selected
78
+ width, height, selected = view.width, view.height, view.selected
85
79
  list_w = width_for_list(width, view.peek)
86
80
  view_h = Renderer.body_height(height)
87
81
  body, items =
88
82
  if view.loading
89
83
  [loading_state(list_w, view_h, view.loading, view.tick), []]
90
84
  else
91
- body_lines(sections, list_w, view, now)
85
+ body_lines(sections, list_w, view)
92
86
  end
93
87
 
94
88
  top = clamp_top(view.top, body.size, view_h, items, selected)
@@ -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} "
@@ -243,7 +235,7 @@ module ClaudeInbox
243
235
 
244
236
  # ----- body ---------------------------------------------------------------
245
237
 
246
- def body_lines(sections, width, view, now)
238
+ def body_lines(sections, width, view)
247
239
  lines = []
248
240
  items = []
249
241
  if sections.all.empty?
@@ -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
- row_lines(row, name, selected, width, now, view.tick).each_with_index do |l, i|
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.
@@ -47,5 +47,6 @@ module ClaudeInbox
47
47
  end
48
48
  nil
49
49
  end
50
+ private_class_method :remote_control, :pick
50
51
  end
51
52
  end
@@ -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
 
@@ -112,5 +112,6 @@ module ClaudeInbox
112
112
  s = s[1..-2] if quoted
113
113
  s.gsub("''", "'")
114
114
  end
115
+ private_class_method :from_dir, :skills, :commands, :qualify, :plugins, :synced, :frontmatter, :unquote
115
116
  end
116
117
  end