claude-inbox 0.11.2 → 0.12.1

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: 86190c0236a4084ec5b00e5044886f6a6b5ab8b51c8e81ac56d7ee9a78619753
4
- data.tar.gz: 763db348bacbb927ec5c5fae6344a02d449ddbf8aeb7dc4460594168e523b5dd
3
+ metadata.gz: 58a50d40ba0f8df6b8b53f3613a75b5982871eaa3c6f9d0c29c46411a1e70323
4
+ data.tar.gz: f8abcc49a7a5d61d339189f79c0f7a62d800b98531ce1e82060efda0a91e0d2e
5
5
  SHA512:
6
- metadata.gz: 532e078a269b17bfafd4eb6bbe83893a8be012740bd7fbac7c4625f074c9ce40df5afb9475e0cfbdd09963200265e35dc4335ecfacadfe4a653a83f78033f369
7
- data.tar.gz: 51632f06152f27dd1708304ecdc4f9eea0ff9dfba18d8ce302f44a8e7b1d260472d56abf80151d1d48f947faf7432ea2ed47133037c43b570972cc171a4b9e24
6
+ metadata.gz: 1c994b3434e3e45fbf60f6c472227fb97610ae8355bb0dd166fb49c83713025fc8be70d2f06660e4475aa0606604a6860ce063c2d721fb836c048e7f18393b86
7
+ data.tar.gz: 591b90b883b64fc4494d8dec115958c618cf88ad13fd5cd3e9a125cbba6364df80b7c370d6d49c6db97bb74e65905882ede46bf9fdcab4b2201d39bc1e582d19
data/README.md CHANGED
@@ -63,9 +63,9 @@ status line. A row with a pull request shows it as `#885 open`, `#885 merged`
63
63
  and so on, with `✗ checks`, `changes requested` or `conflicts` after it when
64
64
  something is wrong, and `+2` after it when the session has two more.
65
65
  A steady `◌` with `idle · 1 shell` means the agent stopped
66
- and is waiting on a shell or sub-agent it started. A `●` marks a session you
67
- haven't attached to since it got blocked, in red, or finished, in blue; one
68
- you have attached to since is dimmed, unless `/color` tints it.
66
+ and is waiting on a shell or sub-agent it started. A blue `●` marks a session you
67
+ haven't attached to since it got blocked or finished; one you have attached
68
+ to since is dimmed, unless `/color` tints it.
69
69
 
70
70
  ## Keys
71
71
 
@@ -105,7 +105,8 @@ Vim bindings. Arrows and the mouse work too.
105
105
  `n` opens a form for the prompt, name, directory, model, effort, permissions,
106
106
  worktree, Remote Control and color, which tints the row as `/color` would.
107
107
  `Tab` moves between fields, `Ctrl-S` starts the session and `Ctrl-O` starts
108
- it and attaches.
108
+ it and attaches. `Option-S` or `Option-O` by mistake only says which key you
109
+ meant; it no longer reads as `Esc` and asks to discard the prompt.
109
110
 
110
111
  Paste an image on macOS, or drop a file into the prompt, and refer to it as
111
112
  `[Image #1]`. Type `/` for a menu of your skills.
@@ -53,7 +53,7 @@ module ClaudeInbox
53
53
 
54
54
  def open_confirm(kind)
55
55
  return unless require_actionable
56
- @modal = Dialog::Confirm.new(kind, @selected.key)
56
+ @modal = Dialog::Confirm.new(kind, @selected.key, label_for(@selected.key))
57
57
  end
58
58
 
59
59
  def stop_session(id)
@@ -64,11 +64,12 @@ module ClaudeInbox
64
64
  end
65
65
 
66
66
  def delete_session(id)
67
- notice("deleting #{id}…")
67
+ label = label_for(id)
68
+ notice("deleting #{label}…")
68
69
  in_background do
69
70
  @client.rm(id)
70
71
  @store.forget(id)
71
- @queue << [:notice, "deleted #{id}"]
72
+ @queue << [:notice, "deleted #{label}"]
72
73
  @poller.soon
73
74
  end
74
75
  end
@@ -38,6 +38,9 @@ module ClaudeInbox
38
38
 
39
39
  def selected_session = session_for(@selected&.key)
40
40
 
41
+ # The alias if one is set, as the row shows it, rather than the bare id.
42
+ def label_for(key) = session_for(key)&.then { |s| @store.row(s).label } || key
43
+
41
44
  def page = [Renderer.body_height(@terminal.size[1]), 1].max
42
45
 
43
46
  def move(delta)
@@ -173,7 +173,12 @@ module ClaudeInbox
173
173
  @input.typed(text).each { |name, c| handle_key(name, c) } if typing
