claude-inbox 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -3
  3. data/exe/claude-inbox +35 -1
  4. data/lib/claude_inbox/actions.rb +7 -4
  5. data/lib/claude_inbox/agents_client.rb +17 -12
  6. data/lib/claude_inbox/app.rb +64 -6
  7. data/lib/claude_inbox/dialog.rb +40 -0
  8. data/lib/claude_inbox/headless.rb +20 -3
  9. data/lib/claude_inbox/images.rb +1 -1
  10. data/lib/claude_inbox/job_state.rb +16 -2
  11. data/lib/claude_inbox/keymap.rb +1 -1
  12. data/lib/claude_inbox/logs.rb +0 -1
  13. data/lib/claude_inbox/mod.rb +36 -1
  14. data/lib/claude_inbox/mouse.rb +1 -1
  15. data/lib/claude_inbox/new_session_form.rb +8 -3
  16. data/lib/claude_inbox/palette.rb +1 -1
  17. data/lib/claude_inbox/paste.rb +1 -1
  18. data/lib/claude_inbox/peek.rb +5 -9
  19. data/lib/claude_inbox/poller.rb +4 -6
  20. data/lib/claude_inbox/pull_requests.rb +5 -5
  21. data/lib/claude_inbox/reaper.rb +1 -1
  22. data/lib/claude_inbox/remote/http.rb +1 -1
  23. data/lib/claude_inbox/remote/page.html +7 -4
  24. data/lib/claude_inbox/remote/pairing.rb +2 -1
  25. data/lib/claude_inbox/remote/start.rb +1 -0
  26. data/lib/claude_inbox/renderer.rb +1 -1
  27. data/lib/claude_inbox/session_request.rb +6 -4
  28. data/lib/claude_inbox/slash_commands.rb +3 -3
  29. data/lib/claude_inbox/snapshot.rb +8 -3
  30. data/lib/claude_inbox/store/entry.rb +4 -5
  31. data/lib/claude_inbox/store/sections.rb +3 -3
  32. data/lib/claude_inbox/store.rb +1 -2
  33. data/lib/claude_inbox/text_buffer.rb +4 -9
  34. data/lib/claude_inbox/theme.rb +1 -1
  35. data/lib/claude_inbox/updater.rb +163 -0
  36. data/lib/claude_inbox.rb +1 -1
  37. data/mod/hooks/register.tsx +29 -5
  38. data/mod/tests/contract.test.tsx +2 -1
  39. data/mod/tests/pane.test.tsx +63 -2
  40. data/mod/tests/snapshot.fixture.ts +4 -3
  41. data/mod/types/index.d.ts +3 -1
  42. metadata +2 -1
@@ -24,7 +24,7 @@ module ClaudeInbox
24
24
 
25
25
  def feed(raw)
26
26
  out = []
27
- rest = raw.to_s
27
+ rest = raw
28
28
  until rest.empty?
29
29
  if @open
30
30
  body, close, rest = rest.partition(CLOSE)
@@ -16,7 +16,6 @@ module ClaudeInbox
16
16
  @logs = logs
17
17
  @open = false
18
18
  @offset = 0
19
- @selected = nil
20
19
  @session = nil
21
20
  end
22
21
 
@@ -30,8 +29,7 @@ module ClaudeInbox
30
29
  def close = @open = false
31
30
 
32
31
  # Called on every selection change; a fold has no session.
33
- def select(selection, session)
34
- @selected = selection
32
+ def select(session)
35
33
  @session = session
36
34
  @offset = 0
37
35
  @logs.want(session.id) if session&.actionable?
@@ -42,17 +40,16 @@ module ClaudeInbox
42
40
  @offset = [@offset + delta, 0].max
43
41
  end
44
42
 
45
- # `row` is the selected row as the frame shows it, or nil when nothing is
46
- # selected. Answers nil when there is no pane to paint.
43
+ # `row` is the selected row as the frame shows it, or nil on a fold or
44
+ # with nothing selected. Answers nil when there is no pane to paint.
47
45
  def view(row, body_h)
48
- return nil unless @open && @selected&.row?
49
- View.new(scrolled(body(row), body_h), row&.label || @selected.key, subtitle(row))
46
+ return nil unless @open && row
47
+ View.new(scrolled(body(row), body_h), row.label, subtitle(row))
50
48
  end
51
49
 
52
50
  private
53
51
 
54
52
  def body(row)
55
- return ["(nothing selected)"] unless row
56
53
  return interactive_note(row.session) if row.session.interactive?
57
54
  @logs.cached(row.session.id) || ["(loading…)"]
58
55
  end
@@ -62,7 +59,6 @@ module ClaudeInbox
62
59
  end
63
60
 
64
61
  def subtitle(row)
65
- return nil unless row
66
62
  s = row.session
67
63
  parts = [s.effective_state, s.status, s.waiting_for, s.id, s.started_at&.strftime("started %b %-d %H:%M")].compact
68
64
  parts += s.prs.map { |pr| "#{pr.short} #{pr.state&.downcase || "?"}" }
@@ -5,7 +5,7 @@ require_relative "sessions"
5
5
  module ClaudeInbox
6
6
  # Asks `claude agents` for the list off the main thread, every INTERVAL
7
7
  # seconds and on demand, puts what it finds in the store and the snapshot,
8
- # and tells App over the shared queue: [:sessions, list] for each
8
+ # and tells App over the shared queue: [:sessions] after each
9
9
  # hand-over, [:error, msg] when a poll fails, [:notice, text] when the
10
10
  # reaper took something. Between polls it applies what another front end
11
11
  # asked for. Nothing here touches App's state directly; the queue is the
@@ -70,11 +70,9 @@ module ClaudeInbox
70
70
  @queue << [:error, e.message]
71
71
  end
72
72
 
73
- # Applies the requests another front end left, and says whether any landed.
73
+ # Applies the requests another front end left.
74
74
  def apply
75
- return false unless @actions.drain(@store) > 0
76
- write_snapshot
77
- true
75
+ write_snapshot if @actions.drain(@store) > 0
78
76
  end
79
77
 
80
78
  private
@@ -82,7 +80,7 @@ module ClaudeInbox
82
80
  def publish(sessions)
83
81
  @store.update(sessions)
84
82
  write_snapshot
85
- @queue << [:sessions, sessions]
83
+ @queue << [:sessions]
86
84
  end
87
85
 
88
86
  def write_snapshot
@@ -10,7 +10,7 @@ module ClaudeInbox
10
10
  # DRAFT, the same as Claude Code's own cache: OPEN, DRAFT, MERGED, CLOSED,
11
11
  # or nil when nothing has told us yet. `resolved_at` is when it was merged
12
12
  # or closed, in epoch seconds, once gh has said.
13
- PullRequest = Struct.new(:number, :url, :state, :title, :resolved_at) do
13
+ PullRequest = Struct.new(:number, :url, :state, :resolved_at) do
14
14
  def short = number ? "##{number}" : url.to_s.sub(%r{\Ahttps?://(www\.)?}, "")
15
15
 
16
16
  def merged? = state == "MERGED"
@@ -115,7 +115,7 @@ module ClaudeInbox
115
115
  h = JSON.parse(json)
116
116
  state = (h["state"] == "OPEN" && h["isDraft"]) ? "DRAFT" : h["state"]
117
117
  resolved_at = h["closedAt"] && Time.iso8601(h["closedAt"]).to_i
118
- PullRequest.new(number: h["number"], url: h["url"] || url, state: state, title: h["title"], resolved_at: resolved_at)
118
+ PullRequest.new(number: h["number"], url: h["url"] || url, state: state, resolved_at: resolved_at)
119
119
  rescue JSON::ParserError, ArgumentError
120
120
  nil
121
121
  end
@@ -140,7 +140,7 @@ module ClaudeInbox
140
140
  number = url[%r{/pull/(\d+)}, 1]&.to_i
141
141
  cached = resolved[url] || claude_cache[url]
142
142
  PullRequest.new(number: cached&.dig("number") || number, url: url, state: cached&.dig("state"),
143
- title: cached&.dig("title"), resolved_at: cached&.dig("resolved_at"))
143
+ resolved_at: cached&.dig("resolved_at"))
144
144
  end
145
145
 
146
146
  # Same shape as Claude Code's cache, so `seed` reads both alike.
@@ -154,13 +154,13 @@ module ClaudeInbox
154
154
 
155
155
  # Under @mutex.
156
156
  def remember(pr)
157
- resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "title" => pr.title, "resolved_at" => pr.resolved_at}
157
+ resolved[pr.url] = {"number" => pr.number, "state" => pr.state, "resolved_at" => pr.resolved_at}
158
158
  return unless @resolved_path
159
159
  Records.save(@resolved_path, resolved)
160
160
  end
161
161
 
162
162
  def fetch(url)
163
- r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,title,url,closedAt")
163
+ r = Subprocess.capture(@gh, "pr", "view", url, "--json", "number,state,isDraft,url,closedAt")
164
164
  r.success? ? self.class.parse(url, r.out) : nil
165
165
  rescue SystemCallError
166
166
  nil
@@ -73,7 +73,7 @@ module ClaudeInbox
73
73
  row.key
74
74
  rescue AgentsClient::Error => e
75
75
  reason = e.message.lines.first.to_s.strip
76
- @store.mark_reap_failed(row.key, reason)
76
+ @store.mark_reap_failed(row.key)
77
77
  write(log, row, now_i, "kept — #{reason}")
78
78
  nil
79
79
  end
@@ -12,7 +12,7 @@ module ClaudeInbox
12
12
  TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
13
13
 
14
14
  REASONS = {
15
- 100 => "Continue", 200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
15
+ 200 => "OK", 201 => "Created", 400 => "Bad Request", 401 => "Unauthorized",
16
16
  403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict",
17
17
  411 => "Length Required", 413 => "Content Too Large", 415 => "Unsupported Media Type",
18
18
  421 => "Misdirected Request", 422 => "Unprocessable Content", 500 => "Internal Server Error",
@@ -124,6 +124,9 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
124
124
  <label for="permission_mode">Permissions</label>
125
125
  <select id="permission_mode"></select>
126
126
  <p class="error" id="error-permission_mode"></p>
127
+ <label for="color">Color</label>
128
+ <select id="color"></select>
129
+ <p class="error" id="error-color"></p>
127
130
 
128
131
  <div class="toggles">
129
132
  <label class="toggle">Worktree <input type="checkbox" id="worktree" switch></label>
@@ -152,7 +155,7 @@ code { font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
152
155
  const MAX_IMAGES = 8;
153
156
  const MAX_BODY = 16777216;
154
157
  const LONG_EDGE = 2000;
155
- const FIELDS = ["prompt", "name", "cwd", "model", "effort", "permission_mode", "images"];
158
+ const FIELDS = ["prompt", "name", "cwd", "model", "effort", "permission_mode", "color", "images"];
156
159
  const NO_ANSWER = "no answer from the inbox. Is it open with --listen-lan, and is the Mac awake?";
157
160
  const REJECTED = "token rejected: press N in the inbox and pair again";
158
161
  const TOKEN = /^[A-Za-z0-9_-]{32,}$/;
@@ -223,9 +226,9 @@ function fill() {
223
226
  $("cwd").replaceChildren(...dirs.map((d) => new Option(d.label, d.path)), new Option("another directory…", ""));
224
227
  $("cwd").value = typed || !dirs.length ? "" : last || dirs[0].path;
225
228
  if (typed) $("cwd-other").value = last;
226
- for (const [id, list] of [["model", options.models], ["effort", options.efforts], ["permission_mode", options.permission_modes]]) {
229
+ for (const [id, list] of [["model", options.models], ["effort", options.efforts], ["permission_mode", options.permission_modes], ["color", options.colors]]) {
227
230
  const picked = $(id).value;
228
- $(id).replaceChildren(...(list || []).map((v) => new Option(v, v)));
231
+ $(id).replaceChildren(...(list || []).map((v) => new Option(id === "color" && v === "default" ? "none" : v, v)));
229
232
  if ((list || []).includes(picked)) $(id).value = picked;
230
233
  }
231
234
  relabel();
@@ -380,7 +383,7 @@ async function start() {
380
383
  const fields = {
381
384
  prompt: $("prompt").value, name: $("name").value, cwd: cwd(),
382
385
  model: $("model").value, effort: $("effort").value, permission_mode: $("permission_mode").value,
383
- worktree: $("worktree").checked, remote: remote
386
+ color: $("color").value, worktree: $("worktree").checked, remote: remote
384
387
  };
385
388
  const body = JSON.stringify({...fields, images: images.map((image) => ({data: image.url.slice(image.url.indexOf(",") + 1)}))});
386
389
  const size = new Blob([body]).size;
@@ -28,7 +28,8 @@ module ClaudeInbox
28
28
  # Ethernet and Wi-Fi (en*) first: the first private address is the one
29
29
  # `c` copies, and a VM's bridge is no use to a phone.
30
30
  def self.addresses(interfaces = Socket.getifaddrs)
31
- interfaces.select { |i| i.addr&.ip? }.sort_by.with_index { |i, n| [i.name.start_with?("en") ? 0 : 1, n] }.map(&:addr)
31
+ en, rest = interfaces.select { |i| i.addr&.ip? }.partition { |i| i.name.start_with?("en") }
32
+ (en + rest).map(&:addr)
32
33
  end
33
34
 
34
35
  # :off, :on or :block_all; nil without macOS's application firewall.
@@ -58,6 +58,7 @@ module ClaudeInbox
58
58
  models: AgentsClient::MODELS,
59
59
  efforts: AgentsClient::EFFORTS,
60
60
  permission_modes: AgentsClient::PERMISSION_MODES & @allowed_modes,
61
+ colors: AgentsClient::COLORS,
61
62
  dirs: paths.map { |path| {path: path, label: SessionRequest.label(path, paths), defaults: @settings.call(path).to_h} }
62
63
  }
63
64
  end
@@ -64,7 +64,7 @@ module ClaudeInbox
64
64
  ["za", "fold"], ["/", "filter"], ["q", "quit"]
65
65
  ].freeze
66
66
 
67
- def initialize(color: true, home: Dir.home)
67
+ def initialize(color:, home: Dir.home)
68
68
  @p = Pastel.new(enabled: color)
69
69
  @theme = Theme.new(enabled: color)
70
70
  @palette = Palette.new(enabled: color)
@@ -15,11 +15,12 @@ module ClaudeInbox
15
15
  end
16
16
  end
17
17
 
18
- KEYS = %w[prompt name cwd model effort permission_mode worktree remote].freeze
18
+ KEYS = %w[prompt name cwd model effort permission_mode worktree remote color].freeze
19
19
  CHOICES = {
20
20
  model: AgentsClient::MODELS,
21
21
  effort: AgentsClient::EFFORTS,
22
- permission_mode: AgentsClient::PERMISSION_MODES
22
+ permission_mode: AgentsClient::PERMISSION_MODES,
23
+ color: AgentsClient::COLORS
23
24
  }.freeze
24
25
  FLAGS = {true => true, false => false, "yes" => true, "no" => false}.freeze
25
26
  MAX_PROMPT = 100_000
@@ -42,7 +43,8 @@ module ClaudeInbox
42
43
  effort: choice_param(:effort, params["effort"]),
43
44
  permission_mode: choice_param(:permission_mode, params["permission_mode"]),
44
45
  worktree: flag_param(:worktree, params["worktree"]) || false,
45
- remote: flag_param(:remote, params["remote"])
46
+ remote: flag_param(:remote, params["remote"]),
47
+ color: choice_param(:color, params["color"])
46
48
  }
47
49
  end
48
50
 
@@ -78,7 +80,7 @@ module ClaudeInbox
78
80
  # A session's cwd may sit inside a worktree another agent is using;
79
81
  # carrying that into a new prompt would spawn the new agent there too,
80
82
  # writing over the same files. Fall back to the repo it was cut from.
81
- def self.strip_worktree(cwd) = cwd.to_s.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
83
+ def self.strip_worktree(cwd) = cwd.sub(%r{/\.claude/worktrees/[^/]+(?:/.*)?\z}, "")
82
84
 
83
85
  # Where sessions ran lately, newest first, then every directory whose
84
86
  # trust dialog was accepted: the likely choices first. Only an order;
@@ -23,7 +23,7 @@ module ClaudeInbox
23
23
 
24
24
  # The project wins over the home directory when both define the same
25
25
  # name, as it does in the CLI.
26
- def list(cwd:, home: Dir.home)
26
+ def list(cwd:, home:)
27
27
  found = {}
28
28
  add = ->(cmd) { found[cmd.name] ||= cmd }
29
29
  from_dir(File.join(cwd, ".claude")).each(&add)
@@ -44,7 +44,7 @@ module ClaudeInbox
44
44
  commands(File.join(dir, "commands"), prefix:)
45
45
  end
46
46
 
47
- def skills(dir, prefix: nil)
47
+ def skills(dir, prefix:)
48
48
  Dir.glob(File.join(dir, "*", "SKILL.md")).sort.filter_map do |path|
49
49
  meta = frontmatter(path)
50
50
  next if meta["user-invocable"] == "false"
@@ -54,7 +54,7 @@ module ClaudeInbox
54
54
  end
55
55
 
56
56
  # A command file's name is its own; a subdirectory only groups them.
57
- def commands(dir, prefix: nil)
57
+ def commands(dir, prefix:)
58
58
  Dir.glob(File.join(dir, "**", "*.md")).sort.map do |path|
59
59
  name = File.basename(path, ".md")
60
60
  Command.new(qualify(prefix, name), frontmatter(path)["description"].to_s)
@@ -15,20 +15,25 @@ module ClaudeInbox
15
15
 
16
16
  def initialize(path: DEFAULT_PATH)
17
17
  @path = path
18
+ @update = nil
18
19
  @body = nil
19
20
  @written_at = nil
20
21
  @mutex = Mutex.new
21
22
  end
22
23
 
24
+ # The notice for a newer claude-inbox, or nil. Held here rather than passed
25
+ # to each write because the poller writes too, without knowing of it.
26
+ attr_writer :update
27
+
23
28
  # Called from the poller's threads and the screen's alike.
24
29
  def write(sections, now)
25
30
  return unless @path
26
- body = sections.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }
31
+ body = {"sections" => sections.to_h { |name, rows| [name.to_s, rows.map { |row| row_hash(row) }] }, "update" => @update}.compact
27
32
  @mutex.synchronize do
28
33
  return if body == @body && now.to_i - @written_at < HEARTBEAT
29
34
  @body = body
30
35
  @written_at = now.to_i
31
- Records.save(@path, {"version" => 1, "written_at" => @written_at, "sections" => body})
36
+ Records.save(@path, {"version" => 1, "written_at" => @written_at, **body})
32
37
  end
33
38
  end
34
39
 
@@ -39,7 +44,7 @@ module ClaudeInbox
39
44
  pr = s.pr
40
45
  {
41
46
  "id" => row.key, "session" => s.session_id, "label" => row.label, "state" => s.effective_state, "actionable" => s.actionable?,
42
- "waiting" => s.waiting_on_work? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url,
47
+ "waiting" => s.waiting_on_work? || nil, "line" => s.summary, "wake_at" => row.wake_at, "remote" => s.remote_url, "color" => s.color,
43
48
  "pr" => pr && {"short" => pr.short, "state" => pr.state&.downcase, "url" => pr.url}
44
49
  }.compact
45
50
  end
@@ -81,9 +81,9 @@ module ClaudeInbox
81
81
  row = Row.new(session: session, entry: self)
82
82
  @h.delete("wake_at") if row.woken?(now_i)
83
83
  @h.delete("snoozed_at") unless wake_at
84
- @h.delete("settled_at") if settled_at && !row.hand_settled?
85
- @h.delete("acknowledged_at") if acknowledged_at && !row.acknowledged?
86
- @h.delete("revived_at") if revived_at && !row.revived?
84
+ @h.delete("settled_at") unless row.hand_settled?
85
+ @h.delete("acknowledged_at") unless row.acknowledged?
86
+ @h.delete("revived_at") unless row.revived?
87
87
  self
88
88
  end
89
89
 
@@ -133,9 +133,8 @@ module ClaudeInbox
133
133
 
134
134
  # So the Reaper backs off instead of shelling out every poll at a
135
135
  # worktree that is never going to let go of its unpushed commits.
136
- def mark_reap_failed(now, message)
136
+ def mark_reap_failed(now)
137
137
  @h["reap_failed_at"] = now.to_i
138
- @h["reap_error"] = message.to_s.lines.first&.strip
139
138
  end
140
139
  end
141
140
  end
@@ -4,9 +4,9 @@ module ClaudeInbox
4
4
  class Store
5
5
  # The Rows of one poll, grouped and ordered, and where the cursor may land among them.
6
6
  Sections = Struct.new(:pinned, :needs_you, :active, :snoozed, :settled) do
7
- def each_section = SECTIONS.each { |k| yield k, self[k] }
7
+ def each_section(&) = each_pair(&)
8
8
 
9
- def all = SECTIONS.flat_map { |k| self[k] }
9
+ def all = values.flatten(1)
10
10
 
11
11
  def row(selection)
12
12
  all.find { |r| r.key == selection.key } if selection&.row?
@@ -35,7 +35,7 @@ module ClaudeInbox
35
35
 
36
36
  # An empty fold is left out, since there is nothing under it to open.
37
37
  def stops(expanded)
38
- SECTIONS.filter_map do |name|
38
+ members.filter_map do |name|
39
39
  rows = self[name]
40
40
  if !Store.folded?(name, expanded) then [name, rows.select(&:selectable?).map { |r| Selection.row(r.key) }]
41
41
  elsif rows.any? then [name, [Selection.fold(name)]]
@@ -19,7 +19,6 @@ module ClaudeInbox
19
19
  # UNSETTLEABLE on purpose: `failed` earns a permanent row because you
20
20
  # should see it, but after REAP_AFTER of not seeing it, you never will.
21
21
  UNREAPABLE = %w[working].freeze
22
- SECTIONS = %i[pinned needs_you active snoozed settled].freeze
23
22
  NotWritable = Class.new(StandardError)
24
23
  # Sections long enough to be worth hiding behind a fold toggle.
25
24
  FOLDABLE_SECTIONS = %i[snoozed settled].freeze
@@ -102,7 +101,7 @@ module ClaudeInbox
102
101
 
103
102
  def settle(id) = edit(id, "settle") { |e| e.settle(@clock.call) }
104
103
 
105
- def mark_reap_failed(id, message) = edit(id) { |e| e.mark_reap_failed(@clock.call, message) }
104
+ def mark_reap_failed(id) = edit(id) { |e| e.mark_reap_failed(@clock.call) }
106
105
 
107
106
  def toggle_pin(id) = edit(id, "pin") { |e| e.toggle_pin(@clock.call) }
108
107
 
@@ -37,8 +37,7 @@ module ClaudeInbox
37
37
 
38
38
  def initialize(text = "")
39
39
  @widths = Hash.new { |h, s| h[s] = Text.width(s) }
40
- @g = text.grapheme_clusters
41
- @cursor = @g.size
40
+ replace(text)
42
41
  end
43
42
 
44
43
  def to_s = @g.join
@@ -62,7 +61,6 @@ module ClaudeInbox
62
61
  def replace(text)
63
62
  @g = text.grapheme_clusters
64
63
  @cursor = @g.size
65
- self
66
64
  end
67
65
 
68
66
  # Everything before the cursor, as one string.
@@ -89,9 +87,8 @@ module ClaudeInbox
89
87
  place(Pasted.new(@pastes = (@pastes || 0) + 1, text))
90
88
  end
91
89
 
92
- # Handles one keypress — readline's editing keys, plus printable text —
93
- # or returns false when the key is not ours and the caller should deal
94
- # with it (Tab, Enter, Escape, anything else unprintable).
90
+ # Handles one keypress: readline's editing keys, plus printable text.
91
+ # Anything else (Tab, Enter, Escape) is ignored.
95
92
  def press(name, raw)
96
93
  case name
97
94
  when :left then @cursor = [@cursor - 1, 0].max
@@ -104,10 +101,9 @@ module ClaudeInbox
104
101
  when :ctrl_k then delete(@cursor, @g.size - @cursor)
105
102
  when :ctrl_w then delete_word
106
103
  else
107
- return false unless raw.is_a?(String) && raw.match?(/\A[[:print:]]+\z/)
104
+ return unless raw.is_a?(String) && raw.match?(/\A[[:print:]]+\z/)
108
105
  insert(raw)
109
106
  end
110
- true
111
107
  end
112
108
 
113
109
  # The text as a single row of at most `width` columns, scrolled right so
@@ -248,7 +244,6 @@ module ClaudeInbox
248
244
  def place(chip)
249
245
  @g.insert(@cursor, chip)
250
246
  @cursor += 1
251
- chip
252
247
  end
253
248
 
254
249
  def delete(at, length)
@@ -13,7 +13,7 @@ module ClaudeInbox
13
13
  define_method(:"#{hue}_bold") { |text| color(text, hue, bold: true) }
14
14
  end
15
15
 
16
- def initialize(enabled: true)
16
+ def initialize(enabled:)
17
17
  @enabled = enabled
18
18
  end
19
19
 
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "rbconfig"
7
+ require_relative "../claude_inbox"
8
+ require_relative "mod"
9
+ require_relative "subprocess"
10
+
11
+ module ClaudeInbox
12
+ # Finds and installs a newer claude-inbox: from a gem, the latest release on
13
+ # rubygems.org; from a checkout, what main's tracking branch has. Every
14
+ # collaborator is injectable so the spec touches neither network nor git.
15
+ class Updater
16
+ ROOT = File.expand_path("../..", __dir__)
17
+ CACHE_PATH = File.join(Dir.home, ".config", "claude-inbox", "update.json")
18
+ LATEST_URL = "https://rubygems.org/api/v1/versions/claude-inbox/latest.json"
19
+ FRESH_FOR = 24 * 60 * 60
20
+ TIMEOUT = 5
21
+
22
+ # The check could not tell: rubygems.org or the remote is unreachable.
23
+ class Error < StandardError; end
24
+
25
+ # `kind` is :gem or :checkout. `behind` counts a checkout's commits and is
26
+ # nil for a gem.
27
+ Update = Data.define(:kind, :current, :latest, :behind) do
28
+ def notice = (kind == :gem) ? "v#{latest} available" : "main is #{behind} behind"
29
+
30
+ def installing
31
+ (kind == :gem) ? "Installing claude-inbox #{latest} over #{current}..." : "main is #{behind} behind, fast-forwarding to #{latest}..."
32
+ end
33
+ end
34
+
35
+ Applied = Data.define(:success, :output) do
36
+ def success? = success
37
+ end
38
+
39
+ def self.fetch_url(url)
40
+ uri = URI(url)
41
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
42
+ http.get(uri.request_uri)
43
+ end
44
+ raise Error, "#{uri.host} answered #{response.code}" unless response.is_a?(Net::HTTPSuccess)
45
+ response.body
46
+ rescue SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError => e
47
+ raise Error, "#{uri.host} is unreachable: #{e.message}"
48
+ end
49
+
50
+ def initialize(checkout: Mod::CHECKOUT, root: ROOT, version: VERSION, cache_path: CACHE_PATH,
51
+ run: Subprocess.method(:capture), fetch: Updater.method(:fetch_url),
52
+ exec: ->(*argv) { Kernel.exec(*Subprocess.command(*argv)) }, clock: -> { Time.now })
53
+ @checkout = checkout
54
+ @root = root
55
+ @version = version
56
+ @cache_path = cache_path
57
+ @run = run
58
+ @fetch = fetch
59
+ @exec = exec
60
+ @clock = clock
61
+ end
62
+
63
+ def kind = @checkout ? :checkout : :gem
64
+
65
+ # The newer version there is, or nil when this one is the latest. Asks the
66
+ # network at most once a day unless forced: what it last heard is kept in
67
+ # the cache file, and a check within the day compares against that.
68
+ def check(force: false)
69
+ known = force ? nil : fresh_cache
70
+ latest = known || fetch_latest
71
+ write_cache(latest) unless known
72
+ compare(latest)
73
+ end
74
+
75
+ # What a check within the day found, without asking the network: nil
76
+ # when it found nothing newer, or there was no check.
77
+ def cached
78
+ latest = fresh_cache
79
+ compare(latest) if latest
80
+ end
81
+
82
+ # Installs the latest: `gem install`, or a fast-forward of main, refused
83
+ # on another branch or a dirty tree rather than merged over. Success
84
+ # drops the cache, so a launch that auto-updates cannot apply the same
85
+ # version again and again should the launcher still resolve to this one.
86
+ def apply
87
+ applied = @checkout ? apply_checkout : install_gem
88
+ FileUtils.rm_f(@cache_path) if applied.success?
89
+ applied
90
+ end
91
+
92
+ # Replaces this process with a fresh launch on the same arguments. The
93
+ # gem's binstub resolves to the newest installed version, and a checkout's
94
+ # bin/claude-inbox runs whatever main now holds. The child's environment
95
+ # is Subprocess's unbundled one, so a Gemfile.lock cannot pin the binstub
96
+ # to the version just replaced.
97
+ def relaunch(argv)
98
+ launcher = @checkout ? File.join(@root, "bin", "claude-inbox") : "claude-inbox"
99
+ @exec.call(launcher, *argv)
100
+ end
101
+
102
+ private
103
+
104
+ def compare(latest) = @checkout ? checkout_update(latest) : gem_update(latest)
105
+
106
+ def install_gem
107
+ 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)
109
+ end
110
+
111
+ def gem_update(latest)
112
+ return nil unless Gem::Version.new(latest) > Gem::Version.new(@version)
113
+ Update.new(kind: :gem, current: @version, latest: latest, behind: nil)
114
+ end
115
+
116
+ def checkout_update(latest)
117
+ behind = git("rev-list", "--count", "main..#{latest}").to_i
118
+ return nil if behind.zero?
119
+ Update.new(kind: :checkout, current: git("rev-parse", "--short", "main"), latest: latest[0, 7], behind: behind)
120
+ end
121
+
122
+ def fetch_latest
123
+ return fetch_upstream if @checkout
124
+ JSON.parse(@fetch.call(LATEST_URL)).fetch("version")
125
+ rescue JSON::ParserError, KeyError
126
+ raise Error, "rubygems.org sent something other than a version"
127
+ end
128
+
129
+ # Fetches the remote main tracks rather than a fixed URL, so a fork's
130
+ # clone follows the fork.
131
+ def fetch_upstream
132
+ remote = git("config", "branch.main.remote") or raise Error, "main tracks no remote"
133
+ fetched = @run.call("git", "-C", @root, "fetch", "--quiet", remote)
134
+ raise Error, "git fetch #{remote} failed: #{fetched.err.strip}" unless fetched.success?
135
+ git("rev-parse", "main@{upstream}") or raise Error, "main tracks no remote"
136
+ end
137
+
138
+ def apply_checkout
139
+ branch = git("symbolic-ref", "--short", "HEAD")
140
+ return Applied.new(success: false, output: "not on main (on #{branch})") unless branch == "main"
141
+ return Applied.new(success: false, output: "the working tree is dirty") unless git("status", "--porcelain") == ""
142
+ merged = @run.call("git", "-C", @root, "merge", "--ff-only", "--quiet", "main@{upstream}")
143
+ Applied.new(success: merged.success?, output: merged.out + merged.err)
144
+ end
145
+
146
+ def git(*args)
147
+ result = @run.call("git", "-C", @root, *args)
148
+ result.out.strip if result.success?
149
+ end
150
+
151
+ def fresh_cache
152
+ cache = JSON.parse(File.read(@cache_path))
153
+ 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
155
+ nil
156
+ end
157
+
158
+ 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))
161
+ end
162
+ end
163
+ 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.5.0"
4
+ VERSION = "0.6.0"
5
5
  end