claude-inbox 0.12.2 → 0.13.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: 39ef2bafdac9f7b1c8ba1d4e6f28f5105a5fd5c42320f627caf61fd07f9c314d
4
- data.tar.gz: 0ed5aadb2d02b61b1eabca62a7de74820f7e884f5ffdc6f0bbb68382ba26cc13
3
+ metadata.gz: f8b8a6141fcc9efc19386143e9412d4ebab669e73350ee42205e6a07fd05b290
4
+ data.tar.gz: 412882713110008db6ff1d96fe8b786e5d61e82ea9b349202c12560f290a86b3
5
5
  SHA512:
6
- metadata.gz: 393709c2bc4fc083ded424de49ca3ad102bcf41d75fd72b5c445d8b1f23f7e80f7df0ac1623d4081994e14699168db7c58a2bc64f486b00dd83941c2d691600f
7
- data.tar.gz: fb06b9a05422bff8e3a5474169a11574041219ae700e30a5178a1e507e74df95cd0c069a1c395a070a7f7856bbe6bcdd6380c2a6011a03ff36a1210c05cdae2e
6
+ metadata.gz: f87a453630132a470dc22b345e8f611ae0e58f04bc877c52ee1c53c4358a306377529a10ae8d807058dc32e1c9d039fc2480dd962a89d382a16e00d35faf6908
7
+ data.tar.gz: de74aeac784fdc777336fdaa4db38cfae93e7458873809e2c3e52c0a5a69dc53d2391cec86d73e2d52882ad9634c4567553f3d6151e2c40c571a7eca0abc8db5
data/README.md CHANGED
@@ -49,7 +49,8 @@ It opens when a session starts. To stop that, set
49
49
 
50
50
  ## Sections
51
51
 
52
- 1. **Pinned.** Rows you pinned with `t`.
52
+ 1. **Pinned.** Rows you pinned with `t`. Snoozing a pinned row moves it to
53
+ Snoozed, marked `★`, until it wakes back into Pinned.
53
54
  2. **Needs you.** Blocked or failed sessions, or ones whose pull request has
54
55
  failing checks or changes requested, that you haven't attached to yet.
55
56
  3. **Active.** Everything else that's running or not yet settled, including
@@ -58,6 +59,10 @@ It opens when a session starts. To stop that, set
58
59
  5. **Settled.** Settled with `x`, or every pull request is merged or closed.
59
60
  Collapsed.
60
61
 
62
+ `--sort=activity` puts the session that spoke most recently first in every
63
+ section but Snoozed, read from its transcript. Put it in the config file to
64
+ keep it.
65
+
61
66
  Under each row is what the session is waiting on, what it produced, or its
62
67
  status line. A row with a pull request shows it as `#885 open`, `#885 merged`
63
68
  and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
@@ -65,7 +70,9 @@ something is wrong, and `+2` after it when the session has two more.
65
70
  A steady `◌` with `idle · 1 shell` means the agent stopped
66
71
  and is waiting on a shell or sub-agent it started. A blue `●` marks a session you
67
72
  haven't attached to since it got blocked or finished; one you have attached
68
- to since is dimmed, unless `/color` tints it.
73
+ to since is dimmed, unless `/color` tints it. The age after the badge is how
74
+ long since the session last said anything, read from its transcript: a
75
+ `working` row that spoke 30s ago is still talking, one silent for 2h is not.
69
76
 
70
77
  ## Keys
71
78
 
@@ -93,7 +100,7 @@ Vim bindings. Arrows and the mouse work too.
93
100
  | `P` | link a pull request by hand (empty clears) |
94
101
  | `X` | stop the session; `Enter` resumes it later |
95
102
  | `Ctrl-x` | delete the session, transcript and worktree for good |
96
- | `/` | filter; `Enter` keeps it, `Esc` clears |
103
+ | `/` | filter by name, directory, prompt, status or pull request (`#885` or its title); `Enter` keeps it, `Esc` clears |
97
104
  | `R` | poll now |
98
105
  | `U` | update claude-inbox when the header says a newer one is out |
99
106
  | `q` | quit |
@@ -154,7 +161,8 @@ covers ssh, the JSON API and an iOS Shortcut.
154
161
  ## Pull requests
155
162
 
156
163
  Pull requests show up when a session's transcript links to them. Install
157
- `gh` to keep their state current. Use `P` to link one by hand.
164
+ `gh` to keep their state current. Use `P` to link one by hand, and `/#885`
165
+ to find the session behind a pull request.
158
166
 
159
167
  ## Notifications
160
168
 
@@ -39,7 +39,7 @@ module ClaudeInbox
39
39
  # Applies every request on disk to `store` and returns how many it took.
40
40
  def drain(store)
41
41
  return 0 unless @dir && File.directory?(@dir)
42
- keys = store.sessions.select(&:actionable?).map(&:key)
42
+ keys = store.actionable_keys
43
43
  Dir.glob(File.join(@dir, "*.json")).sort.count { |path| take(path, store, keys) }
44
44
  end
45
45
 
@@ -27,7 +27,6 @@ module ClaudeInbox
27
27
  classify_origins(parse(r.out))
28
28
  end
29
29
 
30
- # Poll interval for the agents-view watchdog below.
31
30
  WATCH_INTERVAL = 0.05
32
31
 
33
32
  # Hands the terminal to the child; caller must have restored cooked mode.
@@ -35,14 +34,11 @@ module ClaudeInbox
35
34
  # False when the attach failed on its own, as a session that cannot be
36
35
  # woken does: the CLI prints why and exits 1.
37
36
  #
38
- # Pressing ← inside an attached session detaches it and then `claude
39
- # attach` execs itself in place as `claude agents` (same pid). There is
40
- # no flag or env var that suppresses only that relaunch: the one switch
41
- # that exists disables attach too. So we watch the child's command line
42
- # and, the moment it becomes the agents view, terminate it. The user then
43
- # lands back in the inbox instead of native agent view.
44
- # The block, polled with the command line, ends the attach early when it
45
- # answers true: how a switch request from another front end is honoured.
37
+ # ← turns `claude attach` into `claude agents` in place, and nothing
38
+ # switches off only that (docs/cli-quirks.md), so the watchdog kills the
39
+ # child once it does. The block, polled alongside, ends the attach early
40
+ # when it answers true. That is how a switch request from another front
41
+ # end is honoured.
46
42
  def attach(id, &stop)
47
43
  pid = Process.spawn(*Subprocess.command(@bin, "attach", id))
48
44
  watchdog = Thread.new { kill_when_agents_view(pid, &stop) }
@@ -76,8 +72,7 @@ module ClaudeInbox
76
72
  # its path completion escapes them; a quoted path is not recognized.
77
73
  def self.mention(path) = "@" + path.gsub(" ", "\\ ")
78
74
 
79
- # Pure so it can be tested: a setting left nil leaves its flag off. The
80
- # prompt goes after "--", where a leading dash is text rather than an
75
+ # The prompt goes after "--", where a leading dash is text rather than an
81
76
  # unknown option, and --remote-control sits just before it: its optional
82
77
  # name would otherwise eat the prompt (both checked on 2.1.282).
83
78
  def self.spawn_args(bin, prompt:, model: nil, effort: nil, permission_mode: nil, worktree: false, name: nil, remote: false)
@@ -115,14 +110,9 @@ module ClaudeInbox
115
110
  # you type in: the headless print mode and the SDK's stream protocol.
116
111
  HEADLESS_FLAGS = %w[-p --print --input-format --output-format].freeze
117
112
 
118
- # pid => origin, given `ps` for the sessions and for their parents. Pure.
119
- #
120
- # :remote a Remote Control worker: --sdk-url, or a `claude rc` parent
121
- # :headless `claude -p "..."` or an SDK stream run. Its parent is
122
- # whatever shell spawned it, not the claude that asked for
123
- # it, so only its own command line gives it away
124
- # :subagent parented by another claude process
125
- # :terminal everything else, which is a claude you are sitting in
113
+ # pid => origin, given `ps` for the sessions and for their parents.
114
+ # A headless run's parent is whatever shell spawned it, not the claude
115
+ # that asked for it, so only its own command line gives it away.
126
116
  def self.origins(rows, parent_cmd)
127
117
  rows.to_h do |pid, ppid, cmd|
128
118
  parent = parent_cmd[ppid].to_s
@@ -143,7 +133,7 @@ module ClaudeInbox
143
133
 
144
134
  # pid => bridge session id, for the workers a `claude remote-control`
145
135
  # server spawned: each is started with `--session-id cse_…`, the same id
146
- # a background session records as bridgeSessionId. Pure.
136
+ # a background session records as bridgeSessionId.
147
137
  def self.bridge_ids(rows)
148
138
  rows.filter_map { |pid, _, cmd| (m = cmd.match(/--session-id[= ](cse_\w+)/)) && [pid, m[1]] }.to_h
149
139
  end
@@ -185,10 +175,6 @@ module ClaudeInbox
185
175
  assign_origins(sessions, origins_from(rows), self.class.bridge_ids(rows))
186
176
  end
187
177
 
188
- # Tags each interactive session with where it is driven from (pid =>
189
- # origin, terminal when unlisted) and the bridge a remote one is served
190
- # over, and drops the unattended ones, whose parent is the row worth
191
- # showing.
192
178
  def assign_origins(sessions, origins, bridges)
193
179
  sessions
194
180
  .map { |s| s.interactive? ? s.with(origin: origins.fetch(s.pid, :terminal), bridge_id: bridges[s.pid]) : s }
@@ -259,9 +245,7 @@ module ClaudeInbox
259
245
  @bridges = bridges
260
246
  end
261
247
 
262
- # Fixture rows carry no process tree; tag each interactive row from the
263
- # pid => origin map the test hands in, otherwise as a terminal, then drop
264
- # the unattended ones same as the real client does.
248
+ # Fixture rows carry no process tree.
265
249
  def list
266
250
  assign_origins(parse(File.read(@path)), @origins, @bridges)
267
251
  end
@@ -35,7 +35,7 @@ module ClaudeInbox
35
35
  @terminal.release do
36
36
  @terminal.pause unless @client.attach(id) { @switch.requested_for?(from) }
37
37
  end
38
- id = @switch.take(@store.sessions.select(&:actionable?).map(&:key))
38
+ id = @switch.take(@store.actionable_keys)
39
39
  end
40
40
  end
41
41
 
@@ -47,7 +47,7 @@ module ClaudeInbox
47
47
 
48
48
  # The reaper deletes sessions and the listener lets other machines in,
49
49
  # so both are off unless `bin/claude-inbox` switches them on.
50
- def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new,
50
+ def initialize(client: AgentsClient.new, store: Store.new, pull_requests: PullRequests.new, transcripts: Transcript.new,
51
51
  rate_limits: RateLimits.new, reaper: Reaper.disabled, snapshot: Snapshot.disabled, switch: Switch.disabled,
52
52
  actions: Actions.disabled, notifier: Notifier.disabled, listen: nil, updater: nil, argv: [], out: $stdout, input: $stdin, color: true,
53
53
  trust: Trust.method(:projects), terminal: Terminal.new(out, input), queue: Queue.new, pane_offer: Mod::Offer.disabled,
@@ -67,7 +67,7 @@ module ClaudeInbox
67
67
  @renderer = Renderer.new
68
68
  @input = Input.new(input, out)
69
69
  @queue = queue
70
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests,
70
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, transcripts: transcripts,
71
71
  reaper: reaper, queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
72
72
  @listener = listen ? Remote::Listener.new(client: client, store: store, queue: @queue, **listen) : Remote::Listener.disabled
73
73
  @selected = nil
@@ -59,7 +59,7 @@ module ClaudeInbox
59
59
  opts[:color] = options[:color]
60
60
  opts[:notifier] = notifier
61
61
  return opts if options[:fixture] || writer_lock.take(role: "inbox")
62
- opts.merge(store: Store.new(writable: false, relay: Actions.new), reaper: Reaper.disabled,
62
+ opts.merge(store: Store.new(writable: false, relay: Actions.new, sort: options[:sort]), reaper: Reaper.disabled,
63
63
  snapshot: Snapshot.disabled, actions: Actions.disabled, pane_offer: Mod::Offer.disabled, notifier: Notifier.disabled)
64
64
  end
65
65
 
@@ -113,7 +113,7 @@ module ClaudeInbox
113
113
 
114
114
  def headless
115
115
  abort "claude-inbox: --headless takes no --fixture" if options[:fixture]
116
- Headless.new(client: AgentsClient.new, store: Store.new, updater: updater, notifier: notifier).run
116
+ Headless.new(client: AgentsClient.new, store: Store.new(sort: options[:sort]), updater: updater, notifier: notifier).run
117
117
  end
118
118
 
119
119
  # Only what a check within the day already found: a cold launch never waits
@@ -132,7 +132,7 @@ module ClaudeInbox
132
132
  # token phones are paired with nor hold the real inbox's lock.
133
133
  dir = File.join(Dir.tmpdir, "claude-inbox-fixture")
134
134
  {client: FixtureClient.new(options[:fixture]),
135
- store: Store.new(path: nil),
135
+ store: Store.new(path: nil, sort: options[:sort]),
136
136
  pull_requests: PullRequests.new(cache_path: File.join(fixtures, "gh-pr-status-cache.json"), resolved_path: nil, gh: nil),
137
137
  listen: options[:listen]&.merge(fixture: true, pairing: Remote::Pairing.new(path: File.join(dir, "listen.json")),
138
138
  lock_path: File.join(dir, "listen.lock"), images_dir: File.join(dir, "images"))}
@@ -144,7 +144,7 @@ module ClaudeInbox
144
144
  # comes only from here, and only when asked for.
145
145
  def live_options
146
146
  client = AgentsClient.new
147
- store = Store.new
147
+ store = Store.new(sort: options[:sort])
148
148
  {client: client, store: store, reaper: options[:reap] ? Reaper.new(client, store) : Reaper.disabled,
149
149
  snapshot: Snapshot.new, switch: Switch.new, actions: Actions.new, pane_offer: Mod::Offer.new,
150
150
  listen: options[:listen], updater: updater,
@@ -162,7 +162,7 @@ module ClaudeInbox
162
162
  def writer_lock = (@lock ||= WriterLock.new)
163
163
 
164
164
  def parse(argv)
165
- opts = {color: true, update_check: true}
165
+ opts = {color: true, update_check: true, sort: "default"}
166
166
  parser = OptionParser.new(USAGE) do |p|
167
167
  p.on("--version", "Print the version") {
168
168
  @out.puts VERSION
@@ -176,6 +176,7 @@ module ClaudeInbox
176
176
  p.on("--headless", "Keep the pane's snapshot fresh without a screen") { opts[:headless] = true }
177
177
  p.on("--reap", "Delete background sessions idle 14 days") { opts[:reap] = true }
178
178
  p.on("--no-color", "Draw without color") { opts[:color] = false }
179
+ p.on("--sort=ORDER", Store::SORTS, "Order every section by activity, newest message first") { |order| opts[:sort] = order }
179
180
  p.on("--notify", "Send a desktop notification when a session needs you or finishes") { opts[:notify] = true }
180
181
  p.on("--auto-update", "Install a release found today before launching") { opts[:auto_update] = true }
181
182
  p.on("--no-update-check", "Never look for a newer release") { opts[:update_check] = false }
@@ -106,7 +106,8 @@ module ClaudeInbox
106
106
  # once the list spans repos.
107
107
  def label(pr)
108
108
  name = (@prs.map(&:repo).uniq.size > 1) ? pr.qualified : pr.short
109
- pr.title ? @theme.pr_badge(pr, name) + @theme.dim(" · ") + pr.title : @theme.pr_badge(pr, name)
109
+ badge = @theme.pr_badge(pr, name)
110
+ pr.title ? badge + @theme.dim(" · ") + pr.title : badge
110
111
  end
111
112
  end
112
113
 
@@ -17,7 +17,7 @@ module ClaudeInbox
17
17
  class Headless
18
18
  RECHECK_EVERY = 60
19
19
 
20
- def initialize(client:, store:, pull_requests: PullRequests.new, actions: Actions.new, snapshot: Snapshot.new,
20
+ def initialize(client:, store:, pull_requests: PullRequests.new, transcripts: Transcript.new, actions: Actions.new, snapshot: Snapshot.new,
21
21
  lock: WriterLock.new, updater: nil, notifier: Notifier.disabled, recheck_every: RECHECK_EVERY)
22
22
  @lock = lock
23
23
  @snapshot = snapshot
@@ -25,7 +25,7 @@ module ClaudeInbox
25
25
  @recheck_every = recheck_every
26
26
  @notice = nil
27
27
  @queue = Queue.new
28
- @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, reaper: Reaper.disabled,
28
+ @poller = Poller.new(client: client, store: store, pull_requests: pull_requests, transcripts: transcripts, reaper: Reaper.disabled,
29
29
  queue: @queue, snapshot: snapshot, actions: actions, notifier: notifier)
30
30
  end
31
31
 
@@ -42,13 +42,12 @@ module ClaudeInbox
42
42
  # two requests saving in the same millisecond apart. Readable by the
43
43
  # owner only: a photo can be anything.
44
44
  def self.save(bytes, dir:, index:, now: Time.now)
45
- data = bytes.b
46
- ext = extension(data)
45
+ ext = extension(bytes)
47
46
  raise Unsupported, "not a PNG, JPEG, GIF or WebP image" unless ext
48
47
  FileUtils.mkdir_p(dir)
49
48
  prune(dir, now)
50
49
  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) }
50
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) { |f| f.write(bytes) }
52
51
  path
53
52
  end
54
53
 
@@ -8,17 +8,9 @@ module ClaudeInbox
8
8
  # ~/.claude/jobs/<id>/state.json. The only reader of that file, and the
9
9
  # only writer: a new session's color.
10
10
  #
11
- # `claude agents --json` reports one word, `state`, and "working" covers two
12
- # different situations: the agent is thinking, or the agent is idle and
13
- # something it started earlier — a watch shell, a sub-agent — is still open.
14
- # The file tells them apart: `tempo` is the agent's own pulse, `fan` names
15
- # each outstanding piece of work, and `detail` is the session's own status
16
- # line, with `needs` naming what it is waiting on while blocked and
17
- # `output.result` what it produced once done. It also carries the PR links
18
- # the daemon scanned out of the transcript and the transcript's path, which
19
- # PullRequests reads through here, the color `/color` set on the session,
20
- # which `claude agents --json` drops, and `intent`, the prompt the session
21
- # was started from, and `createdAt`, when it was.
11
+ # `claude agents --json` says "working" both while the agent thinks and
12
+ # while it sits idle on a shell or sub-agent it started. This file tells
13
+ # the two apart, and holds what the JSON drops (docs/cli-quirks.md).
22
14
  #
23
15
  # Never cached. A color changes the moment you type `/color`, so every poll
24
16
  # asks the file again.
@@ -40,7 +32,7 @@ module ClaudeInbox
40
32
 
41
33
  # Each background session with its job file read onto `job_state`, or nil
42
34
  # when there is none: interactive sessions have no job file, and neither
43
- # does one the daemon has already forgotten. Sessions.load calls this.
35
+ # does one the daemon has already forgotten.
44
36
  def self.enrich(sessions, jobs_dir:)
45
37
  sessions.map { |s| s.background? ? s.with(job_state: read(s.id, jobs_dir: jobs_dir)) : s }
46
38
  end
@@ -91,7 +83,6 @@ module ClaudeInbox
91
83
  # a wake, are the only record of Remote Control being on it.
92
84
  def remote_control? = flags.include?("--remote-control")
93
85
 
94
- # True when the agent has stopped and is only waiting on what it started.
95
86
  def waiting_on_work? = agent_idle? && in_flight?
96
87
 
97
88
  # What the session needs while blocked, what it produced once done,
@@ -42,7 +42,6 @@ module ClaudeInbox
42
42
  def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
43
43
  @p = Pastel.new(enabled: true)
44
44
  @theme = Theme.new
45
- @palette = Palette.new
46
45
  @home = home
47
46
  @clipboard = clipboard
48
47
  @places = dirs.map { |path| Place.new(path, SessionRequest.label(path, dirs), path.sub(/\A#{Regexp.escape(home)}(?=\/|\z)/, "~")) }
@@ -326,7 +325,7 @@ module ClaudeInbox
326
325
  f.choices.map { |c|
327
326
  text = (c == DEFAULT) ? default_text(f) : c
328
327
  if c == f.value then (on ? @theme.pill(" #{text} ", :cyan) : @theme.cyan_bold(" #{text} "))
329
- elsif f.key == :color then @palette.paint(" #{text} ", c)
328
+ elsif f.key == :color then Palette.paint(" #{text} ", c)
330
329
  else @p.dim(" #{text} ")
331
330
  end
332
331
  }.join(" ")
@@ -9,7 +9,7 @@ module ClaudeInbox
9
9
  # Closing with 39 (default foreground) rather than 0 (reset everything)
10
10
  # matters: the label is often already bold or italic and a full reset
11
11
  # would strip that back off.
12
- class Palette
12
+ module Palette
13
13
  ANSI = {
14
14
  "red" => 31, "green" => 32, "yellow" => 33,
15
15
  "blue" => 34, "purple" => 35, "cyan" => 36
@@ -22,7 +22,7 @@ module ClaudeInbox
22
22
 
23
23
  RESET = "\e[39m"
24
24
 
25
- def paint(text, name)
25
+ def self.paint(text, name)
26
26
  seq = SEQUENCES[name]
27
27
  seq ? seq + text + RESET : text
28
28
  end
@@ -16,12 +16,13 @@ module ClaudeInbox
16
16
  INTERVAL = 4
17
17
  ACTIONS_INTERVAL = 0.5
18
18
 
19
- def initialize(client:, store:, pull_requests:, reaper:, queue:, snapshot:, actions:,
19
+ def initialize(client:, store:, pull_requests:, transcripts:, reaper:, queue:, snapshot:, actions:,
20
20
  notifier: Notifier.disabled, interval: INTERVAL, clock: -> { Time.now })
21
21
  @client = client
22
22
  @clock = clock
23
23
  @store = store
24
24
  @pull_requests = pull_requests
25
+ @transcripts = transcripts
25
26
  @reaper = reaper
26
27
  @queue = queue
27
28
  @snapshot = snapshot
@@ -54,7 +55,7 @@ module ClaudeInbox
54
55
  # state moved.
55
56
  def once
56
57
  now = @clock.call
57
- sessions = Sessions.load(client: @client, pull_requests: @pull_requests, overrides: @store.pr_overrides)
58
+ sessions = Sessions.load(client: @client, pull_requests: @pull_requests, transcripts: @transcripts, overrides: @store.pr_overrides)
58
59
  doomed = @reaper.due(sessions, now)
59
60
  @store.hide(doomed)
60
61
  publish(sessions)
@@ -54,25 +54,18 @@ module ClaudeInbox
54
54
 
55
55
  # Finds the PRs a session is tied to and keeps their state fresh.
56
56
  #
57
- # Claude Code already does the hard part: the daemon scans each background
58
- # session's transcript for links and writes them to the session's job state
59
- # file, which JobState reads onto `job_state`; `claude agents --json` does
60
- # not expose them.
61
- # It is a link scan, so a session that merely mentions a PR gets it too.
62
- # The daemon keeps one PR per session, so StackLinks adds the rest of a
63
- # stack the session opened.
64
- # Interactive sessions have no job file; for those the store's `pr` override
65
- # is the only source.
57
+ # The links come from the daemon's scan of each background session's
58
+ # transcript, so a session that merely mentions a PR gets it too. The
59
+ # daemon keeps one PR per session, so StackLinks adds the rest of a stack
60
+ # the session opened. Interactive sessions have no job file; for those the
61
+ # store's `pr` override is the only source.
66
62
  #
67
- # State comes first from our own record of resolved PRs, then from
68
- # ~/.claude/gh-pr-status-cache.json (whatever Claude Code last saw), then
69
- # from `gh pr view` for PRs that are still open, at most once per
70
- # REFRESH_AFTER. A merged or closed PR never changes again, so once gh has
71
- # said so, and when, it is written to RESOLVED_PATH and never asked about
72
- # again. Claude Code's cache never says when, and says nothing at all about
73
- # most of a link scan's PRs: it only covers PRs its own sessions opened.
63
+ # A merged or closed PR never changes again, so once gh has said so, and
64
+ # when, it goes in RESOLVED_PATH and gh is never asked about it again.
65
+ # Claude Code's cache never says when, and only covers PRs its own
66
+ # sessions opened.
74
67
  #
75
- # `enrich` never touches gh; it is what stands between `claude agents` and
68
+ # `enrich` never touches gh, since it stands between `claude agents` and
76
69
  # the first frame. `refresh` is the slow half, one network round trip per
77
70
  # open PR, and the poller calls it after the list has already gone up.
78
71
  class PullRequests
@@ -93,11 +86,10 @@ module ClaudeInbox
93
86
  @stacks = StackLinks.new
94
87
  end
95
88
 
96
- # Each session with its `prs` set from what is already known, asking
97
- # nobody. The scanned links come off `job_state`, so a session without
98
- # one (interactive, or forgotten) has none; Sessions.load sees to the
99
- # order. `overrides` maps session key => url for links set by hand; an
100
- # override replaces the scanned list.
89
+ # Each session with its `prs` set from what is already known. The scanned
90
+ # links come off `job_state`, so Sessions.load reads that first.
91
+ # `overrides` maps session key => url for links set by hand; an override
92
+ # replaces the scanned list.
101
93
  def enrich(sessions, overrides)
102
94
  sessions.map do |s|
103
95
  urls = overrides[s.key] ? [overrides[s.key]] : scanned(s.job_state)
@@ -127,7 +119,7 @@ module ClaudeInbox
127
119
  end
128
120
  end
129
121
 
130
- # Parse `gh pr view --json` output. Pure so it can be tested.
122
+ # Parse `gh pr view --json` output.
131
123
  def self.parse(url, json)
132
124
  h = JSON.parse(json)
133
125
  state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
@@ -8,14 +8,13 @@ module ClaudeInbox
8
8
  # Reaps sessions that have sat idle past Store::REAP_AFTER.
9
9
  #
10
10
  # The one thing in the inbox that destroys anything without being asked
11
- # first, so the whole of it lives here rather than spread through the poll
12
- # loop, along with the append-only log that is the last record a session
13
- # ever existed once `claude rm` has taken its transcript.
11
+ # first, so all of it lives here, along with the append-only log that is
12
+ # the last record a session ever existed once `claude rm` has taken its
13
+ # transcript.
14
14
  #
15
- # Unpushed work is safe by construction. `claude rm` refuses a worktree
16
- # holding commits that aren't pushed and reports a --discard-unpushed token
17
- # to override it; nothing here ever passes that token, so a refusal is the
18
- # end of it. Refusals are logged and retried at most daily.
15
+ # Unpushed work is safe: `claude rm` refuses it, and nothing here
16
+ # overrides that (docs/cli-quirks.md). Refusals are logged and retried at
17
+ # most daily.
19
18
  class Reaper
20
19
  RETRY_AFTER = 24 * 3600
21
20
  DEFAULT_LOG = File.join(Dir.home, ".config", "claude-inbox", "reaped.log")
@@ -27,14 +26,13 @@ module ClaudeInbox
27
26
  @enabled = enabled
28
27
  end
29
28
 
30
- # For --fixture runs and tests: selects nothing, deletes nothing, and
31
- # needs neither a client nor a store to do it.
29
+ # For --fixture runs and tests: selects nothing, deletes nothing.
32
30
  def self.disabled = new(nil, nil, enabled: false)
33
31
 
34
- # Reaps everything due, returning the keys it actually deleted so the
35
- # caller can drop those rows before they reach the store. Refusals come
36
- # back as survivors rather than exceptions: one worktree with unpushed
37
- # commits must not stop the rest of the sweep.
32
+ # Returns the keys it deleted, so the caller can drop those rows before
33
+ # they reach the store. Refusals come back as survivors rather than
34
+ # exceptions: one worktree with unpushed commits must not stop the rest
35
+ # of the sweep.
38
36
  #
39
37
  # Raises if the log cannot be opened, before anything is deleted. No
40
38
  # audit trail, no reaping.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../text"
4
+
3
5
  module ClaudeInbox
4
6
  module Remote
5
7
  # The little of HTTP/1.1 the listener speaks: one request per
@@ -42,7 +44,10 @@ module ClaudeInbox
42
44
  @details = details
43
45
  end
44
46
 
45
- def body = {error: Http.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Http.utf8(v) }}
47
+ # The JSON generator warns on stderr, which is the terminal, about a
48
+ # binary string and raises on a bad byte, and a message can quote CLI
49
+ # output or request bytes.
50
+ def body = {error: Text.utf8(message), **details.transform_values { |v| v.is_a?(Integer) ? v : Text.utf8(v) }}
46
51
 
47
52
  private
48
53
 
@@ -80,11 +85,6 @@ module ClaudeInbox
80
85
 
81
86
  def self.monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
87
 
83
- # The JSON generator warns on stderr, which is the terminal, about a
84
- # binary string and raises on a bad byte, and a message can quote CLI
85
- # output or request bytes.
86
- def self.utf8(text) = String.new(text.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
87
-
88
88
  # `deadline` (on the monotonic clock) bounds the whole head, not each
89
89
  # read: a per-read timeout lets a byte every few seconds hold the
90
90
  # connection for ever.
@@ -158,7 +158,7 @@ module ClaudeInbox
158
158
  values[:prompt] = SessionRequest.attach(values[:prompt], paths)
159
159
  values, note = SessionRequest.without_stray_worktree(values)
160
160
  @queue << [:notice, "remote: starting session…"]
161
- id = Http.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
161
+ id = Text.utf8(@spawn_lock.synchronize { @client.spawn(**values) })
162
162
  url = remote_url(id, wait: values[:remote] ? @bridge_wait : 0)
163
163
  @queue << [:remote_started, id, via, note].compact
164
164
  {id: id, name: values[:name], cwd: values[:cwd], url: url}.merge(note ? {note: note} : {})
@@ -65,7 +65,6 @@ module ClaudeInbox
65
65
  def initialize(home: Dir.home)
66
66
  @p = Pastel.new(enabled: true)
67
67
  @theme = Theme.new
68
- @palette = Palette.new
69
68
  @home = home
70
69
  end
71
70
 
@@ -354,7 +353,7 @@ module ClaudeInbox
354
353
  elsif bold then @p.bold(label)
355
354
  else label
356
355
  end
357
- @palette.paint(styled, row.session.color)
356
+ Palette.paint(styled, row.session.color)
358
357
  end
359
358
 
360
359
  def glyph_for(s, section, tick)
@@ -375,16 +374,17 @@ module ClaudeInbox
375
374
  base =
376
375
  case section
377
376
  when :snoozed
378
- row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
377
+ wake = row.parked? ? @theme.purple("parked") : @theme.purple("wakes in #{Text.age(row.wake_at.to_i - now.to_i)}")
378
+ row.pinned? ? wake + @theme.cyan(" ★") : wake
379
379
  when :settled
380
- @p.dim("#{s.state} · #{Text.age(now.to_i - row.state_since.to_i)}")
380
+ @p.dim("#{s.state} · #{Text.age(now.to_i - row.spoke_at)}")
381
381
  else
382
382
  if s.interactive?
383
383
  where = s.remote? ? "remote" : "your terminal"
384
- age = row.state_since ? Text.age(now.to_i - row.state_since.to_i) : Text.age(now - s.started_at)
384
+ age = Text.age(now.to_i - row.spoke_at)
385
385
  state_badge(s) + @p.dim(" · #{where} · #{age}")
386
386
  else
387
- age = row.state_since ? @p.dim(" · " + Text.age(now.to_i - row.state_since.to_i)) : ""
387
+ age = @p.dim(" · " + Text.age(now.to_i - row.spoke_at))
388
388
  remote = s.remote_control? ? @theme.blue(" ⇅") : ""
389
389
  state_badge(s) + remote + age
390
390
  end
@@ -10,7 +10,7 @@ module ClaudeInbox
10
10
  # move under it.
11
11
  Session = Data.define(
12
12
  :id, :cwd, :kind, :started_at, :session_id, :name,
13
- :state, :pid, :status, :waiting_for, :origin, :prs, :job_state, :bridge_id
13
+ :state, :pid, :status, :waiting_for, :origin, :prs, :job_state, :bridge_id, :last_message_at
14
14
  ) do
15
15
  # Every member is optional so the parser and the specs name only what they
16
16
  # have; prs is [] rather than nil so nobody asks whether PullRequests has run.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "job_state"
4
+ require_relative "transcript"
4
5
 
5
6
  module ClaudeInbox
6
7
  # Puts the list together. JobState goes before PullRequests because the PR
@@ -9,8 +10,9 @@ module ClaudeInbox
9
10
  module Sessions
10
11
  module_function
11
12
 
12
- def load(client:, pull_requests:, overrides:)
13
+ def load(client:, pull_requests:, transcripts:, overrides:)
13
14
  sessions = JobState.enrich(client.list, jobs_dir: client.jobs_dir)
15
+ sessions = transcripts.enrich(sessions)
14
16
  pull_requests.enrich(sessions, overrides)
15
17
  end
16
18
  end
@@ -45,6 +45,7 @@ module ClaudeInbox
45
45
  {
46
46
  "id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
47
47
  "waiting" => s.waiting_on_work? || nil, "unread" => row.unread? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url, "color" => s.color,
48
+ "last_message_at" => s.last_message_at&.to_i,
48
49
  "pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
49
50
  }.compact
50
51
  end
@@ -26,11 +26,7 @@ module ClaudeInbox
26
26
  when Integer then choice
27
27
  when :m15 then now.to_i + 15 * 60
28
28
  when :h1 then now.to_i + 3600
29
- when :tomorrow_9am
30
- t = Time.at(now.to_i)
31
- t9 = Time.new(t.year, t.month, t.day, 9, 0, 0, t.utc_offset)
32
- t9 += 86_400 if t9 <= t
33
- t9.to_i
29
+ when :tomorrow_9am then SnoozeTime.parse("9am", now)
34
30
  when :until_woken then UNTIL_WOKEN
35
31
  else raise ArgumentError, "unknown snooze #{choice.inspect}"
36
32
  end
@@ -50,8 +46,6 @@ module ClaudeInbox
50
46
 
51
47
  def parked? = wake_at == UNTIL_WOKEN
52
48
 
53
- def snoozed_at = @h["snoozed_at"]
54
-
55
49
  def last_state = @h["last_state"]
56
50
 
57
51
  def state_since = @h["state_since"]
@@ -82,6 +76,9 @@ module ClaudeInbox
82
76
  def observe(session, now)
83
77
  now_i = now.to_i
84
78
  if last_state != session.effective_state
79
+ # Only a change of state wakes a snooze early, not trouble on the
80
+ # PR: a session blocked when you snoozed it stays snoozed.
81
+ @h.delete("wake_at") if last_state && session.needs_you?
85
82
  @h["last_state"] = session.effective_state
86
83
  @h["state_since"] = now_i
87
84
  end
@@ -96,7 +93,6 @@ module ClaudeInbox
96
93
  @h["last_seen"] = now_i
97
94
  row = Row.new(session: session, entry: self)
98
95
  @h.delete("wake_at") if row.woken?(now_i)
99
- @h.delete("snoozed_at") unless wake_at
100
96
  @h.delete("settled_at") unless row.hand_settled?
101
97
  @h.delete("acknowledged_at") unless row.acknowledged?
102
98
  @h.delete("revived_at") unless row.revived?
@@ -105,13 +101,11 @@ module ClaudeInbox
105
101
 
106
102
  def snooze(choice, now)
107
103
  @h["wake_at"] = self.class.snooze_until(choice, now)
108
- @h["snoozed_at"] = now.to_i
109
104
  end
110
105
 
111
106
  # Also lifts a hand-settle and a PR settle, so `u` undoes `x` and a resolved PR alike.
112
107
  def wake(now)
113
108
  @h.delete("wake_at")
114
- @h.delete("snoozed_at")
115
109
  @h.delete("settled_at")
116
110
  @h["revived_at"] = now.to_i
117
111
  end
@@ -121,7 +115,6 @@ module ClaudeInbox
121
115
  def settle(now)
122
116
  @h["settled_at"] = now.to_i
123
117
  @h.delete("wake_at")
124
- @h.delete("snoozed_at")
125
118
  @h.delete("revived_at")
126
119
  end
127
120
 
@@ -19,6 +19,10 @@ module ClaudeInbox
19
19
 
20
20
  def state_since = entry&.state_since
21
21
 
22
+ # When the session last said anything, as its transcript has it; the
23
+ # inbox's own clock, then the process start, when there is none to ask.
24
+ def spoke_at = session.last_message_at&.to_i || state_since || session.started_at&.to_i || 0
25
+
22
26
  def pinned_at = entry&.pinned_at
23
27
 
24
28
  def pinned? = entry&.pinned?
@@ -26,20 +30,17 @@ module ClaudeInbox
26
30
  def reap_failed_at = entry&.reap_failed_at
27
31
 
28
32
  # Names get shortened and forgotten, so the filter also reads what the
29
- # session was asked to do and what it says it is doing now.
33
+ # session was asked to do, what it says it is doing now, and the pull
34
+ # requests linked to it, so `#885` finds the session behind a review.
30
35
  def matches?(query)
31
36
  q = query.downcase
32
- [label, session.cwd, session.intent, session.summary].any? { |field| field.to_s.downcase.include?(q) }
37
+ prs = session.prs.flat_map { |pr| [pr.qualified, pr.url, pr.title] }
38
+ [label, session.cwd, session.intent, session.summary, *prs].any? { |field| field.to_s.downcase.include?(q) }
33
39
  end
34
40
 
35
- # A session already blocked when you snoozed it stays snoozed; one that
36
- # *becomes* blocked or fails afterwards wakes early.
37
- def woken?(now)
38
- return false if wake_at.nil?
39
- return true if session.needs_you? && entry.state_since.to_i > entry.snoozed_at.to_i
40
- return false if parked?
41
- wake_at.to_i <= now.to_i
42
- end
41
+ # Its timer has run out. Becoming blocked or failed wakes it sooner,
42
+ # but only Entry#observe sees that change happen.
43
+ def woken?(now) = !wake_at.nil? && !parked? && wake_at.to_i <= now.to_i
43
44
 
44
45
  def snoozed?(now) = wake_at && !woken?(now)
45
46
 
@@ -108,10 +109,11 @@ module ClaudeInbox
108
109
  end
109
110
 
110
111
  # The terminal outranks everything: never settle or hide a session you are in.
112
+ # A snooze outranks the pin, which stays put and returns when it wakes.
111
113
  def section(now)
112
114
  if session.terminal? then session.needs_you? ? :needs_you : :active
113
- elsif pinned? then :pinned
114
115
  elsif snoozed?(now) then :snoozed
116
+ elsif pinned? then :pinned
115
117
  elsif !revived? && settled? then :settled # a resolved PR outranks Needs You
116
118
  elsif session.needs_you? || pr_needs_you? then acknowledged? ? :active : :needs_you
117
119
  else :active
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "records"
4
+ require_relative "snooze_time"
4
5
  require_relative "store/entry"
5
6
  require_relative "store/row"
6
7
  require_relative "store/selection"
@@ -40,7 +41,13 @@ module ClaudeInbox
40
41
  Row.new(session: session, entry: hash && Entry.new(hash))
41
42
  end
42
43
 
43
- def self.sectionize(sessions, entries, now)
44
+ # `activity` puts the session that spoke most recently first in every
45
+ # section but Snoozed, which stays in wake order. The default keeps each
46
+ # section's own order: Pinned by pin, Needs You and Settled by the state
47
+ # clock, Active by start with running sessions first.
48
+ SORTS = %w[default activity].freeze
49
+
50
+ def self.sectionize(sessions, entries, now, sort: "default")
44
51
  sec = Sections.new(pinned: [], needs_you: [], active: [], snoozed: [], settled: [])
45
52
  sessions.each do |s|
46
53
  row = row_for(s, entries)
@@ -51,6 +58,7 @@ module ClaudeInbox
51
58
  sec.active.sort_by! { |r| [r.session.finished? ? 1 : 0, -(r.session.started_at&.to_i || 0)] }
52
59
  sec.snoozed.sort_by! { |r| r.parked? ? [1, 0] : [0, r.wake_at.to_i] }
53
60
  sec.settled.sort_by! { |r| -(r.state_since || 0) }
61
+ %i[pinned needs_you active settled].each { |name| sec[name].sort_by! { |r| -r.spoke_at } } if sort == "activity"
54
62
  sec
55
63
  end
56
64
 
@@ -60,9 +68,10 @@ module ClaudeInbox
60
68
  # second inbox open at once, which reads along with the first rather
61
69
  # than overwriting what it and the pane's requests settled, and sends
62
70
  # its own edits to the first through the `relay` as requests.
63
- def initialize(path: DEFAULT_PATH, clock: -> { Time.now }, writable: true, relay: nil)
71
+ def initialize(path: DEFAULT_PATH, clock: -> { Time.now }, writable: true, relay: nil, sort: "default")
64
72
  @path = path
65
73
  @clock = clock
74
+ @sort = sort
66
75
  @writable = writable
67
76
  @relay = relay
68
77
  @mutex = Mutex.new
@@ -86,11 +95,13 @@ module ClaudeInbox
86
95
  end
87
96
 
88
97
  def sections(now = @clock.call)
89
- @mutex.synchronize { self.class.sectionize(@sessions, @entries, now) }
98
+ @mutex.synchronize { self.class.sectionize(@sessions, @entries, now, sort: @sort) }
90
99
  end
91
100
 
92
101
  def sessions = @mutex.synchronize { @sessions.dup }
93
102
 
103
+ def actionable_keys = sessions.select(&:actionable?).map(&:key)
104
+
94
105
  def writable? = @writable
95
106
 
96
107
  def snooze(id, choice) = edit(id, "snooze", choice: choice.to_s) { |e| e.snooze(choice, @clock.call) }
@@ -15,11 +15,12 @@ module ClaudeInbox
15
15
 
16
16
  def strip_ansi(s) = s.gsub(ANSI, "")
17
17
 
18
+ # Bytes that aren't UTF-8 become U+FFFD.
19
+ def utf8(s) = String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD")
20
+
18
21
  # Control characters as \xNN, so quoted text can't reach the terminal
19
- # as an escape. Bytes that aren't UTF-8 become U+FFFD.
20
- def printable(s)
21
- String.new(s.to_s, encoding: Encoding::UTF_8).scrub("\uFFFD").gsub(/[[:cntrl:]]/) { |c| format("\\x%02x", c.ord) }
22
- end
22
+ # as an escape.
23
+ def printable(s) = utf8(s).gsub(/[[:cntrl:]]/) { |c| format("\\x%02x", c.ord) }
23
24
 
24
25
  def width(s) = Unicode::DisplayWidth.of(strip_ansi(s))
25
26
 
@@ -3,16 +3,14 @@
3
3
  require_relative "text"
4
4
 
5
5
  module ClaudeInbox
6
- # One editable string with a cursor in it. Every edit and all the cursor
7
- # arithmetic live here, so callers never index into the text themselves:
8
- # they hand over keypresses and ask for something to draw.
6
+ # One editable string with a cursor in it. Callers hand over keypresses
7
+ # and ask for something to draw; they never index into the text.
9
8
  #
10
9
  # Positions count cells, not bytes or characters. A cell is a grapheme
11
- # cluster — prompts and session names carry emoji, and character indexes
12
- # cut them in half — or a chip: an attached Image or a long Pasted text,
13
- # each shown as one token (`[Image #1]`, `[Pasted text #1 +40 lines]`)
14
- # that moves and deletes as one unit, the way Claude Code's own prompt
15
- # treats them.
10
+ # cluster, since prompts and session names carry emoji that character
11
+ # indexes cut in half. Or it is a chip, an attached Image or a long Pasted
12
+ # text shown as one token (`[Image #1]`, `[Pasted text #1 +40 lines]`)
13
+ # that moves and deletes as one unit, as in Claude Code's own prompt.
16
14
  #
17
15
  # b = TextBuffer.new("ab")
18
16
  # b.press(:left, "\e[D")
@@ -56,14 +54,13 @@ module ClaudeInbox
56
54
  }.join
57
55
  end
58
56
 
59
- # Swaps the whole text out and parks the cursor at the end — what
60
- # completion wants after it extends a path.
57
+ # The cursor ends up at the end, where completion wants it after it
58
+ # extends a path.
61
59
  def replace(text)
62
60
  @g = text.grapheme_clusters
63
61
  @cursor = @g.size
64
62
  end
65
63
 
66
- # Everything before the cursor, as one string.
67
64
  def head = @g[0...@cursor].join
68
65
 
69
66
  # How completion drops a picked command in over the half-typed one.
@@ -165,7 +162,7 @@ module ClaudeInbox
165
162
 
166
163
  # One entry per display row: [cells, offset of its first cell]. A
167
164
  # cursor at the end of a row that is already full has no cell of its
168
- # own, so it gets a row of its own — what a terminal does when text
165
+ # own, so it gets a row of its own, as a terminal does when text
169
166
  # reaches the right margin.
170
167
  def wrapped(width)
171
168
  rows = logical_lines.flat_map do |line, start|
@@ -252,7 +249,6 @@ module ClaudeInbox
252
249
  @cursor = at
253
250
  end
254
251
 
255
- # Back over any spaces, then over the word itself.
256
252
  def delete_word
257
253
  at = @cursor
258
254
  at -= 1 while at > 0 && @g[at - 1] == " "
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "agents_client"
6
+
7
+ module ClaudeInbox
8
+ # A session's transcript, read from the end for when its last message was
9
+ # sent. Only the `user` and `assistant` records are messages: the CLI
10
+ # appends undated bookkeeping records long after a session has finished,
11
+ # so the file's mtime says nothing.
12
+ #
13
+ # Every poll asks, and a transcript that ends in a long run of other
14
+ # records costs a scan each time, so each file is read again only once
15
+ # it has grown.
16
+ class Transcript
17
+ MESSAGE_TYPES = %w[user assistant].freeze
18
+ CHUNK = 64 * 1024
19
+
20
+ def initialize
21
+ @seen = {} # path => [size, time]
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ def enrich(sessions, home: Dir.home)
26
+ sessions.map { |s| s.with(last_message_at: last_message_at(path_for(s, home))) }
27
+ end
28
+
29
+ def last_message_at(path)
30
+ return nil unless path
31
+ @mutex.synchronize do
32
+ size = File.size(path)
33
+ seen_size, at = @seen[path]
34
+ return at if seen_size == size
35
+ at = File.open(path, "rb") { |f| scan_back(f) }
36
+ @seen[path] = [size, at]
37
+ at
38
+ end
39
+ rescue SystemCallError
40
+ nil
41
+ end
42
+
43
+ private
44
+
45
+ def path_for(session, home)
46
+ session.job_state&.transcript_path ||
47
+ (session.cwd && session.session_id && AgentsClient.transcript_path(session.cwd, session.session_id, home: home))
48
+ end
49
+
50
+ def scan_back(file)
51
+ pos = file.size
52
+ carry = +""
53
+ while pos > 0
54
+ step = [CHUNK, pos].min
55
+ pos -= step
56
+ file.seek(pos)
57
+ lines = (file.read(step) + carry).split("\n", -1)
58
+ carry = pos.zero? ? +"" : lines.shift
59
+ lines.reverse_each do |line|
60
+ at = message_time(line)
61
+ return at if at
62
+ end
63
+ end
64
+ nil
65
+ end
66
+
67
+ def message_time(line)
68
+ return nil unless MESSAGE_TYPES.any? { |type| line.include?("\"type\":\"#{type}\"") }
69
+ record = JSON.parse(line.force_encoding(Encoding::UTF_8))
70
+ return nil unless MESSAGE_TYPES.include?(record["type"]) && record["timestamp"]
71
+ Time.iso8601(record["timestamp"])
72
+ rescue JSON::ParserError, ArgumentError
73
+ nil
74
+ end
75
+ end
76
+ end
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.12.2"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -38,6 +38,7 @@ export type SnapshotRow = {
38
38
  unread?: boolean
39
39
  line?: string
40
40
  wake_at?: number | 'until_woken'
41
+ last_message_at?: number
41
42
  remote?: string
42
43
  color?: string
43
44
  pr?: { short?: string; state?: string; url?: string }
@@ -142,6 +143,7 @@ export function rowOf(section: Section, one: SnapshotRow): InboxRow {
142
143
  isUnread: one.unread === true,
143
144
  line: one.line,
144
145
  wakeAt: one.wake_at,
146
+ lastMessageAt: one.last_message_at,
145
147
  remote: one.remote,
146
148
  color: one.color,
147
149
  pr,
@@ -155,7 +157,10 @@ function wakeLabel(wakeAt: number | 'until_woken'): string {
155
157
  }
156
158
 
157
159
  function ago(seconds: number): string {
158
- return seconds < 60 ? 'just now' : `${Math.floor(seconds / 60)}m ago`
160
+ if (seconds < 60) return 'just now'
161
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
162
+ if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`
163
+ return `${Math.floor(seconds / 86_400)}d ago`
159
164
  }
160
165
 
161
166
  async function inboxFile($: EngineInterface, path: string): Promise<string> {
@@ -337,7 +342,8 @@ export const register: Register = (on, options) => {
337
342
  const chord = await read($, pending)
338
343
  const written = await read($, writtenAt)
339
344
  const release = await read($, newer)
340
- const ageS = (await nowS($)) - written
345
+ const now = await nowS($)
346
+ const ageS = now - written
341
347
  const groups = groupsOf(list, folded, viewing)
342
348
  const flat = groups.flatMap(group => group.items)
343
349
  const at = onList(flat, await read($, cursor))
@@ -497,6 +503,11 @@ export const register: Register = (on, options) => {
497
503
  </Text>
498
504
  </Box>
499
505
  ) : null}
506
+ {one.lastMessageAt !== undefined ? (
507
+ <Box flexShrink={0}>
508
+ <Text dimColor> {ago(now - one.lastMessageAt)}</Text>
509
+ </Box>
510
+ ) : null}
500
511
  </Box>
501
512
  {!isQuiet && one.line !== undefined ? (
502
513
  <Box paddingLeft={4}>
@@ -18,6 +18,7 @@ test("the rows the pane reads are the ones the gem's Snapshot writes", () => {
18
18
  ['settled', 'c3', 'put away', 'done', true],
19
19
  ])
20
20
  expect(rows[1]?.pr).toEqual({ short: '#7', state: 'open', url: 'https://github.com/o/r/pull/7' })
21
+ expect(rows.map(row => row.lastMessageAt)).toEqual([undefined, 1_789_599_700, undefined])
21
22
  expect(rows[0]?.isWaiting).toBe(false)
22
23
  expect(rows.map(row => row.isUnread)).toEqual([true, false, true])
23
24
  })
@@ -87,6 +87,18 @@ test('a poll takes the notice from the snapshot', async ($, on) => {
87
87
  expect(set.update).toBe('v0.6.0 available')
88
88
  })
89
89
 
90
+ test('a row ends with how long ago its last message was sent, dimmed, and one without a transcript ends as it was', async ($, on) => {
91
+ mock.env(on, { HOME: '/Users/me' })
92
+ mock.clock(on, { now: 1_700_000_000_000 })
93
+ const dated = { ...ROWS[1], lastMessageAt: 1_700_000_000 - 5 * 3600 }
94
+ on('state.get', ($, e, next) => (e.key === 'rows' ? seeded([dated, ROWS[2]]) : e.key === 'writtenAt' ? seeded(1_700_000_000) : next(e)))
95
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
96
+ const when = await ui.find({ type: 'Text', text: / 5h ago$/ })
97
+ expect(when?.props.dimColor).toBe(true)
98
+ expect(await ui.findAll({ type: 'Text', text: /ago$/ })).toHaveLength(1)
99
+ await ui.unmount()
100
+ })
101
+
90
102
  test('an unread row that needs you takes the same blue dot as one that only finished', async ($, on) => {
91
103
  mock.env(on, { HOME: '/Users/me' })
92
104
  mock.clock(on, { now: 1_700_000_000_000 })
@@ -29,7 +29,8 @@ export const SNAPSHOT = `{
29
29
  "short": "#7",
30
30
  "state": "open",
31
31
  "url": "https://github.com/o/r/pull/7"
32
- }
32
+ },
33
+ "last_message_at": 1789599700
33
34
  }
34
35
  ],
35
36
  "snoozed": [],
data/mod/types/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export type InboxRow = {
13
13
  isUnread: boolean
14
14
  line?: string
15
15
  wakeAt?: number | 'until_woken'
16
+ lastMessageAt?: number
16
17
  remote?: string
17
18
  color?: string
18
19
  pr?: { short: string; state: string; url: 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.12.2
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron
@@ -167,6 +167,7 @@ files:
167
167
  - lib/claude_inbox/text.rb
168
168
  - lib/claude_inbox/text_buffer.rb
169
169
  - lib/claude_inbox/theme.rb
170
+ - lib/claude_inbox/transcript.rb
170
171
  - lib/claude_inbox/trust.rb
171
172
  - lib/claude_inbox/updater.rb
172
173
  - lib/claude_inbox/vt_screen.rb