174
174
  end
175
175
 
176
+ # Only the form gives an Option chord a meaning; anywhere else it is the
177
+ # Esc and letter it was before Input named it.
176
178
  def handle_key(name, key)
179
+ if Input.alt?(name) && !(@modal.is_a?(NewSessionForm) && NewSessionForm::SLIPS.key?(name))
180
+ return Input.unglued(key).each { |n, k| handle_key(n, k) }
181
+ end
177
182
  return handle_modal_key(name, key) if @modal
178
183
  return handle_line_key(name, key) if filter_editing?
179
184
 
@@ -38,6 +38,9 @@ module ClaudeInbox
38
38
  # A key and what it does.
39
39
  def hint(name, action) = "#{key(name)} #{@theme.dim(action)}"
40
40
 
41
+ # Esc or q closes a box. Prompt skips this, since q is text there.
42
+ def dismiss?(name, key) = name == :escape || key == "q"
43
+
41
44
  # `s` snooze: one key per choice, and the choice is read from `choice`.
42
45
  class Snooze < Dialog
43
46
  MENU = [
@@ -57,7 +60,7 @@ module ClaudeInbox
57
60
  def lines(_width, _caret) = MENU.map { |k, label, _| " #{key(k)} #{label}" } + ["", " " + hint("esc", "cancel")]
58
61
 
59
62
  def press(name, key)
60
- return :cancel if name == :escape || key == "q"
63
+ return :cancel if dismiss?(name, key)
61
64
  entry = MENU.assoc(key)
62
65
  return nil unless entry
63
66
  @choice = entry[2]
@@ -70,7 +73,6 @@ module ClaudeInbox
70
73
  class PrPicker < Dialog
71
74
  MARGIN = 6
72
75
  KEYS = {open: "o", copy: "y"}.freeze
73
- VERBS = {open: "open", copy: "copy"}.freeze
74
76
 
75
77
  attr_reader :choice
76
78
 
@@ -79,7 +81,7 @@ module ClaudeInbox
79
81
  @prs = [session.pr, *session.prs.reject { |pr| pr.equal?(session.pr) }].first(9)
80
82
  end
81
83
 
82
- def title = " #{VERBS.fetch(kind).capitalize} pull request "
84
+ def title = " #{kind.to_s.capitalize} pull request "
83
85
 
84
86
  # Titles vary, so the box grows to the longest line plus some air,
85
87
  # and never shrinks below a plain dialog.
@@ -87,12 +89,12 @@ module ClaudeInbox
87
89
 
88
90
  def lines(width, _caret)
89
91
  (@prs.each_with_index.map { |pr, i| " #{key((i + 1).to_s)} #{label(pr)}" } +
90
- ["", " #{hint(KEYS.fetch(kind), "#{VERBS.fetch(kind)} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
92
+ ["", " #{hint(KEYS.fetch(kind), "#{kind} #{@prs.first.short}")} · #{key("esc")} #{@theme.dim("cancel")}"])
91
93
  .map { |line| Text.truncate(line, width) }
92
94
  end
93
95
 
94
96
  def press(name, key)
95
- return :cancel if name == :escape || key == "q"
97
+ return :cancel if dismiss?(name, key)
96
98
  @choice = @prs.first if key == KEYS.fetch(kind) || name == :return || name == :enter
97
99
  @choice = @prs[key.to_i - 1] if key.to_s.match?(/\A[1-9]\z/)
98
100
  @choice && :pick
@@ -114,13 +116,19 @@ module ClaudeInbox
114
116
  class Confirm < Dialog
115
117
  TITLES = {stop: " Stop ", delete: " Delete ", adopt: " Adopt "}.freeze
116
118
 
119
+ # `label` is the name the list shows; the id is what App acts on.
120
+ def initialize(kind, id, label = id)
121
+ super(kind, id)
122
+ @label = label
123
+ end
124
+
117
125
  def title = TITLES.fetch(kind)
118
126
 
119
- def lines(_width, _caret)
127
+ def lines(width, _caret)
120
128
  case kind
121
- when :stop then [" Stop session #{id}?", "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
129
+ when :stop then [ask("Stop", width), "", " " + hint("y", "stop it"), " " + hint("esc", "cancel")]
122
130
  when :delete
123
- [" Delete session #{id}?", " Its worktree and conversation", " go with it.",
131
+ [ask("Delete", width), " Its worktree and conversation", " go with it.",
124
132
  "", " " + hint("y", "delete it"), " " + hint("esc", "keep it")]
125
133
  when :adopt
126
134
  [" Pull this session into the daemon?", " Its phone session ends and a",
@@ -130,9 +138,14 @@ module ClaudeInbox
130
138
 
131
139
  def press(name, key)
132
140
  return :confirm if key == "y"
133
- return :cancel if name == :escape || key == "n" || key == "q"
141
+ return :cancel if dismiss?(name, key) || key == "n"
134
142
  nil
135
143
  end
144
+
145
+ private
146
+
147
+ # One line, so a long name is cut rather than wrapped mid-word.
148
+ def ask(verb, width) = " #{verb} #{Text.truncate(@label.to_s, width - Text.width(verb) - 4)}?"
136
149
  end
137
150
 
138
151
  # The first launch without the Claude Code pane asks once whether to add
@@ -9,6 +9,15 @@ module ClaudeInbox
9
9
  # keys. Hides what tty-reader does with ESC, echo and a paste, so App sees
10
10
  # events and not the raw bytes.
11
11
  class Input
12
+ # macOS sends Option-s as "\es", so an Esc glued to one lowercase letter
13
+ # is taken as Option held with it.
14
+ ALT = /\A\e([a-z])\z/
15
+
16
+ def self.alt?(name) = name.to_s.start_with?("alt_")
17
+
18
+ # An Option chord as the quick Esc-then-letter it may also have been.
19
+ def self.unglued(raw) = [[:escape, "\e"], [raw[1], raw[1]]]
20
+
12
21
  def initialize(input, out)
13
22
  @input = input
14
23
  @reader = TTY::Reader.new(input: input, output: out, interrupt: :noop)
@@ -51,7 +60,7 @@ module ClaudeInbox
51
60
  out
52
61
  end
53
62
 
54
- def key_name(key) = @reader.console.keys[key] || key
63
+ def key_name(key) = @reader.console.keys[key] || key[ALT, 1]&.then { |c| :"alt_#{c}" } || key
55
64
 
56
65
  # tty-reader glues ESC to whatever arrives within 100ms, so a fast
57
66
  # "esc gg" comes in as one unknown key "\egg". Vim hands make that
@@ -59,6 +68,7 @@ module ClaudeInbox
59
68
  def split_keys(key)
60
69
  return [key] if key.size <= 1 || @reader.console.keys.key?(key)
61
70
  return [key] unless key.start_with?("\e")
71
+ return [key] if key.match?(ALT)
62
72
  ["\e"] + key[1..].chars
63
73
  end
64
74
  end
@@ -48,7 +48,7 @@ module ClaudeInbox
48
48
  # => JobState, or nil when there is no readable file for this id.
49
49
  def self.read(id, jobs_dir:)
50
50
  return nil unless id
51
- new(JSON.parse(File.read(File.join(jobs_dir, id, "state.json"))))
51
+ new(JSON.parse(File.read(job_file(id, jobs_dir))))
52
52
  rescue JSON::ParserError, SystemCallError
53
53
  nil
54
54
  end
@@ -59,13 +59,16 @@ module ClaudeInbox
59
59
  # must not report a started session as failed.
60
60
  # ponytail: a daemon write racing this one can drop the color; re-apply on poll if that shows up.
61
61
  def self.write_color(id, color, jobs_dir:)
62
- path = File.join(jobs_dir, id, "state.json")
62
+ path = job_file(id, jobs_dir)
63
63
  File.write("#{path}.tmp", JSON.generate(JSON.parse(File.read(path)).merge("color" => color)))
64
64
  File.rename("#{path}.tmp", path)
65
65
  rescue JSON::ParserError, SystemCallError
66
66
  nil
67
67
  end
68
68
 
69
+ def self.job_file(id, jobs_dir) = File.join(jobs_dir, id, "state.json")
70
+ private_class_method :job_file
71
+
69
72
  attr_reader :pr_urls, :transcript_path, :color, :intent, :bridge_id, :created_at
70
73
 
71
74
  def initialize(hash)
@@ -35,6 +35,10 @@ module ClaudeInbox
35
35
  # editable field spends these on its own text instead.
36
36
  CYCLE = {:left => -1, :right => 1, "h" => -1, "l" => 1, " " => 1}.freeze
37
37
 
38
+ # Option slips for Ctrl on the two submit keys. Option-s arrives as Esc
39
+ # then s, and that Esc asked to discard the prompt.
40
+ SLIPS = {alt_s: "that's option-s — ctrl-s starts it", alt_o: "that's option-o — ctrl-o starts and attaches"}.freeze
41
+
38
42
  def initialize(cwd:, dirs: [], home: Dir.home, clipboard: Images.method(:from_clipboard))
39
43
  @p = Pastel.new(enabled: true)
40
44
  @theme = Theme.new
@@ -83,6 +87,7 @@ module ClaudeInbox
83
87
  when :escape then return escape_pressed
84
88
  when :ctrl_s then return submit(attach: false)
85
89
  when :ctrl_o then return submit(attach: true)
90
+ when *SLIPS.keys then @error = SLIPS[name]
86
91
  when :ctrl_v then paste_clipboard
87
92
  when :tab then complete_dir || move(1)
88
93
  when :down then move(1)
@@ -169,10 +174,10 @@ module ClaudeInbox
169
174
 
170
175
  def footer
171
176
  return @p.dim("starting session…") if @busy
172
- return hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
177
+ return @theme.hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
173
178
  return @theme.red(@error) if @error
174
179
  return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
175
- return hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
180
+ return @theme.hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
176
181
  keys =
177
182
  case focused.kind
178
183
  when :multiline then [["⏎", "newline"], ["^V", "image"]]
@@ -182,7 +187,7 @@ module ClaudeInbox
182
187
  keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
183
188
  keys += [["^S", "start"], ["^O", "start & open"],
184
189
  ["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
185
- hints(keys)
190
+ @theme.hints(keys)
186
191
  end
187
192
 
188
193
  # The App's `claude --bg` call failed after `:start`/`:start_and_attach`
@@ -299,8 +304,6 @@ module ClaudeInbox
299
304
  rows
300
305
  end
301
306
 
302
- def hints(pairs) = pairs.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
303
-
304
307
  def field_label(f)
305
308
  on = f.equal?(focused)
306
309
  (on ? @theme.yellow_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
@@ -200,11 +200,11 @@ module ClaudeInbox
200
200
  authenticated(slot) if slot
201
201
  Http.write(io, *answer(request, io, via))
202
202
  rescue Http::Error => e
203
- Http.write(io, e.status, Http::JSON_TYPE.merge(e.headers), JSON.generate(e.body))
203
+ refuse(io, e)
204
204
  rescue => e
205
205
  # A message can name a path under the home directory, which is
206
206
  # nobody's business before the token checks out.
207
- Http.write(io, 500, Http::JSON_TYPE, JSON.generate(error: authorized ? Http.utf8(e.message) : "internal error"))
207
+ refuse(io, Http::Error.new(500, authorized ? e.message : "internal error"))
208
208
  end
209
209
 
210
210
  private
@@ -322,23 +322,27 @@ module ClaudeInbox
322
322
  if slot[:busy] then @turning_away -= 1
323
323
  else
324
324
  @alive -= 1
325
- release_unauthenticated(slot[:via]) unless slot[:authenticated]
325
+ release_unauthenticated(slot)
326
326
  end
327
327
  end
328
328
  end
329
329
 
330
330
  # Under the mutex. A host leaves the hash at zero, so it holds only
331
331
  # hosts connected now.
332
- def release_unauthenticated(via)
332
+ def release_unauthenticated(slot)
333
+ return if slot[:authenticated]
334
+ via = slot[:via]
333
335
  @unauthenticated[via] -= 1
334
336
  @unauthenticated.delete(via) if @unauthenticated[via].zero?
335
337
  end
336
338
 
339
+ def refuse(io, error) = Http.write(io, error.status, Http::JSON_TYPE.merge(error.headers), JSON.generate(error.body))
340
+
337
341
  # `handle` answers every failure it can; what is left is the socket
338
342
  # itself going away, and a thread must not report that on stderr.
339
343
  def serve(sock, slot)
340
344
  if slot[:busy]
341
- Http.write(sock, 503, Http::JSON_TYPE.merge("Retry-After" => "2"), JSON.generate(error: "busy: try again in a moment"))
345
+ refuse(sock, Http::Error.new(503, "busy: try again in a moment", headers: {"Retry-After" => "2"}))
342
346
  else
343
347
  handle(sock, via: slot[:via], slot: slot)
344
348
  end
@@ -353,7 +357,7 @@ module ClaudeInbox
353
357
 
354
358
  def authenticated(slot)
355
359
  @mutex.synchronize do
356
- release_unauthenticated(slot[:via]) unless slot[:authenticated]
360
+ release_unauthenticated(slot)
357
361
  slot[:authenticated] = true
358
362
  end
359
363
  end
@@ -41,7 +41,7 @@ module ClaudeInbox
41
41
  @asking = false
42
42
  return (key == "y") ? :rotate : nil
43
43
  end
44
- return :cancel if name == :escape || key == "q"
44
+ return :cancel if dismiss?(name, key)
45
45
  return nil unless @snapshot.call.state == :listening
46
46
  case key
47
47
  when "c" then :copy
@@ -87,7 +87,7 @@ module ClaudeInbox
87
87
  def key_lines(s)
88
88
  return [" paired phones get 401 until they pair again —", " rotate? y/n"] if @asking
89
89
  keys = (s.state == :listening) ? [%w[c copy], ["r", "new token"], %w[esc close]] : [%w[esc close]]
90
- [" " + keys.map { |k, action| "#{key(k)} #{@theme.dim(action)}" }.join(" ")]
90
+ [" " + @theme.hints(keys)]
91
91
  end
92
92
  end
93
93
  end
@@ -198,7 +198,7 @@ module ClaudeInbox
198
198
  text =
199
199
  if view.filter_editing then " " + @theme.cyan_bold("/") + view.filter.row(width - 2, cursor: caret)
200
200
  elsif view.filter then " " + @theme.cyan_bold("/") + view.filter.to_s + @p.dim(" esc clears")
201
- else " " + footer_keys(view).map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
201
+ else " " + @theme.hints(footer_keys(view))
202
202
  end
203
203
  Text.pad(text, width)
204
204
  end
@@ -250,7 +250,7 @@ module ClaudeInbox
250
250
  def loading_state(width, height, waited, tick)
251
251
  return [] if waited < LOADING_QUIET
252
252
  block = [
253
- centered(@theme.cyan_bold(SPINNER[tick % SPINNER.size]), width),
253
+ centered(@theme.cyan_bold(spinner(tick)), width),
254
254
  "",
255
255
  centered(tray(tick), width),
256
256
  "",
@@ -263,6 +263,8 @@ module ClaudeInbox
263
263
  [""] * [(height - block.size) / 2, 0].max + block
264
264
  end
265
265
 
266
+ def spinner(tick) = SPINNER[tick % SPINNER.size]
267
+
266
268
  TRAY_SLOTS = 9
267
269
 
268
270
  # A dot bouncing between the ends of a tray, one slot per tick.
@@ -302,8 +304,8 @@ module ClaudeInbox
302
304
  quiet = section == :settled || (live && row.read?)
303
305
  unread = live && row.unread?
304
306
  # A terminal cannot be attached to, so it is never read: while it waits on you it keeps its dot.
305
- alert = live && s.needs_you? && (unread || !s.actionable?)
306
- marker = marker_for(sel, unread, alert)
307
+ dotted = unread || (live && s.needs_you? && !s.actionable?)
308
+ marker = marker_for(sel, dotted)
307
309
  glyph = glyph_for(s, section, tick)
308
310
  meta = meta_for(row, section, now)
309
311
 
@@ -328,13 +330,12 @@ module ClaudeInbox
328
330
  [Text.pad(first, width), Text.pad(detail, width)]
329
331
  end
330
332
 
331
- # The caret takes the column. Otherwise one filled dot asks for a look:
332
- # red while the row needs you, blue when it only finished. Read, it
333
- # clears, and the meta still says needs you.
334
- def marker_for(sel, unread, alert)
333
+ # The caret takes the column. Otherwise a blue dot asks for a look, in
334
+ # every state: glyph and meta already say needs you in red, so the dot
335
+ # only says unread. Read, it clears.
336
+ def marker_for(sel, dotted)
335
337
  if sel then @theme.yellow_bold("▶")
336
- elsif alert then @theme.red_bold("●")
337
- elsif unread then @theme.blue("●")
338
+ elsif dotted then @theme.blue("●")
338
339
  else " "
339
340
  end
340
341
  end
@@ -362,7 +363,7 @@ module ClaudeInbox
362
363
  case s.effective_state
363
364
  when "blocked" then @theme.red_bold("?")
364
365
  when "failed" then @theme.red_bold("✗")
365
- when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(SPINNER[tick % SPINNER.size])
366
+ when "working" then s.waiting_on_work? ? @theme.yellow("◌") : @theme.yellow(spinner(tick))
366
367
  when "done" then @theme.green("✓")
367
368
  when "stopped" then @p.dim("■")
368
369
  else @p.dim("·")
@@ -26,6 +26,7 @@ module ClaudeInbox
26
26
  FLAGS = {true => true, false => false, "yes" => true, "no" => false}.freeze
27
27
  MAX_PROMPT = 100_000
28
28
  MAX_NAME = 100
29
+ NOT_ONE_LINE = /[[:cntrl:]\u2028\u2029]/
29
30
 
30
31
  # The hash NewSessionForm#values builds, before `resolve`, from a
31
32
  # request's fields, or Invalid. "default" is nil. An unknown key is
@@ -123,7 +124,7 @@ module ClaudeInbox
123
124
  def self.name_param(value)
124
125
  return nil if value.nil?
125
126
  name = string_param(:name, value).strip
126
- raise Invalid.new(:name, "a name is one line") if name.match?(/[[:cntrl:]\u2028\u2029]/)
127
+ raise Invalid.new(:name, "a name is one line") if name.match?(NOT_ONE_LINE)
127
128
  raise Invalid.new(:name, "the name is over #{MAX_NAME} characters") if name.length > MAX_NAME
128
129
  name.empty? ? nil : name
129
130
  end
@@ -133,19 +134,21 @@ module ClaudeInbox
133
134
  parts = path.split("/").reject(&:empty?)
134
135
  (1..parts.size).each do |n|
135
136
  label = parts.last(n).join("/")
136
- return label if dirs.one? { |other| other.end_with?("/#{label}") }
137
+ return label if dirs_called(dirs, label).one?
137
138
  end
138
139
  path
139
140
  end
140
141
 
142
+ def self.dirs_called(dirs, label) = dirs.select { |dir| dir.end_with?("/#{label}") }
143
+
141
144
  # A path is taken as given. Anything else names one of `dirs` by its
142
145
  # `label`.
143
146
  def self.cwd_param(value, dirs)
144
147
  cwd = string_param(:cwd, value || "").strip
145
148
  raise Invalid.new(:cwd, "a directory is required") if cwd.empty?
146
- raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(/[[:cntrl:]\u2028\u2029]/)
149
+ raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(NOT_ONE_LINE)
147
150
  return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
148
- found = dirs.select { |dir| dir.end_with?("/#{cwd}") }
151
+ found = dirs_called(dirs, cwd)
149
152
  raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
150
153
  raise Invalid.new(:cwd, "#{cwd} could be any of #{found.join(", ")}") if found.size > 1
151
154
  found.first
@@ -173,6 +176,6 @@ module ClaudeInbox
173
176
  text
174
177
  end
175
178
 
176
- private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param
179
+ private_class_method :prompt_param, :name_param, :cwd_param, :choice_param, :flag_param, :string_param, :dirs_called
177
180
  end
178
181
  end
@@ -28,8 +28,8 @@ module ClaudeInbox
28
28
  case head
29
29
  when nil then nil
30
30
  when "tonight", "evening" then rest.nil? ? next_at(now, EVENING) : nil
31
- when "tomorrow" then (hm = rest ? clock(rest) : MORNING) && at(now, 1, hm)
32
- when WEEKDAY then (hm = rest ? clock(rest) : MORNING) && weekday(now, WEEKDAYS[head[0, 3]], hm)
31
+ when "tomorrow" then (hm = time_of_day(rest)) && at(now, 1, hm)
32
+ when WEEKDAY then (hm = time_of_day(rest)) && weekday(now, WEEKDAYS[head[0, 3]], hm)
33
33
  else (hm = clock(text)) && next_at(now, hm)
34
34
  end
35
35
  end
@@ -49,6 +49,9 @@ module ClaudeInbox
49
49
  [hour, minute]
50
50
  end
51
51
 
52
+ # A day's snooze: the time after the word, else 9am.
53
+ def self.time_of_day(rest) = rest ? clock(rest) : MORNING
54
+
52
55
  # That day at `hm`, or a week on when it has already passed.
53
56
  def self.weekday(now, wday, hm)
54
57
  t = at(now, (wday - now.wday) % 7, hm)
@@ -65,6 +68,6 @@ module ClaudeInbox
65
68
  day = now.to_date + days
66
69
  Time.new(day.year, day.month, day.day, hour, minute, 0, now.utc_offset).to_i
67
70
  end
68
- private_class_method :clock, :weekday, :next_at, :at
71
+ private_class_method :clock, :time_of_day, :weekday, :next_at, :at
69
72
  end
70
73
  end
@@ -46,31 +46,29 @@ module ClaudeInbox
46
46
  # `x` holds through the session finishing; any other state change afterwards lifts it.
47
47
  def hand_settled?
48
48
  return false unless entry&.settled_at
49
- session.finished? || entry.state_since.to_i <= entry.settled_at.to_i
49
+ session.finished? || unchanged_since?(entry.settled_at)
50
50
  end
51
51
 
52
52
  # Attaching marks the current state seen: out of Needs You, but into
53
53
  # Active rather than Settled, until the state changes again.
54
- def acknowledged?
55
- entry&.acknowledged_at && entry.state_since.to_i <= entry.acknowledged_at.to_i
56
- end
54
+ def acknowledged? = unchanged_since?(entry&.acknowledged_at)
55
+
56
+ # Finished or needing you, so it carries a read or unread mark. Only a
57
+ # session you can attach to counts, since nothing else ever clears it.
58
+ def markable? = session.actionable? && (session.finished? || session.needs_you?)
57
59
 
58
- # Finished or needing you since you last attached. Only a session you
59
- # can attach to counts, since nothing else ever clears the mark.
60
+ # Finished or needing you since you last attached.
60
61
  def unread?
61
- return false unless session.actionable?
62
- return false unless session.finished? || session.needs_you?
63
- entry&.last_visited_at.nil? || entry.state_since.to_i > entry.last_visited_at.to_i
62
+ return false unless markable?
63
+ !unchanged_since?(entry&.last_visited_at)
64
64
  end
65
65
 
66
66
  # A finished or blocked session you have attached to since: you have seen what it wants.
67
- def read? = session.actionable? && (session.finished? || session.needs_you?) && !unread?
67
+ def read? = markable? && !unread?
68
68
 
69
69
  # `u` overrides hand-settle and the PR rule alike, and holds until the
70
70
  # state actually changes rather than just for the next poll.
71
- def revived?
72
- entry&.revived_at && entry.state_since.to_i <= entry.revived_at.to_i
73
- end
71
+ def revived? = unchanged_since?(entry&.revived_at)
74
72
 
75
73
  # Failing checks or requested changes on the PR want you back the way
76
74
  # a blocked session does, once the agent has stopped working on it.
@@ -119,6 +117,10 @@ module ClaudeInbox
119
117
  else :active
120
118
  end
121
119
  end
120
+
121
+ private
122
+
123
+ def unchanged_since?(at) = at && entry.state_since.to_i <= at.to_i
122
124
  end
123
125
  end
124
126
  end
@@ -73,5 +73,6 @@ module ClaudeInbox
73
73
  ensure
74
74
  writer.close
75
75
  end
76
+ private_class_method :feed
76
77
  end
77
78
  end
@@ -38,6 +38,9 @@ module ClaudeInbox
38
38
  # or choice — the inverse of `color`, which puts the hue on the text.
39
39
  def pill(text, hue) = "\e[38;5;#{BG};48;5;#{HUES.fetch(hue)}m#{text}\e[0m"
40
40
 
41
+ # A footer's key hints: `n new N pair`.
42
+ def hints(pairs) = pairs.map { |k, d| cyan_bold(k) + " " + dim(d) }.join(" ")
43
+
41
44
  private
42
45
 
43
46
  def color(text, hue, bold: false)
@@ -6,6 +6,7 @@ require "net/http"
6
6
  require "rbconfig"
7
7
  require_relative "../claude_inbox"
8
8
  require_relative "mod"
9
+ require_relative "records"
9
10
  require_relative "subprocess"
10
11
 
11
12
  module ClaudeInbox
@@ -33,6 +34,8 @@ module ClaudeInbox
33
34
  end
34
35
 
35
36
  Applied = Data.define(:success, :output) do
37
+ def self.from(result) = new(success: result.success?, output: result.out + result.err)
38
+
36
39
  def success? = success
37
40
  end
38
41
 
@@ -105,7 +108,7 @@ module ClaudeInbox
105
108
 
106
109
  def install_gem
107
110
  result = @run.call(Gem.ruby, File.join(RbConfig::CONFIG["bindir"], "gem"), "install", "claude-inbox")
108
- Applied.new(success: result.success?, output: result.out + result.err)
111
+ Applied.from(result)
109
112
  end
110
113
 
111
114
  def gem_update(latest)
@@ -140,7 +143,7 @@ module ClaudeInbox
140
143
  return Applied.new(success: false, output: "not on main (on #{branch})") unless branch == "main"
141
144
  return Applied.new(success: false, output: "the working tree is dirty") unless git("status", "--porcelain") == ""
142
145
  merged = @run.call("git", "-C", @root, "merge", "--ff-only", "--quiet", "main@{upstream}")
143
- Applied.new(success: merged.success?, output: merged.out + merged.err)
146
+ Applied.from(merged)
144
147
  end
145
148
 
146
149
  def git(*args)
@@ -149,15 +152,14 @@ module ClaudeInbox
149
152
  end
150
153
 
151
154
  def fresh_cache
152
- cache = JSON.parse(File.read(@cache_path))
155
+ cache = Records.read(@cache_path)
153
156
  cache["latest"] if cache["kind"] == kind.to_s && @clock.call.to_i - cache["checked_at"].to_i < FRESH_FOR
154
- rescue SystemCallError, JSON::ParserError, TypeError
157
+ rescue SystemCallError, TypeError
155
158
  nil
156
159
  end
157
160
 
158
161
  def write_cache(latest)
159
- FileUtils.mkdir_p(File.dirname(@cache_path))
160
- File.write(@cache_path, JSON.generate(checked_at: @clock.call.to_i, kind: kind, latest: latest))
162
+ Records.save(@cache_path, {checked_at: @clock.call.to_i, kind: kind, latest: latest})
161
163
  end
162
164
  end
163
165
  end
@@ -24,18 +24,18 @@ module ClaudeInbox
24
24
  return true unless @path
25
25
  FileUtils.mkdir_p(File.dirname(@path))
26
26
  file = File.open(@path, File::RDWR | File::CREAT, 0o600)
27
- return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
27
+ return hold(file, role) if try_lock(file)
28
28
  holder_role, holder = file.read.split
29
29
  return refuse(file) unless role == "inbox" && holder_role == "headless" && headless?(holder.to_i)
30
30
  Process.kill("TERM", holder.to_i)
31
31
  EVICT_TRIES.times do
32
- return hold(file, role) if file.flock(File::LOCK_EX | File::LOCK_NB)
32
+ return hold(file, role) if try_lock(file)
33
33
  sleep 0.1
34
34
  end
35
35
  refuse(file)
36
36
  rescue Errno::ESRCH, Errno::EPERM
37
37
  # The holder the file names is gone already: the lock is free.
38
- file.flock(File::LOCK_EX | File::LOCK_NB) ? hold(file, role) : refuse(file)
38
+ try_lock(file) ? hold(file, role) : refuse(file)
39
39
  end
40
40
 
41
41
  private
@@ -48,6 +48,8 @@ module ClaudeInbox
48
48
  command.include?("claude-inbox") && command.split.include?("--headless")
49
49
  end
50
50
 
51
+ def try_lock(file) = file.flock(File::LOCK_EX | File::LOCK_NB)
52
+
51
53
  # After reading the holder the position sits at the end, and truncate does
52
54
  # not move it, so the write is rewound first or it lands after NUL padding.
53
55
  def hold(file, role)
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.11.2"
4
+ VERSION = "0.12.1"
5
5
  end
@@ -460,17 +460,16 @@ export const register: Register = (on, options) => {
460
460
  // A finished or blocked row you have attached to since dims, as the gem's Active section draws it,
461
461
  // unless it is tinted: faint ink on a /color background is unreadable.
462
462
  const isRead = !isQuiet && one.actionable && ['done', 'stopped', 'blocked', 'failed'].includes(one.state) && !one.isUnread
463
- // One dot asks for a look: red while the row needs you, blue when it only finished. A terminal
464
- // cannot be attached to, so it is never read and keeps its dot while it waits.
463
+ // A blue dot asks for a look in every state, as the gem draws it: the glyph already says needs you
464
+ // in red. A terminal cannot be attached to, so it is never read and keeps its dot while it waits.
465
465
  const needsYou = one.state === 'blocked' || one.state === 'failed'
466
- const alert = !isQuiet && needsYou && (one.isUnread || !one.actionable)
467
- const dot = alert || (one.isUnread && !isQuiet)
466
+ const dot = !isQuiet && (one.isUnread || (needsYou && !one.actionable))
468
467
  const text = one.wakeAt !== undefined ? `${one.label} · ${wakeLabel(one.wakeAt)}` : one.label
469
468
  const tint = one.color ? TINT[one.color] : undefined
470
469
  return (
471
470
  <Box key={viewKey(item)} flexDirection="column">
472
471
  <Box>
473
- <Text color={isHere ? 'yellow' : alert ? 'red' : 'blue'}>{isHere ? '› ' : dot ? '● ' : ' '}</Text>
472
+ <Text color={isHere ? 'yellow' : 'blue'}>{isHere ? '› ' : dot ? '● ' : ' '}</Text>
474
473
  <Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
475
474
  {GLYPH[one.state] ?? '·'}{' '}
476
475
  </Text>
@@ -87,6 +87,19 @@ 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('an unread row that needs you takes the same blue dot as one that only finished', async ($, on) => {
91
+ mock.env(on, { HOME: '/Users/me' })
92
+ mock.clock(on, { now: 1_700_000_000_000 })
93
+ on('state.get', ($, e, next) => {
94
+ if (e.key === 'rows') return seeded([{ ...ROWS[0], isUnread: true }, { ...ROWS[0], id: 'a2', isUnread: true }])
95
+ if (e.key === 'writtenAt') return seeded(1_700_000_000)
96
+ return next(e)
97
+ })
98
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
99
+ // The caret takes one row's column; the header's red needs-you chip is no row's dot.
100
+ expect((await ui.findAll({ type: 'Text', text: '● ' })).filter(dot => dot.props.color === 'blue')).toHaveLength(1)
101
+ })
102
+
90
103
  test("rows sit under the gem's sections, Settled folded until za opens it", async ($, on) => {
91
104
  mock.env(on, { HOME: '/Users/me' })
92
105
  mock.clock(on, { now: 1_700_000_000_000 })
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.11.2
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron