claude-inbox 0.11.2 → 0.12.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: 86190c0236a4084ec5b00e5044886f6a6b5ab8b51c8e81ac56d7ee9a78619753
4
- data.tar.gz: 763db348bacbb927ec5c5fae6344a02d449ddbf8aeb7dc4460594168e523b5dd
3
+ metadata.gz: cfb9f33ca25bbd7a081fcab7316c724af065f9d078cd9a329e4c5d2a4f25d49d
4
+ data.tar.gz: d733d6f8cd233cc37c0d4b0e43abe6f48936b17e1731b9d8d696990f8ded4046
5
5
  SHA512:
6
- metadata.gz: 532e078a269b17bfafd4eb6bbe83893a8be012740bd7fbac7c4625f074c9ce40df5afb9475e0cfbdd09963200265e35dc4335ecfacadfe4a653a83f78033f369
7
- data.tar.gz: 51632f06152f27dd1708304ecdc4f9eea0ff9dfba18d8ce302f44a8e7b1d260472d56abf80151d1d48f947faf7432ea2ed47133037c43b570972cc171a4b9e24
6
+ metadata.gz: a4c6efddf42c8363e1f383941f854ec2ba35e38f2cfe687749aa4b18b88efe1fbb400cdcb2e3e727493daabda7c7b82753d637078b397ca7f8992305d6bb3df6
7
+ data.tar.gz: 7ddfe74e86730f795581cc454fc4c09a0b7f8b1b8f49c45e7e9cbabb504387c7411902177eb84b8578d36653b421dc0794afb01f6ae55a848fa2a657073b56e0
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
 
@@ -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
@@ -130,7 +132,7 @@ module ClaudeInbox
130
132
 
131
133
  def press(name, key)
132
134
  return :confirm if key == "y"
133
- return :cancel if name == :escape || key == "n" || key == "q"
135
+ return :cancel if dismiss?(name, key) || key == "n"
134
136
  nil
135
137
  end
136
138
  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)
@@ -169,10 +169,10 @@ module ClaudeInbox
169
169
 
170
170
  def footer
171
171
  return @p.dim("starting session…") if @busy
172
- return hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
172
+ return @theme.hints([["y", "discard"], ["esc", "keep editing"]]) if @confirm_discard
173
173
  return @theme.red(@error) if @error
174
174
  return @p.dim("matches: ") + @candidates.join(@p.dim(" ")) if @candidates
175
- return hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
175
+ return @theme.hints([["↑ ↓", "choose"], ["⇥ ⏎", "pick"], ["esc", "close"]]) if menu
176
176
  keys =
177
177
  case focused.kind
178
178
  when :multiline then [["⏎", "newline"], ["^V", "image"]]
@@ -182,7 +182,7 @@ module ClaudeInbox
182
182
  keys.unshift(["^U", "recent"]) if focused.key == :cwd && @places.any?
183
183
  keys += [["^S", "start"], ["^O", "start & open"],
184
184
  ["⇥", (focused.key == :cwd) ? "complete / next" : "next"], ["esc", "cancel"]]
185
- hints(keys)
185
+ @theme.hints(keys)
186
186
  end
187
187
 
188
188
  # The App's `claude --bg` call failed after `:start`/`:start_and_attach`
@@ -299,8 +299,6 @@ module ClaudeInbox
299
299
  rows
300
300
  end
301
301
 
302
- def hints(pairs) = pairs.map { |k, d| @theme.cyan_bold(k) + " " + @p.dim(d) }.join(" ")
303
-
304
302
  def field_label(f)
305
303
  on = f.equal?(focused)
306
304
  (on ? @theme.yellow_bold("▶ ") : " ") + (on ? @p.bold(Text.pad(f.label, 14)) : @p.dim(Text.pad(f.label, 14)))
@@ -322,14 +322,16 @@ 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
@@ -353,7 +355,7 @@ module ClaudeInbox
353
355
 
354
356
  def authenticated(slot)
355
357
  @mutex.synchronize do
356
- release_unauthenticated(slot[:via]) unless slot[:authenticated]
358
+ release_unauthenticated(slot)
357
359
  slot[:authenticated] = true
358
360
  end
359
361
  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
@@ -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
@@ -143,7 +144,7 @@ module ClaudeInbox
143
144
  def self.cwd_param(value, dirs)
144
145
  cwd = string_param(:cwd, value || "").strip
145
146
  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]/)
147
+ raise Invalid.new(:cwd, "a directory is one line") if cwd.match?(NOT_ONE_LINE)
147
148
  return File.expand_path(cwd) if cwd.match?(%r{\A(/|~(/|\z))})
148
149
  found = dirs.select { |dir| dir.end_with?("/#{cwd}") }
149
150
  raise Invalid.new(:cwd, "no directory called #{cwd}") if found.empty?
@@ -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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Byron